DEV Community

Cover image for OIDC cloud credentials in GitHub Actions that no other repository can assume
Kay
Kay

Posted on Originally published at latchkey.dev

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.

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.

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.

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:

Claim What it holds Use it for
sub A string built from the repository and the job's context: environment, pull_request, or ref The primary condition on every cloud
aud The repository owner URL by default; the official AWS action sends sts.amazonaws.com, Azure login api://AzureADTokenExchange Required, but it is the same for everyone using that action
repository_id, repository_owner_id Numeric IDs that are never reassigned Conditions that survive a rename and resist name reuse
ref, environment The git ref the run is on, and the environment the job references Narrowing to a branch or a deployment environment
event_name The trigger; Dependabot update jobs send dynamic Refusing events your deploy never uses
job_workflow_ref For a reusable workflow, the path and ref of the called workflow Requiring that one reviewed workflow does the deploy

Claims and defaults from docs.github.com/en/actions/reference/security/oidc, the audience default in aws-actions/configure-aws-credentials v6.3.0 and azure/login v3.1.0 action.yml, all read 2026-11-12.

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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

The IDs 1342004 and 260064828 are the example values from the action's docs/SECURITY_CONSIDERATIONS.md; substitute your own.

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.

Condition What else it accepts Tighten it to
repo:octo-org/*, or repository_owner alone Every repository in the organization, including one created tomorrow by anyone allowed to create repositories One 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 passes StringEquals 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 role Jobs 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 main An environment subject with required reviewers
repo:octo-org/octo-repo:pull_request Every pull request run in the repository, from any branch Never on a role that can write anything
Names instead of IDs A new owner of a deleted or renamed organization or repository repository_id, repository_owner_id, or the immutable subject
aud checked, sub not Any repository using the same official action, since they all send the same audience Always both

The issues, issue_comment and workflow_run rows in the events reference list "Default branch" as GITHUB_REF, and the subject reference says the branch form applies when the job has no environment and the event is not a pull request event (docs.github.com, read 2026-11-12).

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.

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
Enter fullscreen mode Exit fullscreen mode

Print the claims before you write the condition

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.

- 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
Enter fullscreen mode Exit fullscreen mode

FAQ

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

Top comments (0)