Series · 3 partsWhen Access Checks FailPart 1 · You are here
- 1The Pipeline Has No Secret. It Still Has a Cloud Identity.You are here
- 2The Signature Is Valid. The Token Still Belongs Somewhere Else.
- 3The Request Stayed Server-Side. The Credential Did Not.
CI/CD OIDC trust in 60 seconds
The best CI/CD security change most teams make is deleting the long-lived cloud key from their repository and replacing it with OpenID Connect. The workflow requests a short-lived identity token, the cloud exchanges it for temporary credentials, and there is no secret to rotate, leak, or find in an old build log.
That is a real improvement. It also creates a new failure mode that looks clean in every secret scanner: the credential is gone, but the right to mint one is broader than anyone realised.
OIDC does not remove trust from the pipeline. It moves trust into claims and policy conditions. If those conditions say “any workflow in this organisation” when the intended meaning was “the reviewed deployment job on the protected production branch,” the pipeline has become a cloud identity provider with an organisation-wide enrolment policy.
The secret was never the authorisation boundary
A static access key collapses authentication and authorisation into one string: whoever has the value can use the principal. OIDC separates them. GitHub authenticates the workflow and issues a signed token; AWS decides whether the claims in that token are allowed to assume a specific role.
The second decision is the load-bearing one.
For GitHub Actions, the AWS trust policy normally evaluates two claims:
audidentifies the intended token exchange, commonlysts.amazonaws.comwhen using the official AWS credentials action.subidentifies the repository context that requested the token: repository, branch, tag, pull request, or environment, depending on the job.
The role’s permission policy answers what the session may do. Its trust policy answers which workflow may become that session. A least-privilege permission policy attached to a production role does not compensate for a trust policy that lets the wrong repository assume it. Least privilege begins one document earlier than most reviews start.
One wildcard changes the unit of compromise
AWS refuses a new GitHub trust policy whose sub condition is absent, null, or only a bare
wildcard. That guardrail is useful, but it does not make every accepted pattern narrow. This
condition is syntactically constrained and operationally enormous:
{
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:example-org/*"
}
}
It means the security boundary is no longer one deployment repository. It is every current and future repository whose subject matches that organisation pattern, plus every actor and workflow path that can cause one of those repositories to request a token.
The mistake is easy to make because the policy reads like central governance: “our GitHub
organisation may deploy.” But organisations contain archived projects, migration tooling,
internal experiments, forks, and repositories with very different branch protections. A
single low-sensitivity repository becomes relevant to production the moment its workflow
can satisfy the same sub pattern as the deployment repository.
The reliable boundary is the smallest stable claim set the provider and cloud can both enforce: exact repository identity, the expected audience, and either a protected branch or a protected GitHub environment. When an environment appears in the job, the default subject shape changes to include that environment rather than the branch. That is not cosmetic; the trust policy and the repository’s environment protection rules now form one control.
Test the issuer’s vocabulary before writing policy
Do not begin by copying a trust-policy example. Begin by observing the claims produced by each workflow context you intend to distinguish: production environment, main branch, pull-request job, tag release, and reusable workflow.
In an authorised test repository, request the token only long enough to decode its header and claims locally. Do not log the encoded token or exchange it for a production role.
permissions:
contents: read
id-token: write
jobs:
inspect-claims:
runs-on: ubuntu-latest
environment: oidc-lab
steps:
- uses: actions/github-script@v8
with:
script: |
const token = await core.getIDToken('sts.amazonaws.com')
const claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64url'))
core.info(JSON.stringify({ aud: claims.aud, sub: claims.sub, ref: claims.ref }))
Use a non-privileged lab role whose permission policy allows only sts:GetCallerIdentity.
The positive case should assume it from the intended environment. The negative cases should
fail from a feature branch, a pull request, a second repository, and a job that does not
reference the protected environment. If one of those succeeds, the trust policy is broader
than its author thinks.
Do not print the full token. It is a bearer credential for its short lifetime, and a log is still a disclosure even when the value expires quickly.
Names are not always the most durable identity
Repository names made OIDC policy readable, but names can be renamed, transferred, and in some systems reused. GitHub’s current OIDC reference documents an immutable subject format for newer or opted-in repositories that includes owner and repository IDs. Older repositories may still emit the previous name-based form until migrated.
That transition creates a review question most examples omit: which subject format does this repository actually issue today? A policy written for the new shape will deny an old repository. A migration performed without updating the cloud trust will break deployment. A fallback wildcard added during the outage can quietly preserve both formats forever and leave the role broader than before.
Treat subject-format migration like an identity migration: capture the current claims, stage an exact new condition, run positive and negative controls, then remove the old form. Compatibility should be a measured window, not a permanent wildcard.
Evidence matrix
| Signal | What it proves | Negative control | Defender verification |
|---|---|---|---|
Observed aud and sub from the intended protected job | The issuer vocabulary and exact deployment identity are known | Collect claims from a pull request, feature branch, and second repository; each should differ in the expected field | Compare live claims with the trust-policy operators and values |
| Lab role assumption succeeds only from the approved environment | The claim conditions enforce the intended source boundary | Attempt the same role from every near-neighbour context and require denial | Correlate AssumeRoleWithWebIdentity events with repository, subject, role, and session name |
| Organisation or repository wildcard matches more identities than documented | The unit of compromise is larger than the deployment repository | Replace it with exact conditions and confirm legitimate deployment still works | Enumerate every repository and workflow that can satisfy each wildcard pattern |
| Environment protection blocks an unreviewed deployment job | Repository controls and cloud trust compose into one boundary | Remove reviewer/branch eligibility in the lab and confirm the token can no longer satisfy the production path | Review environment reviewers, deployment branches, administrators, and bypass permissions |
| Immutable subject migration accepts the new form and denies the retired form | Compatibility did not leave a second permanent trust route | Present a token using the old subject shape after the cutover and require denial | Track subject format, repository ID, owner ID, and policy migration date |
The pattern I keep seeing
The team celebrates deleting the cloud secret, closes the credential-rotation ticket, and never assigns ownership of the trust policy that replaced it. Platform engineering owns the workflow. Cloud security owns IAM. Repository administrators own environments. The identity exists only when all three systems agree, so no single owner sees the complete enrolment rule.
That is why the durable finding is not “OIDC is misconfigured.” It is: “this production role accepts tokens from these additional workflow contexts, and this one condition is where the boundary expands.” The path should be as concrete as any other identity escalation path.
What to hand the defenders
The resolved trust population. Translate each StringLike pattern into the repositories,
branches, tags, pull-request jobs, and environments it actually matches today.
A claim contract. Record the expected issuer, audience, subject format, repository identity, and deployment context. Test that contract after repository transfers, environment changes, or subject-format migrations.
The negative cases. A successful production deployment proves availability. It does not prove exclusivity. Keep automated denial tests for the nearest untrusted contexts, because those are what detect a trust policy becoming broad again.
The pipeline no longer stores a cloud credential. It stores something more important: the policy that decides who may ask for one.
How current is this note?
The latest source-review, content-update, or publication date is shown.
The author completed a technical review. This does not, by itself, claim lab reproduction.
