CoursesAWS security engineeringFederation & killing static keys

Federation & killing static keys

Identity Center for humans, OIDC for CI, roles on instances.

Advanced35 min · lesson 3 of 15

The single highest-payoff change you can make to an AWS account is deleting its long-lived access keys. Most teams never get around to it. An access key is just two strings, an ID and a secret, and AWS treats whoever presents them as the account owner. It never expires, and there's no second factor to get past. Nobody's even tracking who copied it along the way. It's the digital version of a house key that never changes the lock: hand it out once, and every copy anyone ever made still opens the door tonight.

Here's the alternative: a day pass at an office building. The front desk checks your ID, prints a badge good for eight hours, and unlocks only the two floors you're there to visit. By tonight it's landfill. That's a short-lived credential. AWS mints one on demand, scopes it to exactly what the caller should touch, and lets it expire on its own. Federation is the machinery that swaps the permanent key for the day pass, and which shape it takes depends on who's asking: a person, a pipeline, or a workload that's already running.

Why a static key is the thing attackers hope for

Keys leak. Not occasionally, constantly. They turn up in public GitHub repos, baked into Docker image layers, pasted into Slack threads, printed in a debug log someone shipped to a vendor. AWS runs its own scanner against public GitHub and auto-quarantines keys it finds, which tells you how routine this is. The trouble with a leaked static key is the clock: there isn't one. It keeps working, silently, until a human notices and revokes it. Attribution is miserable too, because a dozen jobs share the same key, so when something goes wrong the audit log can't tell you which caller was the breach.

Rotation is supposed to save you, and it mostly doesn't, because rotating a key by hand is the kind of chore teams defer until the quarter it causes an outage. The clean fix is to have no standing secret to rotate at all. That's what the rest of this lesson builds, one caller type at a time.

People: Identity Center hands out the badges

Your company already has a place where employees log in every morning, with a password and a phone prompt: Okta, Microsoft Entra, Google Workspace. That system is your identity provider, the bouncer who already knows everyone on the guest list. IAM Identity Center (AWS renamed it from AWS SSO) wires that bouncer up to AWS. An engineer signs in with their normal corporate login, picks an account and a permission set, and Identity Center hands back a temporary role session. There's no IAM user and no key anywhere in that flow.

confirm a human is on a role session, not an IAM user
$ aws sso login --sso-session acme
Attempting to automatically open the SSO authorization page in your default browser.
Successfully logged into Start URL: https://acme.awsapps.com/start
$ aws sts get-caller-identity
{
"UserId": "AROA3XICF7EXAMPLE:sachin.chaurasiya",
"Account": "111111111111",
"Arn": "arn:aws:sts::111111111111:assumed-role/AWSReservedSSO_PowerUserAccess_9f1c2b3d4e5f6a7b/sachin.chaurasiya"
}

Look at the Arn in that output: assumed-role/AWSReservedSSO_..., not user/.... That one line is the point. This person holds a session that expires, not a permanent identity with a key to hunt down later. Revocation is one move, too. Disable the account in Okta, and every AWS door that person could open closes at the next token refresh, across every account you own. No spreadsheet of IAM users to comb through.

Pipelines: a signed token for a temporary key

A CI/CD pipeline (the automated system that builds your code and ships it to production) can't type a password or tap a phone. So it proves who it is with a signed note. GitHub Actions (and GitLab, and most CI platforms) can issue each job a short-lived OIDC token. OIDC, OpenID Connect, is a standard format for a signed statement about an identity. The token says, in effect, this job is running for repository acme/api on branch main, and GitHub signs it so it can't be forged. You configure AWS to trust GitHub's signature. The job hands that token to STS, the Security Token Service, the part of AWS whose only job is minting temporary credentials, and gets a day pass back. No secret was ever stored in GitHub.

trust-policy.json + register the role
$ cat trust-policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::222222222222:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:acme/api:ref:refs/heads/main"
}
}
}]
}
$ aws iam create-role --role-name gha-deploy \
--assume-role-policy-document file://trust-policy.json
{
"Role": {
"Path": "/",
"RoleName": "gha-deploy",
"RoleId": "AROAY6R2EXAMPLE7QK3F",
"Arn": "arn:aws:iam::222222222222:role/gha-deploy",
"CreateDate": "2026-07-16T09:12:44+00:00"
}
}

