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.

How Wide Is the Door You Just Opened? An organisation-wide subject match lets any workflow in any repository assume the role, including repositories created later. A repository-wide match with any branch lets any branch, including one pushed by a contributor, assume it. An environment-scoped match limits the role to jobs running in a protected environment with required reviewers, which is the intended scope. sub: repo:example-org/* — organisation wildcard any workflow in any repository, including ones created after you wrote the policy One compromised repository becomes cloud access for the whole organisation. sub: repo:example-org/web:ref:refs/heads/* — any branch one repository, but any branch — including a branch pushed by anyone with write access Branch protection does not apply to branch creation, so this is wider than it looks. sub: repo:example-org/web:environment:production — one environment only jobs running in the protected environment, which carries required reviewers and its own audit trail

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.

What the Credential Can Reach If It Leaks A narrowly scoped deployment role permits object writes to one bucket prefix and one cache invalidation, so a leaked credential can deface a static site for the minutes before it expires. A broad administrative role permits everything in the account, so the same leak is an account compromise regardless of how short-lived the credential was. Scoped to what the script calls object writes to one bucket prefix one cache invalidation, one distribution nothing else in the account If it leaks: a static site could be defaced for the few minutes before it expires. Granted once during an outage an administrative policy, never narrowed every service, every resource, every region including identity and logging If it leaks: an account compromise, and a short lifetime is ample time to persist.

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.

Migrate in an Order That Is Reversible Until the End Five stages. Registering the provider and creating the scoped role changes nothing yet. Switching the workflow to federation is the first behavioural change and is easily reverted. Deactivating the static key is reversible in seconds. Only deletion is permanent, and it happens after a quiet observation period, followed by a CI gate that prevents reintroduction. 1 · Register the provider and create the scoped role — no behaviour changes yet 2 · Switch the workflow to federation — revert by restoring one commit if it fails 3 · Deactivate the static key and watch for a week — reversible in seconds 4 · Delete the key and the stored secrets, then add the gate the gate matters most: the key comes back during the next incident unless something refuses it

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.