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_idandrepository_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.
| Setting | Value | Note | Source |
|---|---|---|---|
| Issuer (GitHub Actions) | https://token.actions.githubusercontent.com | Tokens are signed with RS256. | GitHub OIDC discovery document |
| Audience | AWS sts.amazonaws.com; Azure api://AzureADTokenExchange; your own service URL | GitHub’s default audience is the repository owner’s URL; set an explicit one. | AWS, Microsoft and GitHub documentation |
| Repository binding | repository_owner_id and repository_id, exact match | Google Cloud warns that name fields invite cybersquatting. | Google Cloud and AWS documentation |
| Subject | Exact environment or branch, such as repo:org/app:environment:production | Repositories created after 15 July 2026 use repo:org@ID/app@ID:...; copy the subject from a real token. | GitHub OIDC reference |
| Wildcards | None in the subject | AWS rejects a GitHub trust policy whose sub condition is missing or only a wildcard; Azure supports no wildcards. | AWS IAM and Microsoft Entra documentation |
- Decode a real token from your workflow and confirm the policy pins
repository_owner_id,repository_idand an exact subject or environment. - Open a pull request from a branch and confirm its run cannot assume the production role.
- Request a token with another audience and confirm the cloud provider refuses the exchange.
- Search every trust policy for
*in a subject condition and for conditions that use names without IDs. - 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.
- Part of
- Learning path: Identity through the whole lifecycle (step 9 of 14). Workflows: Fix a finding, Review a design, Respond to a vulnerability
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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 practiceFrom a GitHub Actions job to short-lived AWS credentials
- Request an OIDC tokenThe job, which has the
id-token: writepermission, asks GitHub’s token endpoint for a token with the audiencests.amazonaws.com. - Issue a signed tokenGitHub signs a short-lived JWT whose claims describe the run: issuer, audience,
sub,repository_id,repository_owner_id,ref,environmentandjob_workflow_ref. - Present it to AWSThe job calls
AssumeRoleWithWebIdentitywith the token and the ARN of the role it wants. - 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.
- 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.
- 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.
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.
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.
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 admitsrepo:org/app:pull_requestand 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_nameofdynamic).
| Cloud | Where the trust lives | What it matches | Pitfall to avoid |
|---|---|---|---|
| AWS | IAM role trust policy for the token.actions.githubusercontent.com provider | aud and sub, plus repository_id, repository_owner_id, job_workflow_ref, ref and environment condition keys | StringLike with repo:org/* or repo:org/app:*; IAM refuses only a missing or pure-wildcard sub |
| Google Cloud | Workload identity pool provider with an attribute condition | A CEL condition over mapped claims, such as assertion.repository_owner_id | An attribute condition is required, because every GitHub organization shares one issuer |
| Azure | Federated identity credential on an app registration or managed identity | Exact, case-sensitive issuer, subject and audience | One credential per subject; wildcards are not supported |
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.
{
"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"
}
}
}
]
}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.
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 claimsGive 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.
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
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.com1. 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"}, actionsts:AssumeRoleWithWebIdentity, and aStringEqualscondition ontoken.actions.githubusercontent.com:aud=sts.amazonaws.com,:sub=repo:octo-org/billing:environment:production,:repository_id=456789and:repository_owner_id=123456. UseStringEquals, notStringLikewith wildcards. The policy shown on this page has exactly this shape.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 isrepo:octo-org/billing:pull_request. J3 fails onrepository_id: the name matches, but the recreated repository has a new immutable ID. J4 fails onaud, because the token was minted for a different audience.3. Find the dangerous variant
A colleague proposes
StringLikewithsub=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
audis 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. 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 frommaincan produce a token withenvironmentproduction; and grantid-token: writeonly to the release job.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
StringEqualsconditions; then run a real pull request workflow and confirmAssumeRoleWithWebIdentityis denied. Alert on any successful assumption whose session tags or CloudTrail record show a subject other than the production environment.
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.
- SPIFFE concepts
- SPIFFE Workload API
- RFC 8693: OAuth token exchange. January 2020.
- GitHub: OpenID Connect reference for GitHub Actions
- AWS IAM: create a role for OpenID Connect federation (GitHub)
- Google Cloud: workload identity federation with deployment pipelines
- Microsoft Entra: workload identity federation
Reference information
- SDLC phase
- DesignDevelopmentRelease
- ASVS 5.0.0 requirements
- v5.0.0-13.2.1 · v5.0.0-13.2.2 · v5.0.0-13.3.1