Skip to content

GitHub Actions OIDC: How Workflow Identity Works

Lesson 1 of 10Intermediate → Advanced5 min readGitHub Actions & CI/CD · Actions SecurityVerified: GitHub Actions OIDC token claims and endpoints, August 2026

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.

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.

  1. 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.

  2. 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.

  3. The workflow presents the token to the cloud provider in exchange for credentials.

  4. 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.

The JWT carries claims describing the run. The ones that matter for policy:

ClaimValueUse
isshttps://token.actions.githubusercontent.comIdentifies GitHub as issuer
sube.g. repo:OWNER/REPO:ref:refs/heads/mainThe main thing policies match on
audAudience, defaults to the repository owner URLMust match what the provider expects
repositoryOWNER/REPO
repository_ownerOWNER
refrefs/heads/main
environmentThe environment name, if the job declared one
workflow_refThe workflow file and ref
job_workflow_refThe workflow that actually ran, through reusable calls
runner_environmentgithub-hosted or self-hosted
expExpiry — 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 branch
repo:OWNER/REPO:ref:refs/tags/v1.0.0 # push of a tag
repo:OWNER/REPO:pull_request # a pull request
repo:OWNER/REPO:environment:production # a job declaring an environment

A 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.

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.

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.

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_owner trusts every repository in your organisation. Any of them can assume the role.
  • Matching with a wildcard on sub such as repo: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.

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.

  • 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: write can 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.
  1. Add the claim-inspection step above to a workflow and run it on a push to a branch. Record the sub.

  2. Run it again from a pull request. Note that sub is different.

  3. Add environment: production to the job and run again. Note that sub changed form entirely and no longer contains the branch.

  4. Remove id-token: write and confirm the environment variables are absent — that is the failure you will otherwise spend twenty minutes on.

  5. With those three sub values in front of you, go and write a real trust policy in AWS, Azure or Google Cloud.

Check your understanding

3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

Why does the lesson prefer OIDC over a long-lived cloud key stored as a secret?
Show answer

The token is short-lived and states what is running; a stored key works from anywhere, forever, until rotated — A stored key carries no notion of where it is used and stays valid until rotated — a leak from two years ago may still work. An OIDC token is minted per run and asserts the repository, branch and workflow.

A trust policy checks only that the token's issuer is GitHub. What can obtain the cloud role?
Show answer

Any GitHub Actions workflow in any repository — The issuer is the same for every repository on GitHub. Restriction comes from conditions on the subject (`repo:org/name:ref:refs/heads/main`) and the audience. Without them the role is open to everyone.

What does OIDC *not* solve?
Show answer

Over-broad permissions on the cloud role itself — a short-lived token with admin rights is still admin — OIDC fixes how the workflow proves who it is. What the assumed role may do is a separate question, and least privilege on that role is still your job.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Get the production security checklists and Actions hardening templates from the Professional Toolkit.