Skip to content
Latchkey LogoLatchkey home

OIDC cloud credentials in GitHub Actions that no other repository can assume

GitHub Actions OIDC cloud credentials replace a stored access key with a token GitHub signs for one job, which AWS, Google Cloud or Azure exchange for short-lived credentials only when the token's claims match the trust conditions you wrote. All of the security lives in those conditions: bind them to a repository ID, an environment with required reviewers, or a reusable workflow, and never to an organization name with a wildcard.

You have a deploy job with a cloud access key in a repository secret, and you want it gone. OIDC is the replacement, and the switch itself is a few lines of YAML: grant id-token: write to the job, configure the cloud to trust https://token.actions.githubusercontent.com, and call the provider's login action.

The part that decides whether you are safer afterwards is the trust condition on the cloud side. GitHub's OIDC guide requires one: "you must define at least one condition, so that untrusted repositories can't request access tokens for your cloud resources." Every GitHub repository on earth can get a token from the same issuer. What the token says, how to write conditions on AWS, Google Cloud and Azure that only your job can meet, and the mistakes that quietly widen them make up the rest.

A job token checked against an environment-scoped trust condition: one subject passes, three are refused
The cloud compares the token's claims with your condition and nothing else. Four jobs in the same organization, one condition, one pass.

What the token says about the job

The job asks GitHub's OIDC provider for a JSON Web Token, signs nothing itself, and hands the token to the cloud. The cloud verifies the signature against the issuer and then evaluates your conditions against the claims. These are the claims that matter for trust, from GitHub's OIDC reference:

ClaimWhat it holdsUse it for
subA string built from the repository and the job's context: environment, pull_request, or refThe primary condition on every cloud
audThe repository owner URL by default; the official AWS action sends sts.amazonaws.com, Azure login api://AzureADTokenExchangeRequired, but it is the same for everyone using that action
repository_id, repository_owner_idNumeric IDs that are never reassignedConditions that survive a rename and resist name reuse
ref, environmentThe git ref the run is on, and the environment the job referencesNarrowing to a branch or a deployment environment
event_nameThe trigger; Dependabot update jobs send dynamicRefusing events your deploy never uses
job_workflow_refFor a reusable workflow, the path and ref of the called workflowRequiring that one reviewed workflow does the deploy

The subject formats, and the immutable one

The default sub is one of four shapes. A job that references an environment gets the environment form. Otherwise a pull request event gets pull_request, and anything else gets its branch or tag. That ordering matters: the environment form wins, so a deploy job that names an environment cannot be matched by a branch condition.

Since July 15, 2026, repositories created, renamed or transferred after that date use an immutable subject that carries the owner and repository IDs. Older repositories keep the name-only format until an owner opts in through the OIDC settings or the REST API. GitHub's changelog gives the reason: with the old format, "If a repository or organization name was recycled, a new owner could mint tokens with the same subject claim." If a new repository's login fails against a policy you copied from an old one, this is the first thing to check.

Subject claim formats
# Job references an environment
repo:octo-org/octo-repo:environment:prod

# Pull request event, no environment
repo:octo-org/octo-repo:pull_request

# Any other event, no environment
repo:octo-org/octo-repo:ref:refs/heads/main
repo:octo-org/octo-repo:ref:refs/tags/v1.2.0

# Immutable format: repositories created after July 15, 2026, or opted in
repo:octo-org@123456/octo-repo@456789:environment:prod

AWS: one role, one environment

AWS evaluates the trust policy on the IAM role. IAM checks that token.actions.githubusercontent.com:sub is present and that its value is not only a wildcard, and it refuses to save a policy that fails that check. That stops the worst mistake, not the common ones. GitHub's AWS guide also notes that AWS does not support custom claims, so the subject is the only place to put repository and environment; if you need a reusable-workflow condition on AWS, customize the subject to include job_workflow_ref (below).

The policy here accepts one environment of one repository and nothing else. Pair it with required reviewers on the prod environment, because the environment name in the subject proves the job referenced it, not that anyone approved the run.

role trust policy and deploy.yml
{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:environment:prod"
    }
  }
}

# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment: prod
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-deploy-prod
          aws-region: us-east-1

Google Cloud: an attribute condition on numeric IDs

On Google Cloud the check is a CEL attribute condition on the workload identity provider, plus IAM bindings on the pool. The google-github-actions/auth README gives the instruction without qualification: "Always add an Attribute Condition to restrict entry into the Workload Identity Pool." Its security notes go further and recommend the numeric repository_owner_id and repository_id over names, because "If you delete your GitHub repository or GitHub organization, someone could claim that same name and establish an identity."

GitHub's docs list Google Cloud as one of the providers that support custom claims, so this is the cloud where event_name and ref belong in the condition itself rather than in the subject. Find the IDs with gh api repos/octo-org/octo-repo --jq .id and gh api orgs/octo-org --jq .id.

Terminal
gcloud iam workload-identity-pools providers create-oidc "octo-repo" \
  --project="my-project" --location="global" --workload-identity-pool="github" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository_id=assertion.repository_id" \
  --attribute-condition="assertion.repository_owner_id == '1342004' && assertion.repository_id == '260064828' && assertion.ref == 'refs/heads/main' && assertion.event_name == 'push'"

# in the workflow
- uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
  with:
    workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/octo-repo
    service_account: deployer@my-project.iam.gserviceaccount.com

Azure: exact match, or a flexible credential

Microsoft Entra federated identity credentials compare issuer, subject and audience by exact match, with no wildcards, and an app or user-assigned managed identity holds at most 20 of them. Exact match is the safe default: one credential per environment, with subject repo:octo-org/octo-repo:environment:prod and audience api://AzureADTokenExchange, which is what azure/login sends.

