In short

Let the platform issue a short-lived token to the workload, and trust it only through a policy that pins the issuer, an audience reserved for your resource, and exact, immutable claims: for GitHub Actions the repository_owner_id, repository_id, branch or environment, and workflow. Never use a wildcard subject, a repository name alone or the default audience. Then authorize what the resulting credential may do as narrowly as any other role.

  • The trust policy, not the token’s short lifetime, decides which workloads can become your role.
  • Match immutable IDs such as repository_id and repository_owner_id; names can be deleted and claimed again.
  • A wildcard subject such as repo:org/app:* admits pull request runs and every branch.
  • Bind protected deployments to a GitHub environment with protection rules, and allow only expected events.
  • Give each consumer its own audience, so a token for one service cannot be replayed to another.
  • Revoke by disabling the trust rule or role; waiting for tokens to expire is not incident response.

Verify it

  1. Decode a real token from your workflow and confirm the policy pins repository_owner_id, repository_id and an exact subject or environment.
  2. Open a pull request from a branch and confirm its run cannot assume the production role.
  3. Request a token with another audience and confirm the cloud provider refuses the exchange.
  4. Search every trust policy for * in a subject condition and for conditions that use names without IDs.
  5. Disable the trust rule or role and confirm a fresh run fails within the documented propagation time.
Applies to
  • PyJWT (token broker example) 2.x (tested with 2.15.0)

Short-lived service and build identities. Broker trust, audience and delegation policy need separate checks.

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 29 minutes: 6 to read and 23 for the exercise

What you will learn

  • Explain attestation, issuance, exchange, and resource authorization.
  • Separate issuer-attested claims from workload-supplied labels.
  • Test neighboring workloads and refresh failures.

Before you start

Platform and DevOps engineers who connect CI pipelines and services to cloud accounts, and reviewers who check trust policies.

  • Distinguish workload identity issuance from the permissions attached to the resulting credential.

Refresh first: Review cloud permissions and role trust together

Go to the guided practice

From a GitHub Actions job to short-lived AWS credentials

GitHubYour AWS accountGitHub ActionsjobGitHub OIDCissuerAWS STSDeployment targetRequest token forsts.amazonaws.com1Signed JWT: sub,repository_id,environment2AssumeRoleWithWebIdentity with the token3Fetch GitHub’s signingkeys4Check aud, sub andIDs in the trustpolicy4Temporary credentials for the role5Deploy with temporary credentials6Trust boundaryUntrusted input1Step, described below
  1. Request an OIDC tokenThe job, which has the id-token: write permission, asks GitHub’s token endpoint for a token with the audience sts.amazonaws.com.
  2. Issue a signed tokenGitHub signs a short-lived JWT whose claims describe the run: issuer, audience, sub, repository_id, repository_owner_id, ref, environment and job_workflow_ref.
  3. Present it to AWSThe job calls AssumeRoleWithWebIdentity with the token and the ARN of the role it wants.
  4. Verify the token and the trust policyAWS STS checks the signature against GitHub’s published keys and the expiry, then evaluates the role’s trust policy: the exact audience, the subject or environment, and the repository and owner IDs.
  5. Return temporary credentialsOnly when every condition matches does STS return temporary credentials limited to the role’s permissions; a pull request run, a recreated repository or a token for another audience gets an error.
  6. Use the credentials until they expireThe job deploys with the temporary credentials, which expire on their own. To stop a compromised workflow, disable the trust rule or the role rather than wait.
The short lifetime limits replay, but the trust policy decides which runs can become the role. Pin the audience, the exact subject or environment, and the immutable repository and owner IDs.

Short lifetimes help only after the issuer identifies the workload

A workload identity system issues credentials to a running service based on evidence about where and how it is running. Examples include a platform-issued token or a SPIFFE identity document. The application uses that credential directly or exchanges it for a resource-specific credential. The bootstrap question is who is allowed to claim the workload identity.

A typical sequence is platform attestation, identity issuance, credential retrieval through a controlled local interface, presentation or exchange, and resource authorization. Each step has an audience and trust boundary. A token proving a CI job’s identity is not automatically a database login or permission to administer every cloud resource.

Short-lived credentials reduce the period during which a copied token can be replayed. They do not prevent misuse while valid, nor do they stop a compromised workload from repeatedly obtaining fresh credentials while its attestation and permissions remain valid. Incident response must disable the issuance or authorization path as well as wait for tokens to expire.

Bind issuer assertions to a narrowly defined runtime identity

For federation, configure the expected issuer, audience and subject constraints using authoritative platform claims. For GitHub Actions a deployment policy might require repository_owner_id 123456, repository_id 456789, ref refs/heads/main, the deploy.yml workflow in job_workflow_ref, and the protected production environment. A repository name alone can leave pull request runs, other branches or a recreated repository able to satisfy the policy; the GitHub Actions hardening guide at /domains/supply-chain/ci-cd/github-actions-hardening/ covers the workflow side.

