---
title: "Your GitHub Actions Don't Need Secrets"
description: "How a custom OIDC broker, reusable workflows, and layered frameworks turned GitHub Actions into a self-service platform for 300 engineering teams."
date: 2026-06-05
tags: ["GitHub Actions", "Platform Engineering", "Automation", "Azure", "Case Study"]
canonical: https://htek.dev/articles/your-github-actions-dont-need-secrets
---
## Copy-Paste Workflows Don't Scale

Every platform team hits the same wall. You start with a handful of repos, each with bespoke CI/CD workflows. Twelve months later you have 200 repos, and every deployment pipeline is a snowflake. Engineers copy YAML from Slack threads. Secrets sprawl across repositories. Nobody can answer "who deployed what, and with which permissions?"

I hit this wall at a Fortune 500 energy company, managing CI/CD for an enterprise DevOps platform. We went from 2–3 teams to **300 teams across roughly 1,000 repositories** — all on GitHub Actions — in under two years. The secret wasn't better YAML. It was treating Actions as a **platform engineering problem**, starting from identity.

GitHub Actions processed [11.5 billion minutes in 2025 alone](https://github.blog/news-insights/product-news/lets-talk-about-github-actions/) — up 35% year-over-year — with 71 million jobs running per day on its re-architected backend. At that scale, the question isn't "does Actions work?" — it's "how do you govern it without becoming a bottleneck?"

Here's the recipe: **identify bottlenecks → codify them → scale identity.**

## The Subject Claim Problem (And Why I Built an OIDC Broker)

GitHub Actions supports [OpenID Connect (OIDC) federation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) for passwordless cloud authentication. In theory, every workflow gets a short-lived token scoped to its repo. No more long-lived secrets sitting in repository settings.

In practice? The `sub` (subject) claim in GitHub's OIDC token has a structural limitation: when you call a reusable workflow, the token's subject reflects the *caller* context, not the *called* workflow. This makes it difficult to enforce "only this approved deployment workflow can authenticate to production Azure resources" — because the subject claim doesn't consistently identify which reusable workflow is executing.

GitHub has since added [`job_workflow_ref`](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#using-openid-connect-with-reusable-workflows) as a custom claim and introduced [immutable subject claims](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens) (enforced for new repos, renames, and transfers after June 18, 2026 — existing repos can opt in now). But when I was building this platform, those features didn't exist yet.

**My solution: a custom OIDC server acting as an identity broker.**

The broker accepts a GitHub Actions OIDC token, validates it against the caller's identity, checks the requested scope against a centralized policy, and issues a *new* scoped token for Azure. Think of it as an identity translation layer sitting between GitHub and your cloud provider.

![OIDC broker architecture showing GitHub Actions token exchange through a centralized policy-checked identity translation layer to Azure](/images/articles/your-github-actions-dont-need-secrets/oidc-broker-architecture.webp)
*The custom OIDC broker validates GitHub tokens, checks centralized policy, and issues least-privilege Azure credentials — eliminating long-lived secrets entirely.*

At the heart of the broker is a standard OAuth2 client credentials flow — one `/token` endpoint, three operations:

```typescript
// OIDC broker — token exchange endpoint (routes/github.ts, condensed)
router.post('/github/.well-known/token', async (req, res) => {
  const { client_assertion, type = 'job-workflow-ref' } = req.body;

  // 1. Verify the GitHub Actions OIDC token against GitHub's public JWKS
  const payload = await new Promise((resolve, reject) =>
    jwk.verify(client_assertion, githubJwksClient, {
      issuer: 'https://token.actions.githubusercontent.com',
    }, (err, decoded) => err ? reject(err) : resolve(decoded))
  );

  // 2. Gate access — only your enterprise can use this broker
  if (payload.enterprise !== '<your-enterprise-slug>') {
    return res.status(403).send('Unauthorized');
  }

  // 3. Derive a controlled sub claim from job_workflow_ref.
  //    This is the fix for the sub-claim problem: the BROKER controls the subject,
  //    not GitHub, so Azure federated credential policies are reliable for
  //    reusable workflows regardless of who called them.
  const sub = payload.job_workflow_ref.replace('refs/heads/', '');
  // → "org/repo/.github/workflows/deploy.yml@main"

  // 4. Re-issue a JWT signed with the broker's RSA private key.
  //    Azure trusts this because the broker's /jwks endpoint is registered
  //    as a federated identity credential on the Entra ID application.
  const token = jwk.sign({
    aud:  'api://AzureADTokenExchange',
    iss:  `https://${req.headers.host}/github`,
    sub,
    jti:  randomUUID(),
    exp:  Math.floor(Date.now() / 1000) + 3600,
  }, privateKey, { algorithm: 'RS256', keyid: brokerKeyThumbprint });

  res.json({ id_token: token });
});
```

The `sub` derivation on step 3 is the entire point. GitHub's raw OIDC token produces an unpredictable subject when reusable workflows are involved — the broker re-signs with `job_workflow_ref` as a stable, auditable identity. Azure's federated credential policy can now reliably match on "only *this* approved workflow can authenticate to production."

```yaml
# A team's CD workflow — the entire Azure auth chain is one step
name: 🚀 CD