Two conditions carry all the weight. aud, the audience, is who the token is meant for; you assert it equals sts.amazonaws.com so a token minted for some other service can't be replayed against AWS. sub, the subject, is who the token is about, and pinning it to repo:acme/api:ref:refs/heads/main means only jobs on that repo's main branch match. Everything hinges on getting sub right, which is where people cut themselves.

assume-role-with-web-identity (what the CI action runs under the hood)
$ aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::222222222222:role/gha-deploy \
--role-session-name gha-build-4711 \
--web-identity-token "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJyZXBv..."
{
"Credentials": {
"AccessKeyId": "ASIAY6R2EXAMPLE5NKQW",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"SessionToken": "IQoJb3JpZ2luX2VjEJr...<900+ chars>...==",
"Expiration": "2026-07-16T10:12:58+00:00"
},
"SubjectFromWebIdentityToken": "repo:acme/api:ref:refs/heads/main",
"AssumedRoleUser": {
"AssumedRoleId": "AROAY6R2EXAMPLE7QK3F:gha-build-4711",
"Arn": "arn:aws:sts::222222222222:assumed-role/gha-deploy/gha-build-4711"
},
"Provider": "arn:aws:iam::222222222222:oidc-provider/token.actions.githubusercontent.com",
"Audience": "sts.amazonaws.com"
}

That's the whole trick, laid bare. In real life the configure-aws-credentials GitHub action calls this for you, but the exchange is identical. You get three strings back, not two: an access key ID, a secret, and a SessionToken, and all three are needed together. Notice the access key starts with ASIA, the prefix AWS uses for temporary credentials; a permanent IAM user key starts with AKIA. Notice Expiration, one hour out. A build that spills this into its logs spills something already dead by the time anyone reads it, and there was never a standing key to steal in the first place.

A wildcard sub is worse than the key you just deleted
Federation's whole security lives in that sub condition. Write repo:acme/api:* and you've told AWS to trust every branch and every pull request in the repo, including a pull request opened from a stranger's fork. That attacker's PR runs a workflow, gets your deploy role, and you handed it over with a wildcard. Pin sub to a specific ref like refs/heads/main or to a named GitHub environment, assert aud every single time, and review these conditions as carefully as you'd review a change to production code.

Workloads: often no key at all

Code running on AWS shouldn't carry a key either, because it doesn't need to ask a person for one. The building already knows it's inside. An EC2 virtual machine, an ECS container, an EKS pod, each can be handed an IAM role, and credentials show up automatically through a local metadata service or a mounted token, refreshed for you every few hours. The one rule that matters here: force IMDSv2 on your instances. The older metadata service, v1, could be talked into coughing up the instance's role credentials by a server-side request forgery bug, where an attacker tricks your web app into making a request on their behalf. v2 demands a session token first, which shuts that path.

enforce IMDSv2, then verify it took
$ aws ec2 modify-instance-metadata-options \
--instance-id i-0abc123def4567890 \
--http-tokens required --http-endpoint enabled
{
"InstanceId": "i-0abc123def4567890",
"InstanceMetadataOptions": {
"State": "pending",
"HttpTokens": "required",
"HttpPutResponseHopLimit": 1,
"HttpEndpoint": "enabled"
}
}
$ aws ec2 describe-instances --instance-ids i-0abc123def4567890 \
--query "Reservations[].Instances[].MetadataOptions.HttpTokens"
[
"required"
]

What about a server in your own datacenter, or a workload on another cloud, something that isn't inside the AWS building at all? IAM Roles Anywhere covers that case. Instead of a key, the workload authenticates with an X.509 certificate, a cryptographic ID card signed by your certificate authority (the internal service your org already uses to issue such certificates). You register that authority with AWS as a trust anchor, meaning AWS agrees to trust anything it vouches for, the workload proves it holds the matching private key, and STS hands back the same kind of temporary credentials everything else gets.

