Role assumption and federation
Long-lived access keys are the credential that leaks. Assume-role mints short-lived STS credentials for a scoped task; cross-account uses a trust policy with an external id; CI and pods federate via OIDC, so there are no keys to steal at all.
A GitHub Action runs terraform apply against production. To do it, someone pasted an AWS_SECRET_ACCESS_KEY into the repo’s CI secrets two years ago — a long-lived key bound to a role with broad permissions. The key never expires, nobody remembers it exists, and it sits in plaintext in the runner’s environment on every build. One leaked workflow log, one malicious dependency in the build, one fork with pull_request_target, and that key walks out the door — still valid, still privileged, usable from anywhere on the planet until a human notices and rotates it. The fix isn’t a better secret store. It’s to stop having a standing key at all: let the runner prove who it is and receive credentials that are already dead in fifteen minutes.
By the end of this lesson you’ll know how AssumeRole mints short-lived credentials, how a cross-account trust policy and external id let one account safely act in another, and how OIDC federation lets CI and Kubernetes workloads get cloud access with no stored key at all.
The problem: standing keys are the thing that leaks
A long-lived access key (an AKIA... pair, a service-account JSON file) has three properties that make it the single most exfiltrated credential in cloud: it does not expire, it is bearer-only (whoever holds the bytes is the identity — no proof of possession), and it is usable from any network on earth. So a key that leaks at 3am in a log line is still a fully valid identity at noon the next day. The entire design goal of role assumption is to replace that standing key with a credential that is scoped to a task, short by default, and tied to an identity that has to be re-proven to get more.
The mechanism is temporary security credentials. Instead of holding a key for the permissions you need, you hold the right to ask for them. You call the Security Token Service (sts:AssumeRole), name a role you’re allowed to assume, and STS hands back a triple — an access key id, a secret, and a session token — that expires on a clock you set (15 minutes to 12 hours; one hour is a common default). When it expires, it is inert: a leaked session token recovered after its TTL is worthless. You’ve turned a permanent liability into a perishable one.
How assume-role actually works
Two policies have to agree before STS issues anything, and conflating them is the most common IAM mistake on this topic:
- The trust policy (the
AssumeRolePolicyDocument, attached to the role) answers “who is allowed to become this role?” ItsPrincipalnames the identities — an account, a user, another role, or a federated provider — permitted to callAssumeRoleon it. - The permissions policy (attached to the role) answers “once you are this role, what can you do?” — the actual
s3:GetObject,dynamodb:Query, etc.
A request to assume a role is denied unless the caller is both listed in the role’s trust policy and has sts:AssumeRole permission on their own side. That two-sided handshake is the whole security model: the role owner decides who may enter, and the caller’s own account decides whether they may try. When STS approves, it returns short-lived credentials carrying the role’s permissions — not the caller’s. You can shrink them further at assume-time with a session policy (an inline policy passed in the AssumeRole call that can only subtract permissions), which is how you hand a sub-task even less than the role grants.
Cross-account: the trust policy and the confused-deputy fix
Cross-account assume-role is how a CI account, an audit-tooling account, or a third-party SaaS gets scoped access into your account without you ever creating a user for them. You create a role in your account whose trust policy names their account as principal, attach a tight permissions policy, and hand them the role ARN. They assume it from their side and operate inside your account as that role — never as a long-lived key you minted and shipped to them.
But cross-account trust has a classic trap: the confused deputy. Suppose a SaaS vendor assumes a role in every customer’s account using the same vendor account as principal. If the vendor is tricked into using your role ARN while acting for a different customer — or an attacker who knows your role ARN can reach the vendor — your account is accessed under the vendor’s legitimately-trusted identity. The fix is the external id: a secret string you choose, recorded in your trust policy’s condition (sts:ExternalId), that the vendor must pass on every AssumeRole. Because it’s per-customer and not guessable, an attacker who knows only the role ARN can’t make the deputy use it against you. For your own workloads, the modern equivalent is OIDC subject conditions (below) — the external id pattern is specifically for third parties you can’t federate.
▸Why this works
Why one hour and not one minute? Shorter is safer per-credential, but every expiry forces a re-assume round-trip, and STS has account-level request rate limits. A 15-minute TTL on a chatty service means constant re-auth and a real chance of throttling under load; a 12-hour TTL on an interactive admin session is a long window for a stolen token. The senior reflex is to match TTL to blast radius: minutes for high-privilege or cross-account roles, an hour for ordinary service work, and let the SDK’s credential provider auto-refresh so the rotation is invisible to your code. The point of short-lived creds isn’t to maximize friction — it’s to make a leaked token expire before it’s useful.
Federation: CI and workloads with zero stored keys
The endgame removes the bootstrap key entirely. Federation lets an identity from outside the cloud’s own IAM — a GitHub Actions run, a Kubernetes pod, a Google or Okta login — exchange a token it already holds for short-lived cloud credentials, with no key ever stored.
The dominant pattern for machines is OIDC. GitHub Actions, GitLab CI, and Kubernetes all act as OIDC identity providers: at runtime they mint a signed, short-lived JWT that asserts who is running — the repo, the branch, the workflow, the service account. You register that provider once in your cloud account and create a role whose trust policy says “trust tokens from token.actions.githubusercontent.com, but only when the sub claim is repo:acme/api:ref:refs/heads/main.” The CI job calls AssumeRoleWithWebIdentity, presents its JWT, STS validates the signature against the provider’s published keys and checks the claim conditions, and returns short-lived credentials. No secret was ever stored in CI. A leaked workflow log contains, at most, an already-expiring token bound to one repo and branch.
The same idea runs inside Kubernetes as workload identity (EKS IRSA, GKE Workload Identity): a pod’s service account is bound to a cloud role, the kubelet projects a signed service-account JWT into the pod, and the SDK exchanges it for credentials scoped to that workload — so two pods on the same node get different, minimal permissions instead of sharing the node’s role. The trust-policy condition is doing the heavy lifting in every case: federation is only as safe as how tightly you pin the sub/aud claims. A trust policy that trusts the whole GitHub org instead of one repo, or omits the aud, is a federated wildcard — the same blast-radius mistake as *:*, just wearing an OIDC costume.
| Access pattern | Credential held | Lifetime | Trust gate | Blast radius on leak |
|---|---|---|---|---|
| Long-lived access key | Stored secret (AKIA…) | Until rotated by a human | Bearer — none | Full, indefinite, from anywhere |
| Assume-role (same account) | STS session creds | 15 min – 12 h TTL | Caller perm + trust policy | Role scope, until TTL only |
| Cross-account assume-role | STS session creds | 15 min – 12 h TTL | Trust policy + external id | Role scope in your acct, until TTL |
| OIDC federation (CI / pod) | None stored — JWT minted at runtime | Minutes (token + STS TTL) | Provider sig + sub/aud claim pin | One repo/branch/pod, already expiring |
How a senior reasons about it
The order of preference is a ladder, and you climb as far as the platform allows. For your own workloads inside a cloud, attach a role to the compute (instance profile, IRSA, workload identity) so credentials are delivered and rotated automatically and no key exists. For CI and external-but-federatable systems, use OIDC with tightly pinned claim conditions. For third parties you can’t federate, use cross-account assume-role with an external id. A static access key is the bottom rung — justified only when nothing above it is available, and then it must be short-TTL, narrowly scoped, alarmed on use, and rotated on a schedule. Every step up the ladder removes a stored secret, shortens a lifetime, or tightens a trust condition — and that is the whole game: shrink what a leak gets and how long it lasts.
Your GitHub Actions pipeline needs to deploy to a production cloud account. Today it uses a long-lived access key stored in CI secrets. Pick the best replacement.
A role in account B has a permissions policy granting s3:* but its trust policy lists no principals. A user in account A with sts:AssumeRole tries to assume it. What happens?
Why does OIDC federation for CI eliminate the leaked-key risk that a stored long-lived key has?
Order the steps of an OIDC-federated CI deploy, from start to issued credentials:
- 1 Register the CI provider once and create a role whose trust policy pins the sub/aud claims
- 2 At runtime the CI job mints a signed, short-lived OIDC JWT asserting repo + branch
- 3 The job calls AssumeRoleWithWebIdentity, presenting the JWT
- 4 STS validates the signature against the provider's keys and checks the claim conditions
- 5 STS returns short-lived credentials scoped to the role; the build deploys
- 01Explain how AssumeRole works, the two policies involved, and why the credentials it returns are safer than a long-lived access key.
- 02What is the confused-deputy problem in cross-account assume-role, and how do the external id and OIDC claim conditions defend against it?
Long-lived access keys are the most-exfiltrated cloud credential because they never expire, are bearer-only, and work from anywhere — a leak at 3am is still valid at noon. Role assumption replaces them with temporary STS credentials scoped to a task and expiring on a TTL you set (15 minutes to 12 hours). AssumeRole is a two-sided handshake: the caller needs sts:AssumeRole on their side and must be named in the role’s trust policy, which is separate from the permissions policy that decides what the role can do once assumed. Cross-account access works by trusting another account in the role’s trust policy, and the external id condition closes the confused-deputy hole for third parties who can’t be federated. The endgame is federation: CI systems and Kubernetes workloads act as OIDC providers, minting short-lived signed JWTs at runtime that STS exchanges for credentials via AssumeRoleWithWebIdentity — so no secret is ever stored, and a leaked log holds at most an already-expiring token pinned to one repo, branch, or pod. The safety of every federated setup lives in how tightly the trust policy pins the sub and aud claims: trusting a whole org instead of one repo is a federated wildcard, the same blast-radius mistake as a : policy. The reflex is a ladder — attached role first, then OIDC, then cross-account with external id, and a static key only as the justified last resort with a short TTL — each rung removing a stored secret, shortening a lifetime, or tightening a trust condition.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.