Workload Identity & Pipeline IAM
Most pipelines still authenticate to the cloud the way a person would: a long-lived access key, dropped into a secrets store, used forever until someone remembers to rotate it. Workload identity replaces that key with something a CI job earns fresh on every single run — a signed token asserting exactly which workflow, which branch, which environment is asking — traded at the moment of use for a credential that expires on its own. This page covers the two halves of doing that well: federating a CI job's identity to AWS, GCP, and Azure over OIDC instead of storing a key, extending the same idea to service-to-service calls with SPIFFE and SPIRE, and — the part teams skip because it's more work than flipping on OIDC — designing IAM roles scoped to one pipeline job instead of one broad role every job shares.
A hotel key card only opens your room, and it stops working the day you check out — the front desk doesn't hand out a master key that opens every room forever just because it's less paperwork. A static cloud credential in a CI secret is the master key: made once, works everywhere, kept until somebody remembers to cut a new one. Workload identity is the hotel doing it right — you show your ID at the desk each time (that's the OIDC token), and you get back a key card that only opens your room and stops working at checkout (that's the temporary credential).
Why a stored cloud credential is a liability CI didn't need to accept
☺ Like you're 10: A key that never expires and opens every door is a much bigger problem to lose than a key that opens one door and stops working in an hour.
The default pattern most teams start with looks like this: create an IAM user (or a service account, or an app registration), generate a static key pair, and paste it into AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY repository secrets. Every workflow in the repo — the build, the test suite, the staging deploy, the production deploy — reads the same two secrets. That single fact creates three separate problems that compound on each other. First, IAM access keys have no native expiry; a key generated two years ago for a pipeline that's since been rewritten twice is still valid today unless someone deliberately revoked it. Second, because the key is shared across every job, its attached policy accumulates permissions over time — whatever any job has ever needed gets added and nothing gets removed, because nobody can be sure which job still depends on which grant. Third, the key sits somewhere it can leak: printed accidentally into a build log, exfiltrated by a compromised third-party GitHub Action referenced by a mutable tag rather than a pinned commit SHA, or read by a malicious postinstall script in a dependency — see security in CI/CD for the pipeline-hardening side of that same risk.
These aren't hypothetical. In April 2021, Codecov disclosed that its Bash Uploader script had been modified for roughly two months, silently exfiltrating CI environment variables — including cloud credentials — from any pipeline that ran it. In January 2023, CircleCI disclosed that a compromised engineer laptop had exposed its systems and advised every customer to rotate every secret stored on the platform, not just the ones known to be touched. Both incidents share the same shape: the credential itself was fine right up until the thing storing it wasn't, and a static key has no way to notice that and stop working on its own. Workload identity's entire value proposition is removing the "thing storing it" from the equation. If there's no standing key anywhere, a compromised build log or a compromised third-party action has nothing durable to steal — at worst it catches a token that's already expired by the time anyone could misuse it.
How OIDC federation trades a signed token for a temporary credential
☺ Like you're 10: Instead of holding a key all the time, the job shows an ID badge that's only valid for this one errand, and trades it for a key that stops working the moment the errand's done.
The mechanism is OpenID Connect (OIDC) federation, and it's the same protocol you've likely used to "sign in with Google" on some other website, pointed at a cloud provider's identity and access system instead of a login page. GitHub Actions runs an OIDC identity provider at https://token.actions.githubusercontent.com. When a workflow requests it — which requires explicitly granting permissions: id-token: write, since it's not on by default — GitHub mints a short-lived, signed JSON Web Token (JWT) containing claims that describe the exact calling context: sub (a structured string identifying the repo, ref, or environment), aud (who the token is meant for), repository, ref, actor, and — for reusable workflows — job_workflow_ref, which pins the token to the precise workflow file and ref that produced it.
The job presents that token to the cloud provider's token-exchange endpoint. The provider does two independent checks before handing anything back: it verifies the token's signature against GitHub's published JSON Web Key Set (JWKS) — proving the token really was issued by GitHub and hasn't been tampered with — and it checks the token's claims against whatever trust policy you configured — proving this specific token is allowed to assume this specific role. Only if both pass does it return a credential, and that credential is short-lived by construction: an AWS STS session (roughly an hour by default, tunable up to the role's MaxSessionDuration), a scoped Google access token, or an Azure AD access token. Nothing in that flow was ever written to a secrets store — the credential exists only in the job's memory for the lifetime of the run.
This is the same trick showing up in four different places once you know to look for it. GitHub Actions OIDC federates a CI job's identity to cloud IAM. IAM Roles for Service Accounts (IRSA) on EKS and GKE Workload Identity do the identical thing for a running Pod calling a cloud API — see Kubernetes Security Deep Dive for the RBAC half of that same cluster. Sigstore's keyless signing, via Fulcio, trades an OIDC identity for a short-lived code-signing certificate instead of a cloud credential — see container & supply-chain security. And SPIFFE/SPIRE, covered later on this page, generalizes the whole pattern to any workload calling any other workload. Different endpoints, identical shape: trade an attested runtime identity for a short-lived credential, on every use, instead of holding a standing one.
GitHub Actions OIDC to AWS, GCP, and Azure in practice
☺ Like you're 10: Same idea, three different front desks — each cloud wants the ID badge shown to a slightly different window, in a slightly different format.
The workflow-side change is small once the cloud side is configured: swap a credentials-from-secrets step for an action that requests and exchanges an OIDC token. Here's the minimum shape for each provider — all three assume you've already registered the OIDC trust relationship on the cloud side, which is the part that carries the actual security decision and gets its own section next.
# AWS — aws-actions/configure-aws-credentials
permissions:
id-token: write # required to mint the OIDC token
contents: read
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging # ties this job to a GitHub Environment + its rules
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/deploy-staging
aws-region: us-east-1
# no access key, no secret key — nothing in repository secrets# GCP — google-github-actions/auth, Workload Identity Federation
permissions:
id-token: write
contents: read
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/gh-pool/providers/gh-provider
service_account: deploy-staging@my-project.iam.gserviceaccount.com# Azure — azure/login, a federated credential on an App Registration
permissions:
id-token: write
contents: read
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}Notice what's absent from all three: no AWS_SECRET_ACCESS_KEY, no downloaded service-account JSON key file, no client secret. The values that do appear — a role ARN, a workload identity provider path, a client ID — aren't secrets at all; leaking them tells an attacker where the door is, not how to open it, because opening it still requires a valid signed token from GitHub matching the trust policy's conditions.
| Concept | AWS | GCP | Azure |
|---|---|---|---|
| Where you register trust | IAM OIDC identity provider | Workload Identity Pool + Provider | Federated credential on an App Registration / Managed Identity |
| The exchange call | sts:AssumeRoleWithWebIdentity | STS token exchange + service-account impersonation | Azure AD (Entra ID) token endpoint, client-assertion grant |
| What comes back | Temporary AWS credentials, ~1h default | Short-lived Google OAuth access token | Azure AD access token |
| GitHub Action | aws-actions/configure-aws-credentials | google-github-actions/auth | azure/login |
The trust policy's sub claim is the entire security boundary
☺ Like you're 10: The ID badge only matters if the door checks it carefully — a door that lets in "anyone with a badge from this school" isn't much of a lock.
Configuring OIDC and stopping there is a half-measure that feels like progress while leaving the actual door wide open. The role ARN or workload identity provider is public information the moment it appears in a workflow file — what actually decides whether a given token can assume the role is the trust policy's condition block, and specifically its check against the token's sub (subject) claim. GitHub Actions formats that claim as a structured string: repo:ORG/REPO:ref:refs/heads/main for a push to a branch, repo:ORG/REPO:environment:production for a job gated by a GitHub Environment, or repo:ORG/REPO:pull_request for a token minted during a pull-request run. A tight trust policy pins to one of these exactly:
{
"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:acme/checkout:environment:production"
}
}
}]
}The anti-pattern is one keystroke away: swap StringEquals for StringLike and the exact subject for a wildcard, "repo:acme/*" or even just "repo:acme/checkout:*", usually to save the annoyance of writing one condition per branch or environment. The moment that ships, every branch, every environment, and — if you didn't separately exclude it — every pull-request run in that repo can assume the production deploy role. A pull request from a fork doesn't receive repository secrets by default, but it can still trigger a workflow and request an OIDC token if the workflow grants id-token: write unconditionally, so a wildcarded sub condition combined with a permissive workflow trigger is a real path from "anyone can open a PR" to "anyone can assume the production role."
A wildcarded sub condition doesn't fail loudly — the pipeline works fine, every legitimate job still deploys, and nothing looks wrong until someone deliberately checks the trust policy or an attacker deliberately tests it. Pin sub to the narrowest condition the job actually needs — a specific environment, not a whole repo — and always also check aud, since a token minted for one audience being accepted by a different consumer is exactly the kind of substitution bug an audience check exists to catch. For anything reaching production, pair the trust-policy condition with a GitHub Environment that has required reviewers, so the cloud-side boundary and the human-approval boundary have to both agree before the role opens.
The job_workflow_ref claim goes a step further than sub alone, and AWS trust policies can condition on it directly as its own context key: it pins the token not just to a repo and ref but to the exact reusable workflow file and ref that produced the job, which closes a gap sub by itself leaves open — a caller workflow that's been compromised can't simply invoke a different job in the same repo to inherit broader access, because the trust policy is checking which specific workflow definition ran, not just which repo it lives in.
Least-privilege IAM: one role per pipeline job, not one god role for all of them
☺ Like you're 10: A key that opens the supply closet shouldn't also open the safe — give each job the one key it needs, not a master key it never has to use most of.
Federating to OIDC fixes how a credential is obtained; it says nothing about what that credential can do. Teams that migrate off static keys frequently keep the exact same over-broad IAM role and just point OIDC at it — a "deploy" role that can push images, update every service, write to every bucket, and touch every database, because at some point in the project's history some job needed each of those permissions and nobody went back to split them apart. That role is now reachable without a static key, which is real progress, but a single compromised job — a malicious dependency, a poisoned build step, a workflow file modified by a merged pull request that nobody scrutinized closely enough — still inherits the entire union of everything the pipeline has ever been allowed to do.
The fix is mechanical once you decide to do it: one role (or service account, or managed identity) per (job, environment) pair, each with a trust policy scoped to exactly that job as described above, and each with an IAM policy scoped to exactly what that job does and nothing else. A Terraform pipeline is the clean example, because plan and apply genuinely need different access — plan only has to read state and describe existing resources to compute a diff; only apply, run after review and only on merge to the protected branch, needs to actually create, modify, or destroy anything.
| Pipeline job | Dedicated role | Scoped to |
|---|---|---|
terraform plan (every PR) | tf-plan-readonly | Get* / Describe* / List* only — no Create, Update, or Delete of anything |
terraform apply (merge to main) | tf-apply | Write access, assumable only from environment:production with required reviewers |
| Build & push image | ci-build-push | ecr:PutImage on the app's own repos only, nothing else in the account |
| Deploy to staging | deploy-staging | ecs:UpdateService scoped to the staging cluster only |
| Deploy to production | deploy-prod | ecs:UpdateService scoped to the prod cluster, environment:production gate |
Splitting the role also limits the blast radius of a compromise in a way a shared role structurally can't: if the ci-build-push job is compromised through a malicious dependency, the attacker inherits only the ability to push images — not to update a running service, not to touch Terraform state, not to read a database. Compare that to what "just add the permission" produces over eighteen months of a shared role, and the difference isn't marginal. This is the same discipline IaC security & policy as code applies to infrastructure changes generally — least privilege isn't a one-time review, it's a policy the pipeline enforces on itself every time a new job is added. Layer an account-wide permission boundary or a GCP IAM Condition or an Azure resource-group-scoped role assignment on top and you get defense in depth even against a policy that's still, despite your best effort, a little too broad — the point cloud security posture makes about never relying on a single control to hold the whole line.
SPIFFE and SPIRE: the same pattern, generalized to service-to-service auth
☺ Like you're 10: The same "prove who you are, get a badge that expires" idea, but now every service in the building has to show ID to every other service, not just to the front desk.
OIDC federation solves "a CI job calling a cloud API." It doesn't solve "service A calling service B inside your own infrastructure" — that's still commonly done with a shared API key, a static mTLS certificate copied to both sides, or nothing at all. SPIFFE (Secure Production Identity Framework For Everyone) is a set of open, CNCF-graduated specifications for exactly that problem, and SPIRE is its reference implementation. Every workload gets a SPIFFE ID — a URI of the form spiffe://trust-domain/path, e.g. spiffe://acme.internal/ns/payments/sa/checkout — and proves it holds that identity with an SVID (SPIFFE Verifiable Identity Document), either a short-lived X.509 certificate carrying the SPIFFE ID as a URI SAN, or a signed JWT with the SPIFFE ID as its sub.
SPIRE issues these through a two-step attestation chain, and the order matters. A SPIRE Agent runs on every node and first proves the node's own identity to the SPIRE Server — using a cloud instance identity document, a TPM, a Kubernetes projected service account token, or a join token, depending on the platform. Only once the node itself is trusted does the Agent perform workload attestation for anything running on it: matching selectors like Kubernetes namespace, service account, pod labels, or container image digest against registration entries on the Server, then issuing the matching SVID. A workload never touches a credential file to get its identity — it asks over the local Workload API, a Unix domain socket the Agent exposes only on that node, and the Agent streams a fresh SVID back, rotating it automatically well before expiry.
This isn't a competing idea to OIDC federation — it's the same mechanism run one layer down, and the two commonly sit side by side: OIDC federation carries CI identity out to the cloud provider at build and deploy time, and SPIFFE/SPIRE carries workload identity between running services at request time. SPIRE is what backs mTLS in a service mesh when you swap out the mesh's own self-signed certificate authority for something with proper node and workload attestation, and it's why a certificate can be mutually trusted across clusters, or even across clouds, without either side holding a shared secret — Istio, for instance, can source Envoy's SDS certificates from a SPIRE-issued identity instead of its own built-in CA. See cryptography & key management for the certificate-lifecycle mechanics that make an hour-long SVID practical to rotate at scale, and zero trust for pipelines for how this same "never trust a static credential, always attest" posture extends across the whole delivery path, not just the identity step.
# Registering a workload with SPIRE: this entry says "the process matching # these Kubernetes selectors, running under this attested node, IS this SPIFFE ID." spire-server entry create \ -spiffeID spiffe://acme.internal/ns/payments/sa/checkout \ -parentID spiffe://acme.internal/spire/agent/k8s_psat/prod-cluster/\ -selector k8s:ns:payments \ -selector k8s:sa:checkout
Workload attestation is only as trustworthy as the node attestation underneath it. If every node in a cluster shares one static join token, or the token-generation process is loose enough that an attacker can mint their own, a rogue node can register itself with the Server and then pass workload attestation for any selector it chooses to claim — the second check is meaningless if the first one can be forged. Use a strong, per-node attestation plugin (cloud instance identity documents, TPM-backed attestation, or short-lived per-node join tokens) exactly the way you'd insist on a strong root of trust for any other certificate authority in the environment.
Verifying and auditing workload identity once it's live
☺ Like you're 10: Every time someone uses the badge to open a door, write down exactly which badge and which door — so if the wrong badge ever opens the wrong door, you notice.
Removing standing keys doesn't remove the need to watch what the mechanism that replaced them is doing — it changes what "watching" looks like. Every AssumeRoleWithWebIdentity call lands in CloudTrail with the full set of claims presented, including sub and aud; GCP's Cloud Audit Logs and Azure's Activity Log record the equivalent for their own token exchanges. That's a detection signal a static key never gave you: you can alert on an assumed-role event whose sub doesn't match your known set of workflow files, or whose job_workflow_ref points at a reusable workflow nobody expects to be calling that role, and catch a rogue or compromised caller at the moment it tries to use the credential rather than discovering it later from what the credential was used to do. SPIRE emits comparable audit-relevant events — every SVID issuance is tied to the registration entry and the node that requested it, so a workload receiving an identity it shouldn't be entitled to shows up in the Server's own logs, not just in the eventual blast radius. Detection engineering & security observability covers how to turn signals like these into an actual alert instead of a log line nobody reads.
Wire GitHub Actions OIDC to a sandbox AWS account with a role trust-policy pinned to repo:YOUR-ORG/YOUR-REPO:ref:refs/heads/main, run the workflow once, then go read the matching AssumeRoleWithWebIdentity event in CloudTrail — you'll see your exact sub and aud claims sitting right there in the event body. Then edit the trust policy to a StringLike wildcard on a throwaway role, push from a different branch, and watch it succeed where it should have been rejected. Put it back to StringEquals before you close the tab. Seeing the failure mode once is worth more than reading about it five times.
If you want a structured, hands-on version of exactly this — wiring real OIDC trust into a pipeline and then trying to break your own trust policy — the capstone's cross-cloud hardening track picks it up from Secure a Pipeline — start here. And if you're aiming at a certification that weights this specific area, AWS Certified Security – Specialty and the GCP Professional Cloud Security Engineer exam both cover federated identity and least-privilege IAM design in real depth — as always, verify the current exam guide and domain weights on the vendor's own page before you plan study time around them, since both shift between versions. HashiCorp's Vault Associate certification is worth a look too if your stack leans on Vault's own OIDC/JWT auth methods rather than, or alongside, cloud-native federation — see cryptography & key management for where Vault fits next to SPIRE.
Ellie the Elephant: Benny, this workflow still has an AWS access key sitting in the repository secrets. I thought we moved the deploy jobs to OIDC last sprint.
Benny the Beaver: The key already works! One role, every job uses it, nobody has to think about it. Why touch a thing that isn't broken?
Timmy the Turtle: Because "isn't broken" and "isn't dangerous" aren't the same claim. Your build job, your staging deploy, and your production deploy are all wearing the exact same badge. I can't gate what I can't tell apart.
Rocky the Raccoon: Here's a question, then. If I open a pull request from a fork that happens to trigger this workflow — does my run get the same badge Benny's production deploy gets?
Ellie the Elephant: It shouldn't — not if the trust policy's sub condition is pinned to environment:production with required reviewers. If it's wildcarded to the whole repo instead, Rocky just found himself a badge for free.
Professor Owl: So: one job, one role, one narrow condition — and the credential that opens is one nobody had to store anywhere in the first place. That's the whole page in one sentence.
1. Why is a long-lived cloud access key stored in a CI secret a bigger liability than the same permission granted through OIDC federation, even when the underlying IAM policy is identical? 2. Walk through the four steps of GitHub Actions OIDC federation to a cloud provider, from id-token: write to a temporary credential landing in the job. 3. Which claim is the actual security boundary in an OIDC trust policy, and what's the most common way teams weaken it without meaning to? 4. Give a concrete example of "one role per pipeline job" instead of one shared deploy role, and explain exactly what the split limits in a compromise. 5. What are the two attestation steps SPIRE performs before handing a workload an SVID, and why does skipping the first one make the second one meaningless?
Check your answers
- A static key has no native expiry and sits somewhere it can leak — a build log, a compromised third-party action, a malicious dependency script — and stays valid until someone manually revokes it. An OIDC-federated credential is minted fresh per run and expires on its own (typically within about an hour), so even if a run's in-memory credential were somehow captured, it's very likely already useless by the time anyone could act on it. The permission is the same; the exposure window isn't.
- ① The job requests an OIDC token from GitHub's issuer (requires
permissions: id-token: write). ② GitHub mints a short-lived signed JWT with claims likesub,aud,repository, andjob_workflow_ref. ③ The job presents that token to the cloud provider's token-exchange endpoint (e.g. AWS STSAssumeRoleWithWebIdentity), which verifies the token's signature against GitHub's published JWKS and checks its claims against the role's trust policy. ④ If both checks pass, the provider returns a temporary credential that expires automatically, with nothing ever written to a secrets store. - The
sub(subject) claim, checked in the trust policy's condition block, is the real boundary — the role ARN or provider path is public and harmless on its own. Teams weaken it by swapping aStringEqualsexact match for aStringLikewildcard (e.g.repo:acme/*), usually to avoid writing one condition per branch or environment, which silently lets every branch, environment, and sometimes even fork pull requests assume a role that was meant for one specific job. - For example: a read-only
tf-plan-readonlyrole for every pull request'sterraform plan, versus a separate write-capabletf-applyrole assumable only from a production-gated environment on merge. If theplanjob is compromised (a malicious PR, a poisoned dependency), the attacker inherits only read access to describe resources — not the ability to create, modify, or destroy any infrastructure, which the shared-role version would have handed over along with everything else the pipeline could ever do. - Node attestation first — the SPIRE Agent proves the node itself is legitimate to the SPIRE Server (via a cloud instance identity document, TPM, or a Kubernetes projected service account token) — and only then workload attestation, matching selectors like namespace, service account, or image digest against registration entries to identify the specific workload running on that now-trusted node. Skipping node attestation (e.g. a shared static join token any node can use) means an attacker can register a rogue node and then pass workload attestation for whatever selectors they choose to claim, because the check that was supposed to establish "this is a real node I provisioned" never actually ran.