IAM Roles Anywhere credential helper on a non-AWS host
$ aws_signing_helper credential-process \
--certificate /etc/pki/workload.crt \
--private-key /etc/pki/workload.key \
--trust-anchor-arn arn:aws:rolesanywhere:eu-west-1:333333333333:trust-anchor/8b1e4c2a \
--profile-arn arn:aws:rolesanywhere:eu-west-1:333333333333:profile/2c9d77f0 \
--role-arn arn:aws:iam::333333333333:role/onprem-batch
{
"Version": 1,
"AccessKeyId": "ASIAZ7QF4EXAMPLE3RPLC",
"SecretAccessKey": "9drTa1...EXAMPLE...Kf0Z",
"SessionToken": "IQoJb3JpZ2luX2VjE...==",
"Expiration": "2026-07-16T11:47:20Z"
}
Diagram
Who is asking AWS for access?
Pick the federation path by caller type; none of them stores a key
A person
IAM Identity Center
Corporate IdP (Okta/Entra) to permission set to expiring role session
A CI/CD job
OIDC federation
Signed job token to AssumeRoleWithWebIdentity, sub pinned to repo + branch
AWS compute
Instance / task / IRSA role
Creds delivered via IMDSv2 or a projected token, auto-rotated
Off-AWS workload
IAM Roles Anywhere
X.509 cert to trust anchor to STS temporary creds
Quick check
01Your GitHub Actions role trusts a token where sub is set with StringLike to repo:acme/api:*, and it correctly asserts aud equals sts.amazonaws.com. What's the real exposure?
Incorrect — aud only confirms the token was minted for AWS STS; it says nothing about which repo or branch produced it. The hole is the sub wildcard.
Correct — repo:acme/api:* matches every ref and every PR context, so an outsider who opens a pull request gets a workflow that can assume your deploy role. Pin sub to refs/heads/main or a named environment.
Incorrect — The condition isn't time-based; the wildcard matches any ref, past or future.
Incorrect — The Federated principal pins the provider to your account (222222222222). The actual weakness is the branch and PR wildcard, not cross-account trust.
02Why does forcing IMDSv2 on an EC2 instance, by setting the instance metadata service to http-tokens required, protect the instance's role credentials?
Incorrect — IMDSv2 governs how the metadata endpoint is reached, not disk encryption; role credentials are delivered through the metadata service, not stored on disk.
Correct — IMDSv1 could be tricked by an SSRF bug into returning the role's credentials; IMDSv2 demands a session token first, which a forged server-side request cannot obtain, closing that path.
Incorrect — the credential refresh cadence is unchanged; IMDSv2's protection is the required session token, not faster rotation.
Incorrect — the credentials still arrive through the instance metadata service; IMDSv2 hardens access to that service rather than relocating the secret.
03A build log is accidentally published to a public site. It contains an access key ID beginning ASIA, a secret, and a long SessionToken, all produced by the CI job's OIDC federation. How worried should you be about this specific leak?
Incorrect — ASIA is the prefix for temporary credentials; permanent IAM user keys start with AKIA, and those are the ones that keep working until revoked.
Incorrect — the SessionToken is only valid alongside the temporary access key and secret and expires with them; it cannot independently mint new permanent keys.
Incorrect — all three parts were exposed, not just the ID, and there is no blanket MFA gate; the reason this leak is low-risk is the short expiry, not MFA.
Correct — the ASIA prefix marks a temporary credential with a one-hour expiry, so by the time anyone reads the log it is already dead and there was never a standing key to steal.

Once you've done all this, the handful of static keys still lying around get much easier to police, because they should now be doing almost nothing. A long-term key that suddenly starts calling from a hosting provider in another country, or listing every role in the account, stops being background noise and turns into an obvious alarm. That's the signal the next lesson leans on. GuardDuty ships findings like UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS, raised when instance credentials show up at an IP address outside AWS, and Discovery:IAMUser/AnomalousBehavior, raised when a caller starts enumerating resources it never normally touches. Both exist to catch a credential being used somewhere it never should be.

Try this

Work through “Workloads: often no key at all” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a wildcard sub is worse than the key you just deleted. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related