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

> Replace stored cloud keys with GitHub Actions OIDC on AWS, Google Cloud and Azure, and get trust conditions no fork or other repository can meet.

Source: https://latchkey.dev/learn/ci-security/github-actions-oidc-cloud-credentials  
Updated: 2026-11-12

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.

## 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
```

## 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
```

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

```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
```

## 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](/learn/github-actions/github-actions-oidc-id-token-error) and the provider pages for [AADSTS70021 on Azure](/learn/github-actions/oidc-azure-aadsts70021-no-federated-identity-in-ci) and [impersonation errors on Google Cloud](/learn/github-actions/oidc-gcp-unable-to-acquire-impersonated-credentials-in-ci).

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

## 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

- [GitHub Docs: OpenID Connect reference, claims and subject formats (read 2026-11-12)](https://docs.github.com/en/actions/reference/security/oidc)
- [GitHub Changelog: immutable subject claims for GitHub Actions OIDC tokens](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/)
- [AWS IAM: configuring a role for the GitHub OIDC identity provider (read 2026-11-12)](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html#idp_oidc_Create_GitHub)
- [google-github-actions/auth: security considerations](https://github.com/google-github-actions/auth/blob/main/docs/SECURITY_CONSIDERATIONS.md)
- [Microsoft Entra: flexible federated identity credentials (preview, read 2026-11-12)](https://learn.microsoft.com/en-us/entra/workload-id/workload-identities-flexible-federated-identity-credentials)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