on:
  release:
    types: [created]
  pull_request:
    branches: [main]

env:
  ENVIRONMENT: ${{ github.event_name == 'pull_request' && 'dev' || 'prod' }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ env.ENVIRONMENT }}
    permissions:
      id-token: write   # required for OIDC token request
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: 🔑 Login to Azure
        uses: <your-org>/platform-framework/actions/azure-login@main
        with:
          iam-name: ${{ env.ENVIRONMENT }}        # 'dev' or 'prod' — matches iam.yml job name
          iam-connection-name: AZURE_CREDENTIALS   # matches iam.yml credential binding
          secrets-as-json: ${{ toJson(secrets) }}  # platform reads clientId from here
          vars-as-json: ${{ toJson(vars) }}         # platform reads tenantId/subscriptionId from here

      - name: Deploy to Azure App Service
        uses: azure/webapps-deploy@v2
        with:
          app-name: ${{ vars.APP_NAME }}
          package: .
```

This single composite action became the foundation everything else was built on. Every team authenticates the same way. Every permission is centrally governed. No secrets in repos.

## The Framework Stack: Each Framework = GitHub App + Identity + Reusable Workflow

With centralized identity solved, I layered five frameworks on top — each following the same architecture pattern:

![Layered platform architecture showing identity foundation supporting 5 framework pillars (IAM, Secrets, IAC, Docs, Config) consumed by 300 teams](/images/articles/your-github-actions-dont-need-secrets/framework-stack.webp)
*Each framework follows the same pattern: GitHub App + Entra ID App + Reusable Workflow — all built on the shared identity layer.*

| Framework | Purpose | What Teams Define |
|-----------|---------|-------------------|
| **IAM** | Identity and access management | RBAC roles in a YAML workflow file |
| **Secrets** | Central Key Vault management | Secret names and scopes |
| **IAC** | Infrastructure as Code (Bicep → Azure) | Bicep modules and parameters |
| **Docs** | Centralized documentation deployment | Markdown content |
| **Config** | Configuration management | Environment variables and app settings |

Each framework consists of three components:

1. **A GitHub App** — provides the automation identity and webhook triggers
2. **An Entra ID (Azure AD) app** — holds the federated credential with scoped permissions
3. **A reusable workflow** — the actual pipeline logic teams call from their repos

### The IAM Framework: The Crown Jewel

The IAM framework is where this architecture pays off most dramatically. Here's the team experience:

```yaml
# .github/workflows/iam.yml
# Merge this PR → the IAM framework auto-provisions Entra ID apps,
# federated credentials, and RBAC assignments for every environment.
name: 📋 Platform | Identity and Access Management

on:
  workflow_dispatch:
  push:
    branches: [main]
    paths: ['.github/workflows/iam.yml']

jobs:
  dev:
    uses: <your-org>/platform-iam/.github/workflows/define.yml@main
    with:
      name: dev
      definitions: |
        github/env/dev/AZURE_CREDENTIALS
        rbac/subscriptions/<dev-subscription-id>/Contributor
        rbac/subscriptions/<dev-subscription-id>/Azure Deployment Stack Owner
        rbac/subscriptions/<hub-subscription-id>/resourceGroups/rg-dns-hub/Private DNS Zone Contributor

  prod:
    uses: <your-org>/platform-iam/.github/workflows/define.yml@main
    with:
      name: prod
      definitions: |
        github/env/prod/AZURE_CREDENTIALS
        rbac/subscriptions/<prod-subscription-id>/Contributor
        rbac/subscriptions/<prod-subscription-id>/Azure Deployment Stack Owner
        rbac/subscriptions/<hub-subscription-id>/resourceGroups/rg-dns-hub/Private DNS Zone Contributor
