All articles

Retiring long-lived GitHub PATs for a per-job mint

Replacing standing GitHub PATs across three orgs with GitHub OIDC brokered through OpenBao into hour-lived App installation tokens, and the traps that arrangement produced.

A GitHub personal access token stored as a repository secret has three problems at once. It does not expire on any schedule you control. It carries whatever scope you gave it the day you created it, not the scope the job in front of you actually needs. And there is no way to ask “is this still being used” without grepping every workflow that might reference it by name, because a secret’s name and the credential behind it are two different things that can drift apart without anyone noticing.

The drift is not hypothetical. A single PAT ended up referenced under three different secret names across a handful of repositories. Two of the names were found and deleted in the same sweep; the third survived, because the sweep matched on secret name rather than on credential value, and the PAT underneath it was revoked anyway as part of retiring the two names that were found. A dashboard-publishing job using the third name then started failing with a plain “bad credentials” error, and it stayed broken for two days before anyone looked, because the job sitting next to it in the same pipeline used an unrelated token and kept reporting green. Sweeping for a secret by name proves nothing about what else might be holding the same value.

The fix I ended up with, across three GitHub orgs, replaces every long-lived PAT with a credential that is minted fresh for each job and is worthless an hour later.

What replaced it

The end-to-end path is: a GitHub Actions job asks GitHub for a signed OIDC token describing that specific run, presents it to a self-hosted OpenBao instance, OpenBao checks the claims on that token against a role it holds, and if they match it hands back a short-lived OpenBao token. That OpenBao token is then used to read a path that mints a GitHub App installation token, scoped to one repository and one set of permissions, expiring in about an hour. Nothing durable sits in the repository at any point in that chain.

A workflow that wants this needs permissions: id-token: write, without which the OIDC request returns nothing and the whole thing fails at the first step. The token GitHub issues carries claims describing the run: which repository, which ref, which workflow file, whether the runner is GitHub-hosted or self-hosted, and so on. The OpenBao side is a JWT auth role with bound_claims pinned to the values a legitimate run for that repository should present, something like:

{
  "bound_claims_type": "string",
  "bound_claims": {
    "repository_owner_id": "<the org's numeric id>",
    "repository_id": "<the repo's numeric id>",
    "ref": "refs/heads/main",
    "runner_environment": "github-hosted"
  },
  "token_policies": ["<the policy naming exactly one token-mint path>"],
  "token_ttl": "5m",
  "token_max_ttl": "10m"
}

A run whose claims do not match every one of those is rejected at login, before OpenBao does anything else. A run that does match gets an OpenBao token good for a few minutes, which it uses once to read github-app-broker/token/<permission-set> and get back a real GitHub token, along with an expires_at about an hour out. The permission set behind that path is itself scoped: it names one GitHub App installation, one or more specific repositories, and an explicit list of permissions such as contents=write or pull_requests=write, never a wildcard. Binding both the identity check and the resulting token’s scope this tightly is what makes the whole thing safe to spread across dozens of repositories at once, because a compromised workflow file in one repository cannot mint a token that reaches any other.

Two actions do this work: one mints the installation token; a sibling reads generic secrets out of an OpenBao key-value store the same way, for things that are not GitHub tokens at all, such as Cloudflare API tokens or code-signing material. Both authenticate the same way, through the OIDC exchange above.

Reaching OpenBao at all differs by where the job runs. GitHub-hosted runners join the private network the OpenBao instance sits on as an ephemeral node for the duration of the job, authenticated through its own separate federated identity. Self-hosted runners already sit inside that network and reach OpenBao through an in-cluster egress path instead. Those are two different problems with two different fixes, and getting them confused looks, from a failing job, exactly like an OpenBao problem when it is actually a networking one.

The plugin is a fork, and it needed to be

OpenBao does not ship a secrets engine for minting GitHub App tokens. One exists for HashiCorp Vault: martinbaillie/vault-plugin-secrets-github, by Martin Baillie, licensed Apache-2.0. I did not write that engine. What I did was fork it as rknightion/openbao-plugin-secrets-github and port it to run on OpenBao rather than Vault.

The reason for the fork is compatibility, not a feature gap. Upstream is written against the HashiCorp Vault SDK. OpenBao is a separate project with its own SDK, and a plugin has to be built against that SDK to load as an OpenBao secrets engine at all; a Vault-SDK plugin simply does not mount. The port itself turned out to be a mechanical import-path swap across a few dozen call sites, replacing the Vault SDK imports with the equivalent OpenBao ones, with no logic changes needed because OpenBao’s client library kept the same symbol names the plugin’s entry point already called. The full test suite passed unmodified once the imports were swapped.

The fork keeps an upstream git remote pointing at Martin Baillie’s repository, specifically so a later rebase onto his fixes stays practical rather than turning into a second manual diff every time. The LICENSE file is retained verbatim, and a NOTICE file credits Martin Baillie by name and states what changed. None of the token-minting logic is mine. What I added was making it load on the secrets store I run.

The plugin is distributed as a signed OCI image and loaded declaratively: a plugin stanza in OpenBao’s config names the image, a version, and the sha256 of the binary inside it, and OpenBao downloads and verifies it itself rather than anything being installed by hand.

The traps

A shared permission set needs its role to be named explicitly

