Workload Identity Federation and OIDC Token Exchange for CI/CD and Hybrid Cloud
Last verified2026-10 Also see: GitHub & CI/CD security (the five-line version of OIDC in Actions) · Auth deep dive (JWT attacks in general) · Ephemeral SSH certificates (the same exchange, but the output is an SSH certificate) · AWS IAM deep dive (roles, trust policies, session reading) · Build platform security (what the runner platform must protect)
Start here: the basics in plain words
A CI job that deploys to the cloud needs to prove to the cloud who it is. The old way was to give it a long-lived secret, an AWS access key or a GCP service-account JSON file, stored in the CI system's secret store. That secret works forever, from anywhere, for anyone who copies it, and CI systems are the place where secrets get copied most: into logs, into caches, into memory dumps, into forks.
Workload identity federation replaces the stored secret with a signed statement the CI platform makes about the job: "this is run 18223019 of repository acme/app, on branch main, in the deploy workflow, started 30 seconds ago". The cloud has been told in advance to trust statements signed by that platform, and to hand out short-lived credentials only when the statement matches a rule you wrote. Nothing is stored. The job proves an identity instead of possessing a secret.
The signed statement is an OpenID Connect (OIDC) ID token, which is a JSON Web Token (JWT): three base64 blobs separated by dots, the last of which is a signature over the first two. The cloud verifies that signature with the platform's published public keys, then reads the claims inside.
The CI platform that signs the token: GitHub Actions, Buildkite, GitLab, your own runner controller. Publishes public keys at a well-known URL.
A JWT describing one job: who, where, which branch, when. Lives minutes. The job can ask for it; nothing is stored.
The service that trusts the issuer and hands out credentials: AWS STS, GCP STS, Vault, your own API gateway.
Your rule on the relying party: "accept tokens from this issuer, for this audience, where the subject is exactly X".
Security Token Service. The cloud endpoint that swaps a verified ID token for temporary cloud credentials.
JSON Web Key Set. The issuer's public keys, fetched over TLS, used to check token signatures.
-
1. Job asks its platform for an ID tokenserverno secret involved
- GitHub: a runtime endpoint only the job can reach; Buildkite:
buildkite-agent oidc request-token
- GitHub: a runtime endpoint only the job can reach; Buildkite:
-
2. Platform signs a JWT about this jobtlsminutes of validity
- Claims: issuer, subject (repo + branch or environment), audience, repository ID, workflow, run ID, expiry
-
3. Job presents the JWT to the cloud STSnetworkAssumeRoleWithWebIdentity / GCP token exchange
- An unauthenticated call: the token is the authentication
-
4. Cloud verifies and matchesdefendsignature, iss, aud, exp, then your conditions
- Fetches the issuer's JWKS, checks the signature with the key named by
kid, then comparessuband other claims with the trust policy
- Fetches the issuer's JWKS, checks the signature with the key named by
-
5. Short-lived cloud credentials returnedserver15 minutes to an hour
- Scoped to one role; expire on their own; logged with the token's subject so CloudTrail says which CI run did what
Memory hook"the token is a signed résumé, the trust policy is the hiring rule." The platform writes the résumé and signs it; the cloud checks the signature, then checks the résumé against your rule. Every failure on this page is either a forged résumé the verifier accepted, or a rule loose enough that the wrong résumé matched.
OIDC Protocol Mechanics and the STS Exchange Flow
What is this?
Three standards stacked: JSON Web Signature (RFC 7515) defines the signed envelope, JSON Web Token (RFC 7519) defines the claims inside it, and OIDC Discovery defines how a verifier finds an issuer's keys from its URL. OAuth 2.0 Token Exchange (RFC 8693) describes swapping one token for another, which is what the cloud STS does.
Why it matters
Interviewers want to hear the exact sequence, because each step is a place a real system has been broken: discovery (fetching keys from the wrong place), signature (wrong algorithm), claims (wrong issuer or audience), and matching (a wildcard that was too wide).
How it works
The discovery chain: the issuer URL plus /.well-known/openid-configuration returns a JSON document whose jwks_uri field says where the public keys are.
$ curl -s https://token.actions.githubusercontent.com/.well-known/openid-configuration | jq '{issuer, jwks_uri, id_token_signing_alg_values_supported, claims_supported}'
{
"issuer": "https://token.actions.githubusercontent.com", ← must equal the iss claim in every token, byte for byte
"jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks", ← the only place keys may come from
"id_token_signing_alg_values_supported": ["RS256"], ← the verifier should accept exactly this and nothing else
"claims_supported": ["sub","aud","exp","iat","iss","jti","nbf","ref","repository","repository_id","repository_owner","repository_owner_id","run_id","run_number","run_attempt","actor","actor_id","workflow","workflow_ref","job_workflow_ref","head_ref","base_ref","event_name","ref_type","environment","runner_environment","sha", …]
}A decoded GitHub Actions ID token, with the claims that matter annotated:
- iss
https://token.actions.githubusercontent.com. Compared as an exact string; a prefix or suffix match is a vulnerability- sub
repo:acme/app:ref:refs/heads/main,repo:acme/app:environment:prod,repo:acme/app:pull_request, orrepo:acme/app:ref:refs/tags/v1.2. Repos created after 15 July 2026 default torepo:acme@65/app@74213:ref:refs/heads/main, embedding owner and repository IDs- aud
- What the token is for. Default is the repository owner URL; set it to
sts.amazonaws.comfor AWS or the full provider resource name for GCP so a token minted for one relying party is useless at another - exp, iat, nbf
- Unix seconds. GitHub tokens live minutes; Buildkite's default is five minutes
- jti
- Unique token ID. Your own verifier can refuse a repeat; cloud providers do not promise to
- repository_id, repository_owner_id
- Numeric IDs that survive renames, deletions and transfers.
repositoryandrepository_ownerare names and do not - job_workflow_ref
acme/app/.github/workflows/deploy.yml@refs/heads/main. Pins which reviewed workflow file minted the token- ref, ref_type, sha
- Branch or tag, and the exact commit
- event_name, actor, actor_id
push,pull_request_target,workflow_dispatch; who triggered it- runner_environment
github-hostedorself-hosted. A trust policy can refuse tokens from self-hosted runners for production- environment
- Present only when the job targets a protected environment, which means required reviewers already approved it
Buildkite's token has the same shape with its own names: issuer https://agent.buildkite.com, subject organization:acme:pipeline:app:ref:refs/heads/main:commit:<sha>:step:deploy, and claims such as organization_slug, pipeline_slug, build_branch, build_number, step_key, agent_id and runner_environment. The default audience is https://buildkite.com/<org> and the default lifetime is five minutes.
The exchange itself, on AWS:
sequenceDiagram participant J as CI job participant G as GitHub OIDC issuer participant S as AWS STS participant I as IAM role trust policy J->>G: POST token request<br/>(runtime URL + bearer, aud=sts.amazonaws.com) G-->>J: ID token (JWT, RS256) J->>S: AssumeRoleWithWebIdentity<br/>(RoleArn, WebIdentityToken, DurationSeconds) S->>G: GET /.well-known/jwks (cached) S->>S: Verify signature, iss, aud, exp S->>I: Evaluate Condition on<br/>token.actions.githubusercontent.com:sub / :aud I-->>S: Allow S-->>J: AccessKeyId, SecretAccessKey,<br/>SessionToken (expires)
Two things people get wrong about this diagram:
AssumeRoleWithWebIdentity is an unsigned API callThere is no AWS credential involved; the JWT is the whole authentication. That is why the trust policy's conditions are the only thing standing between "anyone with a GitHub repo" and your role.
Condition keys are named <issuer-host>:<claim>, so token.actions.githubusercontent.com:sub is literally "the sub claim of a token from that issuer". Only sub, aud and a few others are exposed as condition keys on AWS; GCP exposes every claim through attribute mappings, which is the big practical difference covered below.
In practiceFetching and decoding a token inside a GitHub Actions job. The two environment variables are set only when the job has id-token: write, and any step in that job can read them, which is the ambient-token problem covered later:
$ curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value > /tmp/t
$ cut -d. -f2 /tmp/t | base64 -d 2>/dev/null | jq '{iss,sub,aud,repository_id,job_workflow_ref,event_name,runner_environment,exp}'
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:acme/app:environment:prod", ← the environment form: a reviewer approved this deployment
"aud": "sts.amazonaws.com",
"repository_id": "74213", ← pin this, not the name
"job_workflow_ref": "acme/app/.github/workflows/deploy.yml@refs/heads/main",
"event_name": "push",
"runner_environment": "github-hosted",
"exp": 1761342000
}🎯On the job. When you review a pipeline, ask for the decoded
subof a real token and the trust policy side by side. Nine times out of ten the policy was written from documentation and the token's actual subject is a different form (branch vs environment vs pull request). Mismatches get "fixed" by loosening the policy to a wildcard, which is the bug you are there to catch.
Cryptographic Verification Pipeline
What is this?
What a verifier must do, in order, before it believes a single claim. Cloud providers do this for you; you do it yourself when you write a sidecar, an internal API gateway, or a Vault-like broker that accepts CI tokens.
Why it matters
Every famous JWT vulnerability is a verifier that did these steps out of order or let the token choose how it would be verified. The token is attacker-controlled input until the signature check passes, and the signature check itself must not depend on anything inside the token except the key identifier.
How it works
-
1. Parse syntacticallydefendthree base64url parts, header and payload are JSON
- Reject anything else before touching crypto; limit size (a few KB)
-
2. Fix the algorithm from configuration, never from the tokendefend
algallowlist per issuer- GitHub: RS256 only. If the header says
none,HS256or anything else, stop. RFC 8725 says exactly this
- GitHub: RS256 only. If the header says
-
3. Select the key by
kidfrom a JWKS you fetched from a pinned URLdefendnever fromjku,x5uor an issuer URL read out of the token- Unknown
kid: refetch the JWKS at most once per minute, then fail
- Unknown
-
4. Verify the signaturedefendwith the selected public key and the configured algorithm
- Reject degenerate signatures (ECDSA r or s equal to zero), which some libraries once accepted
-
5. Only now read the claimsdefendiss exact, aud contains yours, exp/nbf with bounded skew
- Then your semantic rules: subject pattern, repository ID, workflow ref, runner environment
The attacks, each explained from zero:
Algorithm confusion (RS256 to HS256)
The piecesRS256 is an asymmetric signature: the issuer signs with a private key, everyone verifies with the public key. HS256 is a symmetric MAC: one shared secret both signs and verifies. The JWT header carries an alg field saying which one was used.
What went wrongOlder libraries had a single verify(token, key) call that read alg from the token and did what it said. An attacker took the issuer's public key, which is public, built a token with alg: HS256, and computed an HMAC using the public key bytes as the secret. The library saw HS256, used the key it was given (the public key) as the HMAC secret, and the MAC matched.
Why it's badThe attacker mints any claims they like, including sub: repo:acme/app:environment:prod, and the verifier accepts them. This was CVE-2015-9235 in the Node jsonwebtoken library and the same bug appeared in several other libraries that year.
LessonThe verifier chooses the algorithm from configuration. Keys are typed: an RSA public key can never be handed to an HMAC routine. Modern libraries enforce both; your code must still pass an explicit algorithm list.
alg: none
The piecesThe JWS specification allows an "unsecured" JWT with alg: none and an empty signature, intended for contexts where integrity is provided elsewhere.
What went wrongLibraries that supported none by default accepted a token with the signature stripped as valid.
LessonAllowlist, do not denylist: ["RS256"], not "anything except none".
Psychic Signatures (CVE-2022-21449)
The piecesAn ECDSA signature is two numbers, r and s. The verification equation involves dividing by s and multiplying by r; the standard requires both to be in the range 1 to n−1, where n is the curve order.
What went wrongJava 15 through 18, before the April 2022 critical patch update, rewrote ECDSA verification and forgot the range check. A signature with r = 0 and s = 0 made the verification equation trivially true: the "proof" was that zero equals zero.
Why it's badAny ES256-signed JWT, SAML assertion or WebAuthn response could be forged with an all-zero signature against a verifier running an affected Java. OIDC issuers that sign with ES256 were directly exposed.
LessonSignature verification is not a solved problem you can ignore; pin library versions, run the test vectors, and prefer issuers that publish which algorithms they use so you can restrict yours.
Header-driven key fetching (jku, x5u, kid injection)
The piecesThe JWS header may carry jku (a URL to a JWKS) or x5u (a URL to a certificate), and kid is a free-form string.
What went wrongVerifiers that fetched keys from the URL in the header let the attacker supply their own key. Verifiers that used kid as a file path or SQL fragment got path traversal or injection.
Lessonkid is only ever a lookup key into a JWKS you fetched from a URL you configured. jku and x5u are ignored.
In practiceA verifier in Go using the go-oidc library, which already pins the algorithm list, fetches keys from the pinned discovery document and refuses jku. The lines that matter are the configuration ones:
provider, err := oidc.NewProvider(ctx, "https://token.actions.githubusercontent.com") // ← discovery from a constant, not from the token
verifier := provider.Verifier(&oidc.Config{
ClientID: "internal-artifact-gateway", // ← aud must contain exactly this
SupportedSigningAlgs: []string{oidc.RS256}, // ← allowlist; header alg is ignored otherwise
})
idTok, err := verifier.Verify(ctx, rawToken) // ← signature, iss, aud, exp, nbf (with small skew) all checked here
if err != nil { return deny(err) }
var c struct {
Sub, RepositoryID, JobWorkflowRef, RunnerEnvironment string `json:"sub"` // tags elided for brevity
}
if err := idTok.Claims(&c); err != nil { return deny(err) }
if c.RepositoryID != "74213" || c.JobWorkflowRef != "acme/app/.github/workflows/deploy.yml@refs/heads/main" { return deny(...) } // ← semantic rules only after cryptoThe TypeScript equivalent with jose has the same shape: createRemoteJWKSet(new URL(jwksUri)) from a constant, then jwtVerify(token, jwks, { issuer, audience, algorithms: ['RS256'] }), then your claim checks.
🎯On the job. In code review, search for
decode(withoutverify(, foralgorithmslists that are missing or contain more than one entry, for any JWKS URL built from a string that came out of the request, and for akidused in anything other than a map lookup. Each is a finding regardless of what the surrounding code intends.
JWKS Cache Management and Key Rotation
What is this?
Issuers rotate signing keys. A verifier that fetched the JWKS once at startup will reject every token after the rotation; one that fetches on every request will fall over, or be used as a denial-of-service amplifier, and becomes an SSRF (server-side request forgery) risk if the URL is ever influenced by input.
Why it matters
This is the operational half of the verification question, and the one that causes outages. A good answer shows you know the failure modes in both directions: too stale and you fail closed at 3am, too eager and you are a weapon.
How it works
The issuer URL is configuration. The verifier fetches /.well-known/openid-configuration from it and takes jwks_uri from the response. It never derives either from a token, which is how issuer-confusion SSRF happens.
kidKeep every key from the last fetch. On a token with an unknown kid, refetch at most once per short interval (one minute is common) and fail the request if the key is still unknown. That handles rotation without letting an attacker trigger a fetch per request.
Issuers overlap old and new keys for a window; tokens signed just before rotation must still verify. Do not evict keys the moment a new set arrives; evict by age or by their absence across several fetches.
Cache-Control, with floors and ceilingsRespect the issuer's hints, but never cache for less than a minute (amplification) or more than a day (stale key after an emergency rotation).
If the JWKS endpoint is unreachable and the cache is empty, deny. If the cache is populated but stale, keep serving from it while retrying in the background, and alert.
TLS to the pinned host, a size limit on the response, only keys with use: sig and the expected kty and alg.
In practiceA JWKS with two keys during a rotation, and the log line a healthy verifier emits when it meets a new kid:
$ curl -s https://token.actions.githubusercontent.com/.well-known/jwks | jq '.keys[] | {kid, kty, alg, use}'
{ "kid": "1F2AB834…", "kty": "RSA", "alg": "RS256", "use": "sig" } ← current (values illustrative)
{ "kid": "38826b17…", "kty": "RSA", "alg": "RS256", "use": "sig" } ← previous, still valid for in-flight tokens
# verifier log
level=info msg="jwks refresh" issuer=token.actions.githubusercontent.com reason=unknown_kid kid=1F2AB834… keys_before=1 keys_after=2 next_allowed_refresh=60s ← one refresh, then rate-limited📰Real incident — GitHub certificate change breaks AWS federation (2023). For years, creating an AWS IAM OIDC provider required pasting the SHA-1 thumbprint of the issuer's TLS certificate chain, and when GitHub's intermediate certificate changed in early 2023 many pipelines broke until administrators added a second thumbprint. AWS then changed the design: it now verifies the JWKS endpoint's TLS certificate against its own trusted root store and falls back to the configured thumbprints only when the issuer uses a certificate from an unknown CA. The lesson is general: pinning a leaf or intermediate certificate for a key-distribution endpoint trades a theoretical attack for a guaranteed future outage; pin the issuer URL and verify TLS normally.
🎯On the job. Load-test the verifier with tokens carrying a random
kid. If the JWKS endpoint sees one outbound request per inbound token, you have found an amplifier and a latency bug at once. Then pull the network and confirm it keeps serving from cache for a while and alerts, rather than either denying everything instantly or serving forever.
Standard Claim Validation Logic
What is this?
After the signature, the registered claims: iss, aud, exp, nbf, iat, jti. Each has a correct comparison and a tempting wrong one.
Why it matters
These are cheap string and integer checks, which is why they get skipped or done loosely. A loose iss check lets a token from a different issuer with the same key format through; a loose aud check lets a token minted for the wiki unlock production.
How it works
issExact string equality with the configured issuer, including scheme and trailing-slash behaviour. Not "starts with", not "contains the domain". With multiple trusted issuers, select the configuration by exact match first, then verify with that issuer's keys only.
audMay be a string or an array. Pass if your identifier is present, fail otherwise. A verifier that accepts any audience, or that accepts a token with no aud, is accepting tokens minted for other services. On AWS this is the :aud condition key; on GCP the provider's allowed audiences list.
exp and nbfCurrent time must be before exp and at or after nbf, with a skew allowance of at most a minute or two. A large allowance is a replay window; zero allowance breaks on ordinary clock drift. Short-lived tokens make the right value obvious: if tokens live five minutes, a five-minute skew doubles their life.
iatSanity bound: refuse tokens "issued" in the future beyond the skew, or absurdly far in the past even if exp says otherwise.
jtiFor your own relying party, store it until exp and refuse repeats; a CI token should be exchanged exactly once. Cloud STS endpoints do not promise single use, which is one reason to keep the token's lifetime and the resulting session short.
When a token's aud names a resource (https://artifacts.internal) rather than a client, verify against the resource name your service actually is, not a shared value every internal service accepts.
In practiceThe validation function's failure messages, which double as your test cases. Each line corresponds to a bug that has shipped somewhere:
deny: iss mismatch got="https://token.actions.githubusercontent.com/" want="https://token.actions.githubusercontent.com" ← trailing slash; exact match caught it
deny: aud missing got=["https://github.com/acme"] want="internal-artifact-gateway" ← default audience; the workflow forgot audience=
deny: token expired exp=1761342000 now=1761342130 skew=60s ← 130 s late; 60 s skew refused it
deny: nbf in future nbf=1761342300 now=1761342000 ← clock on the issuer side? investigate before raising skew
deny: jti replay jti=a3f9c1… first_seen=12s_ago ← the same token presented twice🎯On the job. Clock skew tickets are a smell. When someone asks to raise the allowance "because tokens keep expiring", find out why the token takes that long to be presented; usually a step is minting the token at the start of a long job and exchanging it at the end. Fix the ordering, not the window.
Subject Claim Anatomy and Wildcard Traps
What is this?
The sub claim is where authorisation actually happens: it is the one claim both AWS and GCP let you match on by default, and its structure invites prefix matching that is wider than it looks.
Why it matters
This is the single most common real-world misconfiguration in CI-to-cloud federation, and the question interviewers ask most often in this area. A trust policy is an IAM policy; a wildcard in it is a wildcard in who can be your production role.
How it works
What the subject forms mean, and how each can be matched too loosely:
repo:acme/app:ref:refs/heads/mainOne branch of one repository. Safe when matched exactly. The risk is upstream: anyone who can push to main (or merge into it) can mint this subject.
repo:acme/app:environment:prodIssued only when the job targets the prod environment, which means environment protection rules (required reviewers, branch restrictions) passed. This is the strongest subject for production because a human approval is encoded in it.
repo:acme/app:pull_requestIssued for pull request runs. Fork pull requests run with read-only permissions and cannot mint tokens; but a pull_request_target workflow runs in the base repository's context with full permissions and can, while checking out or executing code influenced by the fork. That is the pwn-request pattern.
repo:acme/*Matches every repository in the organisation, present and future. A new sandbox repo, a forked-then-imported repo, an archived repo someone still has write access to: all can assume the role.
repo:acme/app:*Every branch, tag, pull request and environment of one repo. A feature branch pushed by a junior developer, or a dependency-update bot's branch, deploys to production.
StringLike with * anywhererepo:acme/app:ref:refs/heads/release-* sounds narrow, but anyone with push access to any branch name starting with release- matches. Branch protection rules are not part of the token.
Names versus IDs: repository is acme/app. If the repository is deleted or transferred and a new repository with the same name appears under the same owner, it inherits every trust policy written against the name. repository_id (74213) and repository_owner_id (65) are numeric and never reused, which is why GitHub moved the default subject to the immutable form repo:acme@65/app@74213:ref:refs/heads/main for repositories created after 15 July 2026, and why custom subject templates let older repositories opt in.
In practiceThe dangerous policy next to the safe one. Both "work" in the demo; only one survives an insider or a compromised dependency:
"Condition": {
"StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
"StringLike": { "token.actions.githubusercontent.com:sub": "repo:acme/*" } ← any repo in the org; a new sandbox repo is now a prod deployer
}"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme@65/app@74213:environment:prod" ← immutable IDs + a reviewed environment; nothing else matches
}
}If you cannot yet use the immutable subject, customise the subject template to include repository_id and job_workflow_ref (include_claim_keys), so the policy can pin both the repository and the reviewed workflow file rather than a name anyone can recreate.
📰Real incident — Ultralytics on PyPI (2024). The popular
ultralyticsPython package shipped malicious releases in December 2024 that ran a cryptominer on install. Attackers did not steal a PyPI password: they exploited apull_request_targetworkflow that interpolated a fork's branch name into a shell command, ran their code inside the trusted repository's context, and from there tainted the release pipeline that publishes to PyPI. The identity federation worked exactly as designed; the workflow that held the identity was the weak point. The lesson: an OIDC identity is only as trustworthy as every step that can execute inside the job that mints it.
🎯On the job.
aws iam list-rolesthen, for each role trustingtoken.actions.githubusercontent.com, print thesubcondition. Anything withStringLikeand a*, or any role with only anaudcondition and nosubat all (which accepts tokens from any GitHub repository on earth), is a critical finding. The GCP equivalent is listing workload identity pool providers and reading their attribute conditions.
Cloud Provider Trust Boundary Configuration
What is this?
AWS and GCP both accept external OIDC tokens, but they expose the token differently: AWS gives you a few condition keys inside an IAM trust policy; GCP gives you a programmable mapping layer in CEL (Common Expression Language) that can read every claim.
Why it matters
Knowing the expressiveness gap tells you where each provider's sharp edge is. On AWS the mistake is a wide sub. On GCP the mistake is a mapping that collapses distinct identities into one subject, or an attribute condition that was never written.
How it works
AWS
Created once per issuer URL and audience list. AWS now verifies the issuer's TLS certificate against its own trusted root store and uses configured thumbprints only as a fallback.
sts:AssumeRoleWithWebIdentity allowed for the provider's ARN, with Condition on :aud (always) and :sub (always, exact). Only a small set of claims are available as condition keys, so if you need to pin job_workflow_ref or repository_id you encode them in a custom subject.
DurationSeconds is capped by the role's maximum session duration; use the minimum the job needs. The session name should be set by the job to something traceable (run ID), and CloudTrail records the token's sub in the AssumeRoleWithWebIdentity event.
The role's own policy should be minimal, and a service control policy can deny AssumeRoleWithWebIdentity from any provider you have not explicitly approved.
GCP
The pool is a container; the provider holds the issuer URL, allowed audiences and the mapping. Default audience is the full provider resource name, which is already a strong binding.
CEL expressions from assertion.* (the token) to google.subject and up to 50 attribute.* fields. google.subject must be unique and at most 127 characters, which GitHub subjects with long environment or branch names can exceed; map it to something stable and short, such as assertion.repository_id + ":" + assertion.ref.
A CEL boolean that must be true or the token is rejected before any IAM evaluation: assertion.repository_owner_id == "65" && assertion.repository_id == "74213" && assertion.job_workflow_ref == "acme/app/.github/workflows/deploy.yml@refs/heads/main". This is where GCP is more expressive than AWS: every claim is available.
IAM bindings reference principal://…/subject/<value> for one identity or principalSet://…/attribute.repository/acme/app for everything that mapped to that attribute value. Prefer direct resource access with these principals over service account impersonation when the resource supports it; impersonation adds a second hop (generateAccessToken) and a second place to misconfigure.
In practiceThe GCP provider definition that enforces the same policy as the safe AWS example above, with the mapping and the condition both visible:
$ gcloud iam workload-identity-pools providers describe github --location=global --workload-identity-pool=ci --format=yaml
attributeCondition: assertion.repository_owner_id == "65" && assertion.repository_id == "74213" && assertion.job_workflow_ref == "acme/app/.github/workflows/deploy.yml@refs/heads/main" ← rejected before IAM if false
attributeMapping:
google.subject: assertion.repository_id + ":" + assertion.ref ← short, stable, under 127 chars
attribute.repository_id: assertion.repository_id
attribute.environment: assertion.environment
oidc:
allowedAudiences:
- //iam.googleapis.com/projects/1234567890/locations/global/workloadIdentityPools/ci/providers/github ← default aud; tokens for anything else fail
issuerUri: https://token.actions.githubusercontent.com🎯On the job. On GCP, a provider with no
attributeConditionaccepts every token the issuer signs; the only thing then standing between any GitHub repository and your project is whichprincipalSetbindings exist. On AWS, read the trust policy, not the permissions policy, first: a perfectly scoped permissions policy on a role anyone can assume is still a breach.
Securing Ephemeral Runners and Multi-Tenant CI
What is this?
The token is minted inside the job, where every step, every third-party action and every dependency's install script runs. Protecting the exchange means protecting the environment that holds the token for its few minutes of life.
Why it matters
Federation removes the stored secret, but it creates an ambient credential: in GitHub Actions, any process in a job with id-token: write can read two environment variables and mint a token. A malicious dependency does not need to steal anything from a vault; it asks the platform politely.
How it works
Where the token can leak, and the control for each:
Split jobs: the job that needs cloud access contains only trusted, SHA-pinned steps and nothing that installs or runs third-party code; build and test run in a separate job with id-token: none. Exchange the token as the first step, then unset ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL for the rest.
pull_request_target and other base-context triggersNever check out or execute fork-controlled content in a workflow that can mint tokens. If you must, use a two-workflow pattern where the privileged workflow consumes only artifacts, never code.
The runner process holds the request token and any exchanged credentials. On a self-hosted runner, nothing untrusted should run as the same user as the runner; on a sandboxed runner (gVisor or a microVM per job, see sandbox isolation), a dump of the job's own memory still exposes the job's own token, so short lifetimes and immediate exchange matter.
Masking covers known secret values, not tokens you printed yourself. Lint for echo $ACTIONS_ID_TOKEN_REQUEST_TOKEN, env and printenv in workflows.
A step that writes the credentials file into a cached directory (~/.aws, ~/.config/gcloud) ships them to the next run and to anyone who can restore the cache.
runner_environment: self-hosted in the token lets a trust policy refuse production access from machines you do not control as tightly; on bare-metal EPYC or AmpereOne runner pools and ephemeral macOS vz guests, use one-shot runners (just-in-time registration) so a token or credential never outlives the VM.
Pair the exchange with a custom egress proxy and dedicated egress IP pools: cloud credentials obtained in the job should only be usable from the runner's addresses (aws:SourceIp conditions on the role's policy, VPC endpoint policies), and the proxy should only allow the cloud endpoints the deploy needs, so an exfiltrated session token is useless from anywhere else.
-
1. Malicious dependency runs inside the jobattackpostinstall script
- Reads the two request environment variables, mints a token with aud=sts.amazonaws.com
-
2. Exchanges it for the deploy roleattackfrom inside the runner
- Succeeds, because the trust policy only sees a valid subject
-
3. Where the design stops itdefendthree independent walls
- The role's policy has
aws:SourceIppinned to the egress pool → credentials cannot be used from the attacker's box - The egress proxy only allows
s3.eu-west-1.amazonaws.comandsts→ the dependency cannot phone home with them - The exchange was done in a separate, dependency-free job → the dependency never had
id-token: writeat all
- The role's policy has
In practiceThe workflow shape that keeps dependencies and tokens in different jobs. The deploy job runs nothing but pinned first-party steps:
jobs:
build:
permissions: { contents: read, id-token: none } # ← npm/pip/cargo run here; no token exists to steal
steps: [ ... ] # produces an artifact
deploy:
needs: build
environment: prod # ← sub becomes repo:…:environment:prod after reviewer approval
permissions: { contents: read, id-token: write }
steps:
- uses: aws-actions/configure-aws-credentials@<full-sha> # ← first step: exchange immediately
with:
role-to-assume: arn:aws:iam::123456789012:role/app-prod-deploy
role-duration-seconds: 900 # ← 15 minutes, the job needs 4
role-session-name: gha-${{ github.run_id }} # ← shows up in CloudTrail
- uses: actions/download-artifact@<full-sha>
- run: aws s3 sync ./dist s3://app-prod-site --delete # ← no third-party code runs in this job📰Real incident — tj-actions/changed-files (CVE-2025-30066, 2025). Between 12 and 15 March 2025, a GitHub Action used by more than 23,000 repositories had its version tags retroactively pointed at a commit that dumped the runner worker's memory and printed the contents, double-base64 encoded, into the job log. Every secret and credential the job held was exposed to anyone who could read the logs, including public repositories' logs. The attackers got in through a compromised bot token, likely via an earlier compromise of another action. CISA added it to the Known Exploited Vulnerabilities list. The lesson for federation: a token exchanged early and expiring in minutes limited the damage in jobs that used OIDC; jobs with long-lived keys in secrets had to rotate everything. And nothing about OIDC helps if the step that dumps memory runs in the same job as the exchange.
🎯On the job. For each workflow with
id-token: write, list everyuses:and every package manager invocation in that job. If anything third-party runs there, split the job. Then check whetherpull_request_targetappears anywhere in the repository with a checkout ofgithub.event.pull_request.head.sha: that is the Ultralytics shape.
Engineering a Custom Token Verifier
What is this?
The same verification, but for your own services: an internal API gateway that lets CI runs fetch build caches, a sidecar that gates access to bare-metal rack management, a broker that issues SSH certificates or database credentials to jobs. You become the relying party.
Why it matters
Cloud providers ship a hardened verifier. Yours starts as forty lines that pass the demo. The interview question is whether you know what the other hundred lines are for.
How it works
Checklist for a production verifier, in the order the code should run:
Accept the token only over TLS, in an Authorization: Bearer header, with a size limit. Never in a query string (it ends up in access logs).
Parse the payload without trusting it only to read iss, select the matching configured issuer by exact string equality, and use that issuer's pinned discovery URL, allowed algorithms and audience. A token whose iss matches nothing is rejected before any network call.
From the per-issuer JWKS cache described above; kid is a map key, nothing more.
With the configured algorithm; then aud, exp, nbf, iat, skew of 60 seconds, jti recorded until exp.
A declarative table mapping claim predicates to permissions: (iss=github, repository_id=74213, job_workflow_ref=…deploy.yml@refs/heads/main, runner_environment=github-hosted) → scope: push:cache/acme-app. Evaluate the table; never string-interpolate claims into anything.
A credential that is weaker than the input: shorter-lived, narrower in scope, bound to the caller's address if possible (cnf claims per RFC 7800, or a source-address on an SSH certificate).
Log iss, sub, jti, the matched policy row and the issued scope; never log the raw token.
The negative cases above as fixtures: wrong alg, none, HS256-with-public-key, unknown kid, expired, wrong aud, trailing-slash issuer, replayed jti, r=s=0 for ES256 if you accept it.
In practiceThe request path through such a gateway, as a sequence, with the one step people forget (issuer selection before key fetch):
sequenceDiagram participant R as CI runner participant V as Verifier sidecar participant K as JWKS cache participant P as Policy table R->>V: Bearer <jwt> V->>V: Parse; read iss (untrusted)<br/>select issuer config by exact match V->>K: key(kid) for that issuer K-->>V: RSA public key (cached) V->>V: Verify RS256; check aud, exp, nbf, jti V->>P: Match claims → scope P-->>V: scope=push:cache/acme-app V-->>R: Short-lived scoped token<br/>bound to runner egress IP
🎯On the job. Ask for the verifier's test file before its implementation. If the negative cases are not there, treat the verifier as unverified. Then check the logging: a verifier that logs the raw bearer token at debug level has turned your log pipeline into the credential store you were trying to eliminate.
Interview Q&A
Syntactic first: three base64url parts within a size limit, valid JSON header and payload, then read only the issuer and select a configured issuer by exact string match, refusing anything unknown before any network call. Cryptographic second: the algorithm comes from that issuer's configuration, never from the header, the key is looked up by kid in a JWKS cached from the pinned discovery document, and the signature is verified, rejecting degenerate values like an all-zero ECDSA signature. Semantic last: audience contains ours, expiry and not-before within about a minute of skew, jti not seen before, and then the policy rules on sub, repository ID, workflow reference and runner environment, because claims are untrusted input until the signature passed.
RS256 verifies with a public key and HS256 verifies with a shared secret, and a library that reads alg from the token and calls a generic verify with whatever key it was handed will, for a token relabelled HS256, use the issuer's public key as the HMAC secret, which the attacker also has, so a forged token's MAC matches; this was CVE-2015-9235 in the Node jsonwebtoken library. The fix is to pass an explicit algorithm allowlist, ["RS256"], to go-oidc's SupportedSigningAlgs or jose's algorithms option, use typed keys so an RSA key can never reach an HMAC routine, and treat none the same way by allowlisting rather than denylisting. In an interview, add that kid, jku and x5u get the same treatment: the token never decides how it is verified.
StringLike on sub equal to repo:acme-org/* dangerous, and what attack path does it open?It lets a token from any repository in the organisation assume the role, including repositories that exist only in the future: a sandbox a developer creates, a repository transferred in, or a fork imported under the org. An insider with write access to any of them, or a compromised dependency running in any of their workflows, writes a two-step workflow that requests a token with audience sts.amazonaws.com and calls AssumeRoleWithWebIdentity, and the trust policy sees a matching subject. The fix is StringEquals on a full subject that names a reviewed environment, using the immutable owner and repository IDs, and if you need breadth, GCP-style per-claim conditions encoded through a custom subject template rather than a wildcard.
AWS exposes only a handful of token claims as condition keys, chiefly sub and aud, inside an ordinary IAM trust policy, so any finer control has to be packed into a customised subject string. GCP puts a CEL mapping layer in front of IAM: you map any claim into google.subject and up to fifty attributes, write an attribute condition over every claim that rejects the token before IAM is consulted, and then bind permissions to principal or principalSet identifiers derived from those attributes, with a practical gotcha that the subject is limited to 127 characters. The trade is that AWS is harder to express but hard to get subtly wrong, while GCP is expressive and a provider with no attribute condition accepts every token the issuer signs.
Fetch the discovery document from a configured issuer URL, never one taken from a token, take jwks_uri from it, and cache the keys by kid for a bounded time, keeping previous keys through a rotation window and evicting by age rather than at the first sight of a new set. On an unknown kid, refetch at most once per minute and otherwise fail that request, so an attacker sending random key IDs cannot make you hammer the issuer. Validate the fetched set, TLS to the pinned host, size-limited, signing keys of the expected type only, and fail closed when the cache is empty but keep serving a stale cache while retrying and alerting when the endpoint is down.
A subject like repo:acme/app is a name, and names are reusable: delete or transfer the repository and recreate one with the same name under the same owner, or rename an unrelated repository into place, and every trust policy written against the name now trusts the newcomer. repository_id and repository_owner_id are numeric and never reissued, so a policy pinned to them follows the original project through renames and is dead the moment the project is deleted. That is why GitHub's default subject for repositories created after 15 July 2026 embeds both IDs, as repo:owner@ownerid/name@repoid:ref:…, and why older repositories should opt in through a custom subject template.
Accept that any code in a job with id-token: write can mint a token, and design so that no untrusted code is in that job: build and test with dependencies in one job with no token permission, deploy in a separate job whose only steps are SHA-pinned first-party actions, exchange the token in the first step and clear the request variables after. Make the runner one-shot so nothing outlives the job, keep the token and the exchanged session to minutes, refuse production roles for runner_environment: self-hosted unless the pool is one you harden, and never run pull_request_target workflows that check out fork code. The tj-actions compromise in March 2025 dumped runner memory into logs; jobs built this way lost a token already spent and expiring, not a long-lived key.
Give each runner pool a small, fixed set of egress addresses through a NAT gateway or an egress proxy, then write the cloud side to require them: an aws:SourceIp or VPC endpoint condition on the role's permissions, or the equivalent IP-bound access level on GCP, so a session token exfiltrated to another machine is refused on the first API call. The proxy itself allows only the cloud endpoints the job needs, so a compromised dependency cannot both use the credentials and phone home, and its logs give you a per-job record of every destination. With a token that lives minutes, a session that lives fifteen, and credentials usable only from an address the attacker does not have, a theft is a log line rather than an incident.
The trust policies, for wildcards or a missing sub condition; the workflows, for id-token: write on jobs that also run third-party code or pull_request_target with a fork checkout; the session durations and whether anything writes credentials into a cache; and the self-hosted runner story, because a long-lived runner with a cloud instance role is a persistent credential by another name. Then the relying parties they built themselves, the artifact gateway or the SSH broker, for algorithm allowlists, pinned JWKS, exact issuer matching and negative tests. OIDC removes the stored secret; it does not remove the need to review who is trusted, from where, for how long.