At some point every team running more than a handful of repositories in GitHub Actions reaches for the same workaround: a Personal Access Token, minted by a person, pasted into an organization secret, and used by every workflow that needs to touch another repository.
The workaround that becomes the problem
GITHUB_TOKEN, the token Actions gives every workflow run automatically, is scoped to the repository the workflow lives in. That is correct and safe by default, but it also means it cannot check out a second repository, cannot trigger a workflow in a different repo, and cannot comment on a PR anywhere else. The moment your CI needs to touch more than one repository, GITHUB_TOKEN stops being enough.
So someone creates a PAT. It gets scoped generously, because nobody wants to come back and widen it later when a new workflow needs one more permission. It gets tied to whichever person created it, so when they leave, half of CI quietly breaks or, worse, keeps running on a token that belongs to someone who no longer works there. It lives as long as nobody remembers to rotate it, which in practice is until GitHub emails everyone that it is about to expire, or until it leaks.
We have a few hundred repositories at this point. Managing PATs at that scale is not a policy problem you solve by writing a rotation reminder into a wiki page. It is a structural problem: the credential model is wrong for what CI actually needs, which is a token, once, scoped to one action, gone shortly after.
What we already had
We had already solved half of this. oidc-token-cli gave every tool a single, boring way to fetch an OIDC token instead of inventing its own auth. GitHub Actions already issues one of those for free: every workflow run can request a signed OIDC identity token that says who it is, which repository it belongs to, and which workflow file triggered it. Nobody has to store anything for that identity to exist, it is minted per run and expires with the run.
What was missing was the other side of the exchange: something that takes that OIDC token, decides whether the caller is allowed to do what it is asking, and if so hands back a real GitHub credential scoped to exactly that. That is gh-token-broker.
🔑 OAuth 2.0 STS that mints least-privilege GitHub App tokens for Actions workflows, gated by custom CEL policies.
How the exchange works
gh-token-broker is an OAuth 2.0 Security Token Service. A workflow sends it the OIDC identity token GitHub Actions already gave it, plus the resource and permissions it wants. The broker validates that token against GitHub’s own OIDC provider, so it knows the claims are genuine and not forged. It then evaluates a set of CEL policies against the caller’s claims (which repository, which owner, which workflow ref triggered the run) and the requested parameters (which target resource, which permissions). If a policy matches, the broker calls the GitHub App installation token API and returns a token scoped to exactly what was requested, never broader.
OIDC token exchange for a scoped GitHub App token
sequenceDiagram participant W as Workflow (Actions) participant B as gh-token-broker participant G as GitHub OIDC provider participant A as GitHub App API W->>B: OIDC token + resource + scope B->>G: validate OIDC token G-->>B: claims verified B->>B: evaluate CEL allow-policies B->>A: request installation token (scoped) A-->>B: short-lived App token B-->>W: scoped token
From the workflow side, this rides on the same oidc-token CLI from the earlier post. You add the standard Actions OIDC permission:
permissions: id-token: writeThen exchange it for a scoped token in a step:
TOKEN=$(oidc-token \ --grant-type token-exchange \ --subject-token-source github-actions \ --issuer https://broker.example.com/ \ --resource repo:acme/app \ --scope "contents:read")The github-actions source pulls the run’s OIDC identity token automatically, which is the only reason the workflow needs id-token: write and nothing else. Nothing about that command touches a secret the workflow author has to create or rotate. The only thing stored anywhere is the broker’s own GitHub App private key, held once, on infrastructure we run.
Policies are additive allow-rules written in CEL, evaluated against the caller’s claims and the request. A minimal one that lets any workflow in acme/ci-tools read the contents of acme/app looks like this:
request.resource == "repo:acme/app" &&request.scope == "contents:read" &&claims.repository == "acme/ci-tools"If nothing matches, nothing is issued. There is no default-allow path.
How this compares to the alternatives
We were not the first to think about this. A few other approaches solve overlapping parts of the same problem, and each is a reasonable choice depending on what you are optimizing for.
| Approach | Where policy lives | What you distribute | Hosting |
|---|---|---|---|
| gh-token-broker | Centralized, CEL, one place we operate | Nothing, App key stays with the broker | Self-hosted |
| octo-sts | Per-repo trust policy file committed into each target repo | Nothing per repo, but policy is scattered across every repo | Chainguard-hosted or self-hosted |
actions/create-github-app-token | No policy layer, the workflow just asks | The App private key, as a secret, in every repo that uses it | GitHub Actions marketplace action |
| Vault GitHub secrets engine | Vault policy, centralized | Vault token or auth method per consumer | Self-hosted Vault |
octo-sts is the closest thing to what we built, and it is a solid choice if a per-repo trust policy file fits how your organization already thinks about repo ownership. The tradeoff is that authorization ends up distributed: to audit what a repository is allowed to request, you have to go look at that repository’s own trust policy file, and there are as many of those as there are protected repos. We wanted one place to read to answer “what can call what,” so we centralized it as CEL policy in the broker instead.
actions/create-github-app-token is the simplest option and the official one. If you only have a handful of repos and trust every workflow that has the App key, it works fine and needs no extra infrastructure. The cost is that the App’s private key gets distributed as a secret into every repository that uses it, which is exactly the “secret sprinkled everywhere” problem we were trying to get away from, and there is no policy layer deciding what each caller is actually allowed to request.
HashiCorp Vault’s GitHub secrets engine is a reasonable choice if you already run Vault and want GitHub token issuance to live alongside your other secrets engines. It is more general-purpose than gh-token-broker, and that generality is either an advantage or overhead depending on whether Vault is already part of your stack.
The security model, in short
Tokens the broker issues are short-lived, on the order of the GitHub App installation token TTL, which is an hour at most. They are scoped to exactly the resource and permission requested, never to “whatever the App can do.” And they are institution-bound: the credential belongs to the GitHub App the organization controls, not to whichever engineer happened to generate a PAT three years ago. When someone leaves, nothing breaks, because no token was ever tied to them in the first place.
Auditing gets simpler for the same reason. Every issued token traces back to one CEL policy match, one workflow run, one OIDC claim set, all in the broker’s logs. Compare that to auditing a PAT, where the answer to “what could this touch” is “whatever permissions were checked when it was created, however long ago that was.”
Where this fits
This is the third piece of the same pattern: oidc-token-cli gives every tool one way to get an OIDC identity, and a broker on the other end trades that identity for a scoped, short-lived credential once a CEL policy says yes. We already used it once, for self-hosted tunnels with frp, the same OIDC-plus-CEL house pattern pointed at tunnels. gh-token-broker points it at GitHub, so CI stops needing a person’s PAT to reach across repositories.