Review which claims the platform signs and which values the job itself can choose. A branch name in an environment variable is not the same evidence as an issuer-signed claim. Prefer numeric IDs to names: GitHub repository, organization and user names can be renamed or deleted and then registered by someone else, while repository_id, repository_owner_id and actor_id never change. For repositories created after 15 July 2026, GitHub also puts those IDs in the subject, as repo:octo-org@123456/billing@456789:environment:production.

For SPIFFE, the SPIFFE ID identifies the workload within a trust domain, while an SVID carries a verifiable identity assertion. Trust bundles and federation configuration determine which issuers are accepted. A local identity socket is sensitive: a neighboring process that can retrieve another workload’s credentials can cross the intended identity boundary.

Mutual TLS can authenticate both peers and protect transport. Authorization must still map the peer identity to specific services, actions, and resource scope. A mesh that accepts every workload certificate in one trust domain has established membership, not an application-specific access policy.

How do AWS, Google Cloud and Azure check a GitHub Actions token?

All three exchange the GitHub token for short-lived cloud credentials, but they match claims differently. Write the narrowest condition each one supports.

  • Dangerous: a subject ending in :*, which admits repo:org/app:pull_request and every branch.
  • Dangerous: a repository-name condition alone, which a recreated repository with the same name satisfies.
  • Dangerous: no audience condition, which lets a token minted for another service assume the role.
  • Dangerous: allowing every event, which includes Dependabot update jobs (event_name of dynamic).