Flexible federated identity credentials, in preview on 2026-11-12, add a small expression language with matches for wildcards. For GitHub tokens Microsoft requires every flexible expression to match sub and at least one of repository_id or repository_owner_id, which closes the name-reuse hole by design. The Azure CLI, Azure PowerShell and the Terraform providers do not support them yet, so you create them through Microsoft Graph, for example with az rest, or in the portal.

Azure federated credential and login step
# Flexible credential expression (Microsoft Graph, claimsMatchingExpression.value)
claims['sub'] matches 'repo:octo-org/octo-repo:environment:*' and claims['repository_id'] eq '456789'

# in the workflow
- uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}
    subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

The mistakes that make a trust policy too wide

Each of these passes a casual review and accepts tokens you did not mean to accept.

ConditionWhat else it acceptsTighten it to
repo:octo-org/*, or repository_owner aloneEvery repository in the organization, including one created tomorrow by anyone allowed to create repositoriesOne repository by ID, or an environment subject
StringLike on repo:octo-org/octo-repo*Any repository whose name starts that way: repo:octo-org/octo-repo-sandbox:environment:prod passesStringEquals on the full subject; if you need a wildcard, keep the context before it: repo:octo-org/octo-repo:environment:prod-*
ref:refs/heads/main on a deploy roleJobs from issues, issue_comment and workflow_run, which run with the default branch as their ref and present the same subject as a push to mainAn environment subject with required reviewers
repo:octo-org/octo-repo:pull_requestEvery pull request run in the repository, from any branchNever on a role that can write anything
Names instead of IDsA new owner of a deleted or renamed organization or repositoryrepository_id, repository_owner_id, or the immutable subject
aud checked, sub notAny repository using the same official action, since they all send the same audienceAlways both

Environments, reusable workflows and the subject template

An environment in the subject only proves the job referenced it. Since December 8, 2025, environment branch rules evaluate against the ref the job runs on, which for pull_request_target is the default branch, so a "main only" deployment rule does not keep a privileged pull request workflow out. Required reviewers do: a job that references the environment waits until one of them approves it, and cannot read the environment's secrets before that. On GitHub Free, Pro and Team plans, required reviewers are available only for public repositories.

For organizations with many repositories, the stronger pattern is a reusable deploy workflow that every repository calls. Customize the subject with the REST API to include job_workflow_ref, then require that value on the cloud side. GitHub's documented template combines repo, context and job_workflow_ref. Create the matching cloud condition first: every new job uses the new template the moment you set it, and logins fail until the cloud agrees.

Terminal
gh api -X PUT repos/octo-org/octo-repo/actions/oidc/customization/sub \
  --input - <<'JSON'
{ "use_default": false, "include_claim_keys": ["repo", "context", "job_workflow_ref"] }
JSON

# resulting subject
repo:octo-org/octo-repo:environment:prod:job_workflow_ref:octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main

Write the condition from what the token actually contains, not from what you expect. GitHub's docs still point at github/actions-oidc-debugger, but that repository is archived; its README now carries the replacement as a plain step, which prints the decoded claims and never the token itself. Run it in the job you plan to trust, on the event you plan to trust, then delete it.

If the job fails before this step with a token error, the cause is usually permissions rather than trust: see OIDC: failed to get ID token and the provider pages for AADSTS70021 on Azure and impersonation errors on Google Cloud.

.github/workflows/deploy.yml
- name: Show OIDC claims
  env:
    AUDIENCE: sts.amazonaws.com
  run: |
    TOKEN_JSON=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE")
    ID_TOKEN=$(echo "$TOKEN_JSON" | jq -r .value)
    echo "$ID_TOKEN" | awk -F. '{print $2}' | base64 -d 2>/dev/null | jq -r

Key takeaways

  • Every GitHub repository can get a token from the same issuer: the trust condition is the whole control.
  • An environment subject beats a branch subject, because issues, issue_comment and workflow_run jobs also run on the default branch.
  • Use numeric IDs or the immutable subject; repositories created after July 15, 2026 get the immutable format by default.
  • AWS does not support GitHub's custom claims; put anything beyond aud and sub into the subject with the customization API.
  • Required reviewers on the environment are what stop a privileged workflow from deploying, not branch rules.

Frequently asked questions

Is OIDC more secure than storing AWS access keys in GitHub secrets?
Yes, if the trust policy is narrow. The credentials AWS returns expire on their own and nothing long-lived is stored in the repository. A trust policy that accepts repo:your-org/* gives the same reach to every repository in your organization, so the improvement depends entirely on the condition.
Why is my OIDC sub claim different from the documentation example?
Three usual reasons. The job references an environment, which replaces the branch in the subject; the event is a pull request, which puts pull_request there instead; or the repository uses the immutable format, which adds @ and the numeric owner and repository IDs. Print the claims from the job itself and copy the subject from the output.
Can a pull request from a fork assume my AWS role?
A pull_request run from a public fork gets a read-only token, and its subject is repo:owner/repo:pull_request, so a policy that does not accept that subject refuses it. The risk is the privileged events: pull_request_target, issue_comment and workflow_run run with the default branch as their ref, so scope deploy roles to an environment with required reviewers, not to main.
What audience should I use for GitHub Actions OIDC?
The one your provider's official action sends: sts.amazonaws.com for aws-actions/configure-aws-credentials and api://AzureADTokenExchange for azure/login. google-github-actions/auth defaults it to the workload identity provider name, which is what Google Cloud expects. Treat the audience as required plumbing, not as a security boundary, because every user of the same action sends the same value.

References

A credential that lives for one job, on a Latchkey runner that lives for one job. Start free → 30-day trial · No credit card