OIDC Federation Instead of Long-Lived Cloud Keys
A static cloud access key stored as a CI secret is a credential with no expiry, no binding to a particular job, and a strong tendency to escape: into a log line that printed the environment, into a fork that inherited a workflow, onto a developer laptop for local testing. It stays valid until someone notices, and nobody notices quickly.
Federation replaces it with an identity the platform mints per job. The cloud provider verifies a signed token, checks that its claims match a policy you wrote, and issues credentials that expire in minutes and are scoped to one repository and branch. This guide implements that end to end and proves the static keys are actually gone. It is part of the Build Pipeline & CI Runner Hardening guide within Supply Chain & Dependency Security.
Prerequisites
- Administrative access to the cloud account to register an identity provider and create roles
- Workflows in version control with branch protection on deployment branches
- An inventory of every static key currently stored as a CI secret and what each is used for
- A cloud audit log you can query, to confirm which keys are still in use before deleting them
Expected Outcomes
- No long-lived cloud access keys stored in the CI platform
- A trust policy that matches one repository, one branch or environment, and the expected audience
- Roles carrying only the permissions their job needs
- A CI gate that fails when a static key pattern reappears in a workflow
Step 1: Register the Provider and Constrain the Trust Policy
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012: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:example-org/web:environment:production"
}
}
}]
}
The sub claim is the whole control. repo:example-org/web:environment:production grants the role only to jobs in that repository running in that environment — not to a different branch, not to a fork, and not to another repository in the organisation. The two patterns to avoid are a wildcard organisation match, which trusts every repository including a new one an attacker convinces someone to create, and a ref:refs/heads/* match, which trusts any branch including one pushed by a contributor with write access.
Step 2: Use the Identity in the Workflow
name: deploy
on:
push: { branches: [main] }
permissions:
id-token: write # required to mint the identity token
contents: read # everything else stays at the default of none
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production # the claim the trust policy matches
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy-web
role-session-name: deploy-${{ github.run_id }} # traceable in the audit log
role-duration-seconds: 900 # 15 minutes, not the default hour
aws-region: eu-west-1
- run: ./scripts/deploy.sh
Two settings repay the effort. A session name derived from the run identifier makes every cloud audit entry traceable back to the exact workflow run. And a duration matched to the job means a credential captured mid-build expires long before anyone could reuse it out of hours.
Step 3: Grant Only What the Job Needs
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublishAssets",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::example-web-assets/*"
},
{
"Sid": "InvalidateCache",
"Effect": "Allow",
"Action": ["cloudfront:CreateInvalidation"],
"Resource": "arn:aws:cloudfront::123456789012:distribution/E1EXAMPLE"
}
]
}
Deployment roles accumulate permissions in the direction of convenience, so the discipline is to start from what the deploy script actually calls and add nothing speculative. If a step needs a new permission, that is a pull request to the policy — visible, reviewable, and dated.
Step 4: Delete the Static Keys and Prove They Are Gone
# 1. Which keys are still being used, and by whom?
aws iam list-access-keys --user-name ci-deploy
aws iam get-access-key-last-used --access-key-id AKIA… # confirms nothing depends on it
# 2. Deactivate first — reversible — and watch for a week.
aws iam update-access-key --user-name ci-deploy --access-key-id AKIA… --status Inactive
# 3. Delete the key and the CI secret once nothing has broken.
aws iam delete-access-key --user-name ci-deploy --access-key-id AKIA…
gh secret delete AWS_ACCESS_KEY_ID --repo example-org/web
gh secret delete AWS_SECRET_ACCESS_KEY --repo example-org/web
# 4. Gate the regression, because someone will paste a key back during an incident.
- name: No static cloud credentials in workflows
run: |
if grep -REn 'AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|GOOGLE_APPLICATION_CREDENTIALS|AZURE_CLIENT_SECRET' \
.github/workflows/; then
echo "::error::Use federated identity, not static credentials."
exit 1
fi
Deactivating before deleting is what makes this safe to do on a Friday: if something breaks, reactivating takes seconds, whereas recreating a deleted key means new secrets everywhere.
Verification
# 1. The workflow assumes the role and receives temporary credentials.
# In the job log, confirm the identity is a session ARN, not a user ARN.
aws sts get-caller-identity
# Expected: arn:aws:sts::123456789012:assumed-role/deploy-web/deploy-1234567890
# 2. The role cannot be assumed from another repository.
# Run the same step from a scratch repository and confirm it is refused.
# Expected: AccessDenied — the sub claim does not match.
# 3. No static keys remain anywhere.
gh secret list --repo example-org/web | grep -iE 'aws|gcp|azure' || echo "no cloud secrets stored"
aws iam list-access-keys --user-name ci-deploy --query 'AccessKeyMetadata[?Status==`Active`]'
# Expected: empty.
# 4. Credentials expire quickly.
# Capture the expiry from the assume-role response and confirm it is minutes, not hours.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Assume-role fails with an audience error | Audience claim mismatch between platform and policy | Set the audience explicitly in both places and compare the exact strings |
| Works on the default branch, fails elsewhere | Trust policy matches a specific ref | Decide deliberately: either add the environment claim or keep it branch-scoped |
| Identity token cannot be minted | Missing identity-token permission in the workflow | Add the permission to the job, not merely at the workflow level |
| Credentials expire mid-deployment | Session duration shorter than the job | Raise the duration to just above the job’s worst case, and no further |
| Cloud audit shows unattributable sessions | Generic session name | Derive the session name from the run identifier |
| A fork pull request obtains credentials | Identity-token permission granted on an untrusted trigger | Remove it there; move privileged steps behind an approved environment |
Common Implementation Mistakes
Frequently Asked Questions
What exactly does the trust policy have to constrain?
Four things: the issuer, the audience, the subject, and any platform-specific claims naming the repository, branch or environment, and workflow. A policy that matches only the issuer trusts every workflow on that platform, including one in a repository an attacker creates. Match the subject with an exact string wherever you can, and if you use a wildcard, write down in the policy description why it is necessary and what compensates for it.
How long do the federated credentials last?
Usually an hour by default, which you should shorten to the length of the job. The identity token itself is valid only for minutes, and the credentials it exchanges for expire on their own. That is precisely the benefit: a credential captured from a log line or a compromised step is worthless shortly afterwards, whereas a static key remains valid until a human notices and rotates it — historically, long after the incident.
Can pull requests from forks use federation?
They should not. Even a scoped federation issues a real credential, and a fork pull request runs code nobody has reviewed. Keep the identity-token permission off untrusted triggers entirely and run those builds with no cloud access at all. Where a preview deployment is genuinely needed, put it behind an environment with required reviewers, so a human approves the code before any credential is minted.
Related
- Build Pipeline & CI Runner Hardening — the parent guide covering the wider pipeline threat model
- Pinning GitHub Actions to a Commit SHA — stopping the third-party step that would look for these credentials
- Verifying Container Image Signatures With Cosign — the deployment-side integrity check
- Defining Trust Boundaries — where the pipeline sits in the wider zone model