
What OIDC in GitHub Actions doesn't protect you from
By: Dzhuneyt Ahmed
Posted · 14 min read
Every guide to GitHub Actions OIDC ends at the same place: you deleted AWS_ACCESS_KEY_ID from your repository secrets, the workflow assumes a role instead, and the credentials expire in an hour. Long-lived keys gone. Job done.
That part is real, and you should do it. Static cloud keys in CI are one of the most reliably exploited things in modern infrastructure — they leak into logs, forks, screenshots, and stale secret stores, and they stay valid for years.
But I went through this exercise on my own infrastructure repo, and the interesting part wasn't the setup. It was everything the setup quietly left open. OIDC changes how a credential is issued. It does almost nothing about who is allowed to ask for one, and that turns out to be the part that matters.
Here's what actually stays exposed.
The one thing OIDC genuinely fixes
Worth being precise about the win, because it's narrower than the marketing.
Without OIDC, your cloud credential exists as a durable secret in two places: your cloud provider's IAM system and GitHub's secret store. It has no expiry, no binding to a particular workflow, and no context. Anyone who extracts it owns it until someone notices and rotates.
With OIDC, GitHub mints a short-lived JSON Web Token describing the run, and your cloud provider trades it for temporary credentials. Nothing durable is stored anywhere.
That eliminates the stored secret problem. It's a real improvement. It's also the entire improvement.
Look at the token
Almost nobody who sets this up ever sees the thing the whole mechanism turns on. Decoded, the interesting half of that token looks roughly like this:
GitHub is asserting a set of facts about the run. Your cloud provider decides whether to believe them and what to hand over in exchange.
Here's the part that surprises people: AWS only reads two of these by default. aud, which says who the token is for, and sub, which says who it claims to be. Everything else in that payload is informational as far as your trust policy is concerned. The rest of this post is really about what you do with sub.
You can see your own. In a job that already has id-token: write, add a step:
The padding loop isn't decoration. JWT segments are base64url encoded with the trailing = stripped, so feeding one straight to base64 -d makes it exit non-zero — which is invisible until the day you add set -o pipefail and the step starts failing for reasons that have nothing to do with your token.
Print the decoded claims, never the raw token — the token itself is a live credential for its validity window, and pasting it into a build log hands over exactly what you just stopped storing as a secret. Take the step out once you've read the output.
Five minutes here will tell you more than the rest of this post. Especially if your repository is newer than a couple of months, for reasons I'll get to.
Part 1: one JSON condition is now your entire perimeter
Once there's no secret, the only thing standing between a workflow run and your cloud account is the trust policy on the role. Every access decision you used to get for free by controlling a secret is now a condition in a JSON document.
Checking the audience proves nothing
The most common mistake is validating the wrong claim. A trust policy that checks only the audience looks like this:
That looks like a security condition. It isn't. sts.amazonaws.com is the default audience for every GitHub Actions OIDC token issued to AWS, everywhere, by every repository on GitHub. A trust policy with only an aud condition can be assumed by a workflow in a repository that has nothing to do with you. Someone creates a public repo, adds a three-line workflow, and assumes your role. They need to know your role ARN, which is a speed bump rather than a control — ARNs turn up in logs, screenshots, and public IaC all the time.
sub is the claim that carries identity. If it isn't constrained, nothing is.
A wildcard sub means anyone who can push a branch
So people add the sub condition, and it usually looks like this:
Better — now only your repository can assume the role. But read the wildcard carefully. The subject claim for a branch push ends in :ref:refs/heads/BRANCH, and * matches every branch, every tag, and every pull request context.
The practical consequence: anyone who can push a branch to that repository can assume the role. They don't need to merge anything. They don't need a review. They push a branch containing a new workflow file with id-token: write, it fires on that branch's push event, and it runs with your deploy credentials. The workflow doesn't need to exist on your default branch first — it arrives with the push.
That inverts the mental model most teams have. You think of your deploy role as gated by your merge process. It isn't. It's gated by your push permissions, which are almost always much broader — every contributor, every bot, every integration with write access, and anyone who compromises any of them.
Pinning the ref is what closes it:
Copy the shape, not the string. Which brings us to the two ways that string stops matching.
The sub claim isn't one shape
Say you pin to ref:refs/heads/main and everything works. Then someone adds environment: production to the job, and deploys start failing with an unhelpful error.
That's because the subject claim has several formats, and they're mutually exclusive:
| Context | sub value |
|---|---|
| Branch push | repo:ORG/REPO:ref:refs/heads/main |
| Tag push | repo:ORG/REPO:ref:refs/tags/v1.2.3 |
| Pull request | repo:ORG/REPO:pull_request |
| Job with an environment | repo:ORG/REPO:environment:production |
When a job declares an environment, the environment format replaces the ref format. Your ref pin no longer matches. The failure is loud, so you'll catch it — but the fix people reach for under deploy pressure is widening the pattern back to *, which walks straight back into the previous section.
Two of these deserve separate attention.
Tags. If you allow ref:refs/tags/* for release deploys, note that rulesets covering tags are a far less commonly configured control than branch protection. On a lot of repos, anyone with write access can push any tag — including one pointing at whatever commit they like.
Pull requests. The pull_request subject carries no ref at all — it's the same string for every PR against the repo. That flattens every pull request into a single identity, so a policy trusting it can't distinguish a maintainer's PR from anyone else's with push access.
One thing that is not true: this isn't an open door for drive-by contributors. Workflows triggered by a pull request from a fork run with a read-only token and don't get id-token: write, so an external PR can't mint a token by default. The realistic trust set is still people with write access — bad enough, and worth not overstating.
pull_request_target, which is the exception to all of that. Treat this one as a hard rule: don't grant id-token: write in a pull_request_target workflow.
That event exists specifically to run with the base repository's secrets and permissions on a fork's pull request — the read-only-token protection above doesn't apply to it. And since December 2025, its GITHUB_REF resolves to your repository's default branch, with environment rules evaluating against that same default branch.
Read those two together and the problem is clear enough without needing to know the exact subject claim: the job is privileged by design, and as far as GitHub is concerned it's running on the ref your trust policy is most likely to trust. Pinning sub to your default branch is not obviously a defence when the run genuinely is your default branch. GitHub doesn't document what subject pull_request_target emits, and I haven't tested it — which is itself an argument for not building a control on the assumption that it's excluded.
The control that works here isn't in the trust policy. It's not granting the permission in the first place, and gating anything that genuinely needs credentials behind an environment with required reviewers.
Repository names are mutable, and your policy hardcodes one
Your trust policy contains a string like repo:my-org/my-repo. That string is a name, and names on GitHub can be changed and released.
If an organisation is renamed or deleted, the old name becomes available for registration. A trust policy matching on the name would then match a repository under that name owned by someone else. Low likelihood, high severity — it needs a specific sequence of events, but nothing warns you.
GitHub fixed this at the source, and the fix has a sharp edge. Since 15 July 2026, repositories that are newly created, renamed, or transferred get an immutable subject format that embeds the numeric owner and repository IDs directly in sub:
repo:my-org@123456/my-repo@789012:ref:refs/heads/main
Older repositories keep the name-based format until they opt in, via the use_immutable_subject field on the repository's OIDC subject-claim customisation endpoint.
The sharp edge: a policy pinned to the name format will not match a repository created after that date. Set up OIDC on a fresh repo by copying a trust policy from any tutorial written before mid-2026 and the role assumption fails, with an error that doesn't explain why. This is the single most likely reason your correctly-reasoned pin doesn't work, and it's why the token-dump step above is worth thirty seconds.
Worth being explicit about where this fix does not live, because the intuitive approach is a trap. The token carries repository_id and repository_owner_id as standalone claims, so the natural move is to condition on them directly:
That fails closed, silently and completely. AWS maps only five claims from a default OIDC provider to IAM condition keys — amr, aud, email, oaud, and sub. Anything else you reference is a context key that isn't present in the request, so the condition evaluates false and every assume-role call fails. Not just an attacker's. Yours.
The immutable IDs are only reachable through the one channel AWS actually reads: the sub string.
Part 2: your YAML is not a security boundary
This is the one I got wrong first, and I think it's the most common self-deception in the whole pattern.
The obvious response to the wildcard problem is to defend inside the workflow. Gate the job on the branch:
Or route it through an environment, or factor the check into a composite action that every credential-using job calls first.
I did a version of all three. Then I deleted most of it, because of a detail that's easy to miss: the attacker is editing the file those checks live in. They push a branch. That branch contains their workflow. Their workflow doesn't have your if condition, doesn't reference your composite action, and doesn't declare your environment. Every control you wrote lives in a file that the untrusted input gets to rewrite.
A composite action that re-checks the ref is a good example of guardrail theatre. It reads like defence in depth. It's actually the same check, in the same trust domain, guarding against a threat that was never going to invoke it. I cut mine — the job-level if was already equivalent, and both are worth about the same against someone who controls the workflow file.
Which isn't to say don't write them. Job-level conditions are genuinely useful against accident: the wrong branch, a stray workflow_dispatch, a copy-pasted job. Just be honest about which threat you're buying down. Client-side checks stop mistakes. Only server-side controls stop attacks, and the server here is your cloud provider's IAM, not your YAML.
environment: is the sharpest version of this
One line deserves singling out, because it looks more like a real control than anything else in the file — it names something that exists in your repository settings.
On its own, environment: production gates nothing. An environment only holds a deploy if someone has opened repository settings and attached protection rules to it: required reviewers, a wait timer, a deployment branch policy. The YAML is a pointer to a rule that lives on GitHub's side. If nobody ever created the rule, the pointer resolves to nothing and the job runs straight through, exactly as if the line weren't there.
It isn't inert, though, and that's the part that bites. Declaring an environment switches your sub claim to the environment: format from Part 1. So one line can read like an approval gate, deliver no approval gate, and quietly change the identity your trust policy is matching against — all at the same time.
Both halves are worth checking: that the environment actually has protection rules attached, and that your trust policy pins the subject format the job now sends.
Part 3: what OIDC never claimed to fix
Two things survive the migration unchanged. Neither is new, but both matter more once OIDC is in place, because it's easy to file them under "handled."
Short-lived is not least-privileged. OIDC shrinks the credential's lifetime and says nothing about its scope. CI roles drift toward broad permissions for a boring reason: the deploy fails on a missing permission at 6pm, someone attaches a wide managed policy to unblock it, and it's never narrowed. The credential now expires in an hour instead of never — but an hour is enough to enumerate an account, read every secret, create a persistent user, and leave. Scope the role to what the deploy actually touches, and treat the expiry as a mitigation rather than a control.
The credential is live in the runner for the whole job. After the credential-configuring step runs, those credentials sit in the environment for every subsequent step — your dependency install, your build, every third-party action. A compromised action or a malicious postinstall script executes in the same process space as a live cloud credential. OIDC changes nothing here, because the credential has to exist somewhere for the deploy to work.
The mitigations are ordinary supply-chain hygiene:
- Pin third-party actions to a full commit SHA, not a tag. Tags are mutable and repointing one is a well-worn attack.
- Keep credential-holding jobs small. If the job that assumes the role also runs your test suite, every test dependency is in the blast radius.
- Grant
id-token: writeper job, not at workflow level. Default the workflow tocontents: readand opt individual jobs in.
What actually holds the line
Sorting all of the above by where the control lives makes the picture clearer.
Server-side, holds against attack:
subpinned to specific refs, in whichever subject format your repository actually issues- Branch protection requiring review to reach the pinned ref
- Environment protection rules with required reviewers, actually configured
- A role scoped to what the deploy needs
Client-side, holds against accident:
- Job-level
ifconditions on the ref permissionsblocks- Path filters and concurrency groups
- Anything else written in the workflow file
Both lists are worth having. Only the first survives contact with someone who can push a branch.
Do this today
Fifteen minutes, and you'll know where you stand.
1. Read your current trust policy.
2. Look at the sub line. If there's a * anywhere after the repository name, anyone who can push a branch to that repo can assume this role. That's the finding. Everything else is secondary.
3. Compare against the target shape:
Two details carry the weight. It's StringEquals, not StringLike — StringLike is what lets a stray * mean something. And the sub value must match the format your repository actually issues, which is the name-based string above for older repos and the @ID variant for anything created, renamed, or transferred since 15 July 2026. Dump your token if you're not sure.
4. Then check the boring one: does the role's permission policy match what the deploy needs, or is it a wide managed policy someone attached during an outage?
The part that doesn't announce itself
OIDC moves your CI security posture from "protect this secret" to "get this trust policy exactly right." The second problem is harder, less visible, and has no rotation story when you get it wrong.
Nothing alerts you that your sub has a wildcard in it. There's no scanner in your PR, no warning in the console, no expiry forcing an annual review. The deploys just work — which is exactly what a misconfiguration that grants too much access looks like from the outside.
That's the real asymmetry. A leaked static key eventually shows up in a secret scanner or an anomalous API call. An over-permissive trust policy just sits there, working perfectly, until someone notices it's the shortest path into your account.
Go read the sub line. It's doing more work than the rest of your pipeline combined.