Almost everywhere, a permission set, its OpenBao ACL policy and the JWT role calling it all share one name, and the mint action defaults the role it asks for to whatever permission-set name it was given. That default is correct right up until one permission set has to serve several repositories at once, which happens whenever many repositories all fire the same downstream action, such as several repositories all dispatching a build in one shared hub repository. There, one shared permission set is correct, but each consuming repository still needs its own role, pinning its own repository id, so the names necessarily diverge.

The first rollout of exactly that shape passed only the permission-set name to the mint action and let the role default to the same string. No role by that name existed, and the login call returned a plain 400. The mint step failed and stayed failed until the workflow was corrected to pass the role name explicitly. So whenever a permission set is shared across more than one consumer, the workflow has to name its role rather than relying on the default.

contents: write, not actions: write

One consumer’s whole job is firing a repository_dispatch at another repository to kick off a build there. The obvious-looking permission for that is actions: write, since it is triggering a workflow. It is the wrong one. The endpoint that creates a repository_dispatch event returned x-accepted-github-permissions: contents=write on the actual call, and a GitHub App token scoped only to actions: write cannot make that request succeed. And the workflow’s own default GITHUB_TOKEN cannot make this call at all, regardless of permissions, because it has no reach into a different repository. Any cross-repository dispatch needs a token minted for that purpose from the start; there was never a version of this that could stay on the ambient token.

Line-based masking and a multi-line secret

The action that reads generic secrets out of OpenBao’s key-value store masks each returned value in the job log with GitHub’s ::add-mask:: mechanism. That mechanism is line-based: given a value with more than one line, it registers only the first line as a secret and prints every remaining line to the log as ordinary, unmasked output. The trap is that nothing looks wrong while every secret in play is single-line, base64-shaped material, which is what most API tokens are. Hand the same helper a multi-line value, an RSA private key or a service-account JSON blob, and its body goes to the log in cleartext while the step reports success and the masked first line sits above it looking like the mechanism worked. On a scheduled workflow that repeats every run until somebody reads a log.

The fix was structural. The value is now captured to a file first, and the action asserts every line of it is a single well-formed ::add-mask:: command before emitting any of it, failing on a count if anything else appears rather than on content, so a multi-line value fails the step instead of leaking through it. There is no equivalent to ::add-mask:: that works without the value crossing standard output at all, so vetting what reaches the log is the only defence available. A masking helper that has only ever seen single-line secrets has not been tested against the case that matters.

The mount’s plugin version and the catalog version are two different pointers

This one is about the broker infrastructure rather than any individual token, and it turns a routine version bump into a silent outage. Bumping the plugin version in the declarative config re-pins what OpenBao’s catalog considers current. It does not, on its own, move the separate version pointer the actual mounted path is running against. Skip the second step and OpenBao logs that the old version is no longer in the catalog and silently declines to recreate the mount route; the path returns a plain 404 with nothing in the startup logs pointing at why. The fix is a second explicit command retuning the mount to the new version, followed by a restart, and confirming afterwards that both the reported version and the binary checksum actually running match what was configured, not just what the config file says.

A related trap sits one layer further back: an early build of the plugin shipped with its version string never set at link time, so the outgoing GitHub API client sent an empty User-Agent header on every request, and GitHub’s own edge rejected it outright. The plugin mounted cleanly and reported itself running; only the actual token mint failed. That build is permanently unusable and must never be re-pinned to. A broken release here shows up as a clean deploy of the wrong artefact, not as a red build.

What this does not solve

The one shape this arrangement cannot serve at all is a workflow that has to authenticate on a pull request rather than on a push to the main branch. Every role described above binds its ref claim to refs/heads/main, and GitHub’s own claim shape works against that on a PR run: workflow_ref gains a @refs/pull/<n>/merge suffix that a role bound to the plain branch ref will never match, so the login is rejected before OpenBao does anything else. This is fixable per-consumer with a narrower role, one bound on event_name=pull_request plus a glob over the exact workflow file, rather than on ref at all, and exactly one workflow across the whole estate needed that treatment.

It is not always the right fix, though, and one attempt showed why. A repository needed CI, and its CI’s entire purpose is checking pull requests before merge. A full broker consumer was provisioned for it: a permission set, a policy, a role, everything checked out structurally identical to a working consumer elsewhere. It was also completely useless, because the one thing that repository’s CI needs a credential for is authenticating on a pull request, and no role bound the way this one is could ever do that. The correct answer there was not a PR-shaped OpenBao role at all, but a plain read-only deploy key on the repository being checked out, which works on a same-repo PR branch without needing any of this. The whole broker consumer was deleted the same hour it was created. So before wiring any of this up, ask whether the consumer needs the credential on a pull request. If it does, it wants a deploy key rather than a broker role, and the broker path will look complete and pass every check right up until the first PR runs through it.

Beyond that specific limitation, this arrangement trades one kind of risk for another rather than eliminating risk outright. Every consumer now depends on a single self-hosted service being up, unsealed, and reachable, which is a new single point of failure that a plain repository secret never had. And the one-credential-behind-many-names problem that started this piece is a property of how secrets get referenced, not of PATs specifically; nothing about minting tokens on demand stops a future workflow from stashing a minted value somewhere it outlives its intended hour, so the discipline that matters is still watching what a job actually does with a credential once it has one, not just how that credential was obtained.