The usual one-line explanation of OIDC is “GitHub logs into AWS without a password”. That sentence is wrong in a way that makes everything downstream confusing, and people who hold it struggle to debug their first failure.
GitHub does not log into anything. GitHub makes a signed statement about what is running, and a cloud provider you configured decides whether that statement is worth anything. Understanding it as a trust relationship rather than a login is the whole lesson.
The problem it solves
Section titled “The problem it solves”A stored cloud access key has four properties that make it dangerous, none of which are fixable by being careful:
- It does not expire, so a leak from two years ago may still work.
- It works from anywhere — the credential carries no notion of where it is used.
- It is readable by every workflow in the repository, including one added tomorrow.
- Rotating it requires coordinated changes in two systems, so it is deferred indefinitely.
OIDC removes all four by not having a stored credential at all.
The trust architecture
Section titled “The trust architecture”-
You configure trust, once. In your cloud provider, you register GitHub’s OIDC issuer as an identity provider and write a policy saying: tokens from this issuer, describing this repository, on this branch or environment, may assume this role. No secret is exchanged. This is a statement about who you are willing to believe.
-
A workflow requests a token. With
id-token: write, the job can call GitHub’s token endpoint and receive a short-lived JWT signed by GitHub, describing itself. -
The workflow presents the token to the cloud provider in exchange for credentials.
-
The cloud provider validates it — signature against GitHub’s published keys, expiry, audience, and every condition in your policy. If all pass, it issues its own short-lived credentials.
The credential the workflow ends up using is issued by the cloud provider, not by GitHub, and lasts minutes. Nothing durable is stored anywhere.
What the token actually says
Section titled “What the token actually says”The JWT carries claims describing the run. The ones that matter for policy:
| Claim | Value | Use |
|---|---|---|
iss | https://token.actions.githubusercontent.com | Identifies GitHub as issuer |
sub | e.g. repo:OWNER/REPO:ref:refs/heads/main | The main thing policies match on |
aud | Audience, defaults to the repository owner URL | Must match what the provider expects |
repository | OWNER/REPO | |
repository_owner | OWNER | |
ref | refs/heads/main | |
environment | The environment name, if the job declared one | |
workflow_ref | The workflow file and ref | |
job_workflow_ref | The workflow that actually ran, through reusable calls | |
runner_environment | github-hosted or self-hosted | |
exp | Expiry — minutes |
The sub claim varies by trigger, which is the source of most first-time failures:
repo:OWNER/REPO:ref:refs/heads/main # push to a branchrepo:OWNER/REPO:ref:refs/tags/v1.0.0 # push of a tagrepo:OWNER/REPO:pull_request # a pull requestrepo:OWNER/REPO:environment:production # a job declaring an environmentA trust policy matching ref:refs/heads/main will reject a job that declares
environment: production, because the sub becomes the environment form instead. That single fact
explains a large share of “it worked until I added an environment” reports.
Inspecting the token
Section titled “Inspecting the token”Understanding beats guessing, and the token is directly inspectable:
permissions: id-token: write contents: read
steps: - name: Show this workflow's OIDC claims run: | token="$(curl -sS \ -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" \ | jq -r .value)" echo "$token" | cut -d. -f2 | base64 -d 2>/dev/null | jq .What it doesRequests an OIDC token and prints its decoded claims.
Why we run itEvery trust policy is a set of conditions on these claims. Seeing the actual values for your trigger removes the guesswork from writing the policy — especially the `sub` claim, which is what most failures are about.
Expected resultA JSON object with sub, aud, repository, ref and the rest. Safe to print: the claims are not secret.
ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL are injected into the job only
when id-token: write is granted. If they are empty, the permission is missing — which is the first
thing to check when the request fails.
The claims are not secret. The token is a bearer credential for its short lifetime, so print the decoded payload, not the raw token.
Why the audience matters
Section titled “Why the audience matters”aud states who the token is for. A cloud provider is configured to accept only tokens whose audience
names it — sts.amazonaws.com for AWS, the application ID URI for Azure, the provider resource name
for Google.
Without that check, a token your workflow legitimately obtained for one service could be replayed against another that also trusts GitHub’s issuer. The audience is what stops a token being useful outside its intended destination, which is why every provider requires it and why a mismatched audience produces an authentication failure rather than a permissions error.
Writing conditions that actually restrict
Section titled “Writing conditions that actually restrict”A trust policy is only as good as its conditions. The failure mode to avoid is a policy that matches too broadly:
- Matching only
repository_ownertrusts every repository in your organisation. Any of them can assume the role. - Matching with a wildcard on
subsuch asrepo:OWNER/REPO:*trusts every branch, every pull request and every environment in that repository — including a branch an outside contributor can cause to exist. - Matching nothing at all trusts every repository on GitHub.com, because they all share one issuer. This is the serious one, and providers have added guardrails against it precisely because it happened.
The rule: pin sub to the exact ref or environment that should have this access, and give staging and
production separate roles with separate conditions.
Reusable workflows
Section titled “Reusable workflows”When a job comes from a reusable workflow, sub
describes the calling repository, while job_workflow_ref names the reusable workflow that
actually ran.
That distinction lets a platform team write a trust policy keyed on job_workflow_ref — “only our
audited deployment workflow may assume this role, regardless of which repository calls it” — which is
a genuinely strong control. It also means a policy keyed only on sub can be satisfied by a
repository calling a workflow you did not intend.
What OIDC does not solve
Section titled “What OIDC does not solve”- It is not authorisation. Identity is established; what the identity may do is your IAM policy’s job. An over-permissioned role reached by OIDC is still over-permissioned.
- It does not protect against a compromised workflow. A malicious step in a job that legitimately
holds
id-token: writecan obtain credentials exactly as the intended step would. The mitigations are least privilege, pinning and workflow security. - It does not cover every service. Some vendors still only accept API keys. Scope those tightly and store them as environment secrets.
Exercise
Section titled “Exercise”-
Add the claim-inspection step above to a workflow and run it on a push to a branch. Record the
sub. -
Run it again from a pull request. Note that
subis different. -
Add
environment: productionto the job and run again. Note thatsubchanged form entirely and no longer contains the branch. -
Remove
id-token: writeand confirm the environment variables are absent — that is the failure you will otherwise spend twenty minutes on. -
With those three
subvalues in front of you, go and write a real trust policy in AWS, Azure or Google Cloud.
Then what?
Section titled “Then what?”Check your understanding
3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.