How do AWS, Google Cloud and Azure check a GitHub Actions token?
CloudWhere the trust livesWhat it matchesPitfall to avoid
AWSIAM role trust policy for the token.actions.githubusercontent.com provideraud and sub, plus repository_id, repository_owner_id, job_workflow_ref, ref and environment condition keysStringLike with repo:org/* or repo:org/app:*; IAM refuses only a missing or pure-wildcard sub
Google CloudWorkload identity pool provider with an attribute conditionA CEL condition over mapped claims, such as assertion.repository_owner_idAn attribute condition is required, because every GitHub organization shares one issuer
AzureFederated identity credential on an app registration or managed identityExact, case-sensitive issuer, subject and audienceOne credential per subject; wildcards are not supported

Trust one environment of one repository in AWS

This role trust policy admits only jobs that run in the production environment of one repository, identified by its immutable owner and repository IDs. Protect the environment in GitHub with required reviewers and deployment branch rules. The JSON was checked for syntax and its conditions were evaluated against sample token claims with IAM’s documented exact-match semantics; AWS itself was not called.

Configurationtrust-policy.jsonJSON
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "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/billing:environment:production",
          "token.actions.githubusercontent.com:repository_owner_id": "123456",
          "token.actions.githubusercontent.com:repository_id": "456789"
        }
      }
    }
  ]
}

Accept a GitHub Actions token in your own service

A deployment service or internal broker can accept GitHub tokens directly. This Python function verifies the token against GitHub’s published keys, requires your own audience, and compares the immutable claims exactly. It was tested with PyJWT 2.15.0 on Python 3.14 against tokens signed by a local key: the deploy workflow is accepted, while a recreated repository, another owner, another workflow file or branch, a different-case environment, a missing environment, pull request and Dependabot events, GitHub’s default audience, another issuer, an expired token and an unknown key are all refused. The JWT validation lab at /labs/token-validation/ practices the same audience and purpose checks.

Fixedgithub_oidc.pyPython
import jwt  # PyJWT 2.x

ISSUER = "https://token.actions.githubusercontent.com"
jwks = jwt.PyJWKClient(ISSUER + "/.well-known/jwks", cache_keys=True, lifespan=600)

# Exact values for the one workflow allowed to deploy. Use the numeric IDs: a deleted or
# renamed repository's name can be registered again by someone else.
POLICY = {
    "repository_owner_id": "123456",
    "repository_id": "456789",
    "job_workflow_ref": "octo-org/billing/.github/workflows/deploy.yml@refs/heads/main",
    "ref": "refs/heads/main",
    "environment": "production",
}
ALLOWED_EVENTS = {"push", "workflow_dispatch"}


def verify_deploy_token(token: str) -> dict:
    key = jwks.get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        key.key,
        algorithms=["RS256"],
        issuer=ISSUER,
        audience="https://deploy.example",  # your service, never the default audience
        options={"require": ["exp", "iat", "sub", "aud"]},
    )
    for name, expected in POLICY.items():
        if claims.get(name) != expected:  # exact match: no prefixes, wildcards or case folding
            raise PermissionError(f"{name} does not match the deployment policy")
    if claims.get("event_name") not in ALLOWED_EVENTS:  # excludes pull requests and Dependabot runs
        raise PermissionError("event not allowed to deploy")
    return claims

Choose scope, refresh, and outage behavior together

Give each workload a role appropriate to its job, and issue resource credentials with the intended audience and lifetime. If one worker processes multiple tenants, tenant authorization still belongs in each request or job context; the worker’s identity does not by itself prove which customer’s data a job may touch.

Refresh before expiry with jitter and bounded retries so thousands of instances do not stampede the issuer at once. Preserve a clear expiry cutoff and fail closed when no valid credential remains. Avoid a static emergency token embedded in the image, because it can become the permanent bypass around the identity system.

An issuer outage presents a real availability tradeoff. A valid cached credential can continue within its existing authority and lifetime if policy allows; an expired one cannot become valid because refresh failed. Longer lifetimes reduce dependence on refresh availability while increasing theft and revocation exposure.

Record identity, target audience, policy version, and decision outcome in logs. Do not log complete bearer tokens or private keys. For certificate identities, preserve a way to trace the issuing trust domain and key lifecycle without assuming certificate expiry is immediate revocation.

Exercise the denied neighboring identities

A permitted workload should obtain a correctly scoped credential and complete its intended operation. The same credential at a different audience must fail. A neighboring namespace, forked repository, wrong workflow, or unrelated trust domain must not receive equivalent authority merely because it shares infrastructure.

Test expiry at the exact boundary, refresh failure with a still-valid cache, refresh failure after expiry, and disabling the workload’s issuance rule. Include an authorization test showing that identity success cannot read a different tenant’s resource. These outcomes distinguish a working credential refresh loop from a sound workload trust policy.

Guided practice

Your production deploy role in AWS must trust only the release job of octo-org/billing, which runs in the protected production environment. Below are the decoded claims of four GitHub Actions OIDC tokens. Write the role's trust policy, then decide which tokens it admits. Produce the policy before reading the worked answers.

Supplied scenario material
Plain text
J1 {"iss": "https://token.actions.githubusercontent.com", "aud": "sts.amazonaws.com",
    "sub": "repo:octo-org/billing:environment:production", "repository": "octo-org/billing",
    "repository_id": "456789", "repository_owner_id": "123456", "ref": "refs/heads/main",
    "environment": "production", "event_name": "push",
    "job_workflow_ref": "octo-org/billing/.github/workflows/release.yml@refs/heads/main"}
J2 as J1 but "sub": "repo:octo-org/billing:pull_request", "ref": "refs/pull/12/merge",
    "event_name": "pull_request", and no environment claim.
J3 as J1 but "repository_id": "460001": the repository was deleted and a new one created under the same name.
J4 as J1 but "aud": "https://github.com/octo-org", the default audience when the workflow does not request one.
Provider ARN: arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com
  1. 1. Write the trust policy

    Write the IAM role trust policy that admits only J1-like tokens.

    Expected result and reasoning

    Principal {"Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"}, action sts:AssumeRoleWithWebIdentity, and a StringEquals condition on token.actions.githubusercontent.com:aud = sts.amazonaws.com, :sub = repo:octo-org/billing:environment:production, :repository_id = 456789 and :repository_owner_id = 123456. Use StringEquals, not StringLike with wildcards. The policy shown on this page has exactly this shape.

  2. 2. Decide the four tokens

    For J1 to J4, say whether the policy admits it and which condition decides.

    Expected result and reasoning

    J1 is admitted. J2 fails on sub, because a pull request job's subject is repo:octo-org/billing:pull_request. J3 fails on repository_id: the name matches, but the recreated repository has a new immutable ID. J4 fails on aud, because the token was minted for a different audience.

  3. 3. Find the dangerous variant

    A colleague proposes StringLike with sub = repo:octo-org/billing:* and no ID conditions "to make deployments easier". Which tokens would it admit?

    Expected result and reasoning

    J1, J2 and J3 (J4 still fails if aud is checked). J2 means any job triggered by a pull request in the repository could assume the production role without passing the environment's reviewers; J3 means whoever recreates the repository name inherits the role. This is the wildcard subject pitfall in the provider table above.

  4. 4. Add the GitHub-side control

    Say what must be configured in GitHub for the environment condition to mean anything.

    Expected result and reasoning

    Protect the production environment with required reviewers and a deployment branch rule limited to main, so only an approved run from main can produce a token with environment production; and grant id-token: write only to the release job.

  5. 5. Write the tests

    Describe how to test the policy without deploying.

    Expected result and reasoning

    Evaluate the trust policy against the four claim sets with IAM's exact-match semantics, for example with the IAM policy simulator or a unit test that applies the StringEquals conditions; then run a real pull request workflow and confirm AssumeRoleWithWebIdentity is denied. Alert on any successful assumption whose session tags or CloudTrail record show a subject other than the production environment.

Your deliverable: The trust policy JSON, a decision table for J1 to J4, the effect of the wildcard proposal, and the GitHub environment settings.

The claim sets are abridged and the IDs invented. Check the claims your own workflows emit by printing the decoded token in a test run with the token itself redacted.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. SPIFFE concepts
  2. SPIFFE Workload API
  3. RFC 8693: OAuth token exchange. January 2020.
  4. GitHub: OpenID Connect reference for GitHub Actions
  5. AWS IAM: create a role for OpenID Connect federation (GitHub)
  6. Google Cloud: workload identity federation with deployment pipelines
  7. Microsoft Entra: workload identity federation

Reference information

ASVS 5.0.0 requirements
v5.0.0-13.2.1 · v5.0.0-13.2.2 · v5.0.0-13.3.1

Download this guide as Markdown