```

When a team pushes this file, the IAM framework:

1. Creates an Entra ID application registration
2. Configures federated credentials tied to their specific repo
3. Stores the client ID as a repository variable
4. Sets up RBAC assignments in Azure

The team then calls the login composite action with a version tag — that's it. Zero portal clicks. Zero tickets. Full auditability.

**Result**: a new team goes from "we need Azure access" to "we're deploying to production" in a single PR review cycle.

## The Scaling Arc: Patterns That Actually Matter

A [2025 practitioner survey of 419 GitHub Actions users](https://pith.science/paper/2601.11299) found that while reusable *actions* see heavy adoption, reusable *workflows* remain underutilized — largely because teams fear versioning complexity and loss of control. This matches what I observed: teams resist reuse unless the abstraction is genuinely simpler than copy-paste.

The patterns that made reuse stick:

### 1. Composite Actions as the Building Block

Composite actions (not reusable workflows) are where you start. They're simpler to version, test, and compose. Our `login-to-azure` action is called by every framework's reusable workflow — it's the atomic unit.

### 2. Reusable Workflows as Contracts

Reusable workflows define the *contract* — "this is how you deploy infrastructure" or "this is how docs get published." GitHub recently expanded these to support [10 levels of nesting and 50 workflow calls per run](https://github.blog/news-insights/product-news/lets-talk-about-github-actions/), which validates the deep composition patterns we built early.

### 3. Trigger Type Literacy

The most underrated skill in Actions at scale: understanding trigger types deeply. `workflow_call` vs `workflow_dispatch` vs `repository_dispatch` each has fundamentally different trust boundaries and token behaviors. Most engineers treat them interchangeably — and then get bitten by permission escalation or silent failures.

### 4. Central Repos as the Source of Truth

Each framework lives in a dedicated repo. Teams never fork — they call with version tags. Updates propagate instantly. Governance lives in one place.

## From CI/CD to Intelligent System

The final evolution was adding intelligence on top of the platform. Using webhooks and GitHub Issues, we built:

- **AI-powered issue categorization**: incoming platform issues get triaged automatically
- **Automated release notes**: framework releases generate changelogs from PR descriptions
- **Policy drift detection**: nightly runs compare actual Azure state against declared YAML

None of this required a separate tool. The identity layer, the reusable workflows, and the event system were already there. Intelligence was just another consumer of the same platform primitives.

## Your Playbook: The Three-Step Recipe

If you're staring at 50+ repos with snowflake workflows, here's the path:

![Three-step enterprise scaling playbook: 1. Solve Identity First, 2. Build Frameworks Not Pipelines, 3. Scale Identity Not Humans](/images/articles/your-github-actions-dont-need-secrets/three-step-playbook.webp)
*The three-step recipe that scales from 3 teams to 1,000 repos: centralize identity, codify frameworks, let identity scale itself.*

1. **Solve identity first.** Whether you use GitHub's native OIDC (with the newer [`job_workflow_ref` claims](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) and [repository custom properties](https://github.blog/changelog/2026-03-12-actions-oidc-tokens-now-support-repository-custom-properties)) or build a broker — centralized, auditable identity is your foundation.

2. **Build frameworks, not pipelines.** Each framework should be composable (composite action → reusable workflow → team YAML). Teams should define *what* they need, not *how* to get it.

3. **Scale the identity, not the humans.** When a new team onboards, they shouldn't need a meeting. They define their requirements in YAML, the framework provisions everything, and identity flows through automatically.

[AstraZeneca scaled 5,000 developers across 20,000 repositories](https://theapplied.co/use-cases/astrazeneca-github-copilot-drug-discovery) on GitHub Enterprise using similar patterns — reusable Actions libraries with security baked in by default. The pattern works whether you're 50 engineers or 5,000.

## The Bottom Line

GitHub Actions at enterprise scale isn't a YAML problem — it's a platform engineering problem. The organizations that scale are the ones that treat identity as infrastructure, workflows as contracts, and frameworks as products with versioned APIs.

I've written extensively about [platform engineering with GitHub](/articles/platform-engineering-github-internal-developer-platform) and how [GitHub Actions debugging](/articles/github-actions-debugging-guide) fits into this picture. If you're building internal developer platforms, the identity-first approach is the one architecture decision that makes everything else possible.

The recipe hasn't changed since I scaled to 1,000 repos: **identify bottlenecks → codify them → scale identity.** Everything else is implementation detail.
