Getting long-lived cloud keys out of your CI
The static access key in your pipeline is the credential most likely to end up in a breach report. OIDC federation removes it in an afternoon, and the trust policy is where people get it wrong.
Look in the secrets settings of any CI system that has been running for three years and you will find an access key created by someone who has left, with permissions nobody has reviewed, that has never been rotated. It is readable by every job in the repository, it works from anywhere on the internet, and it does not expire.
Every major CI system can now exchange a short-lived OIDC token for cloud credentials instead. The work is a couple of hours and the result removes an entire class of incident.
How the exchange works
The CI system signs a JWT describing the job: which repository, which branch or environment, which workflow. The cloud provider is configured to trust that issuer, checks the claims against a policy you write, and hands back credentials valid for an hour or less.
Nothing is stored. There is no key to leak, rotate or revoke, and the credentials are bound to the specific job that requested them.
AWS — register the provider once, then a role whose trust policy names the exact subject:
{
"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:acme/orders:environment:production"
}
}
}
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-orders-deploy
aws-region: eu-west-1
Azure uses federated credentials on an app registration with the same subject claim. GCP uses Workload Identity Federation with an attribute condition and a service account. Vault has a JWT auth method that maps bound claims to a policy. GitLab CI, Jenkins (via the OIDC provider plugin) and Argo Workflows all emit compatible tokens.
The trust policy is the whole security control
This is where most implementations are weaker than the keys they replaced.
Use StringEquals, not StringLike, wherever you can. A subject of repo:acme/* with StringLike means every repository in the organisation can assume the role. Someone creates a new repo, pushes a workflow, and has your production credentials.
Scope to an environment, not a branch, for anything that touches production. repo:acme/orders:ref:refs/heads/main looks tight but anyone who can push a branch named main in a fork-and-PR flow, or who can rename a branch, gets in. repo:acme/orders:environment:production combined with an environment that has required reviewers means the credential is only issuable after a human approves.
Always pin the audience. Omitting the aud check allows a token minted for a different relying party to be replayed.
One role per repository per environment. A shared ci-deploy role that everything assumes is the same blast radius as the shared key, just with better expiry. The whole point is that the credential describes who is using it.
What to do about the secrets that are left
OIDC covers cloud APIs. It does not cover the npm token, the Docker Hub password, the third-party API key or the signing key. For those:
- Keep them in a real secret manager and pull them at job time with the cloud credentials you just federated, rather than pasting them into CI variables. One place to rotate, one audit log.
- Scope them per environment and mark them protected, so a job on an arbitrary branch cannot read the production ones.
- Rotate on a schedule you can actually verify. A rotation policy nobody checks is a document, not a control.
- Run secret scanning on the repository and on CI logs. Masking in logs is best-effort, and anything that goes through
base64or a JSON blob will slip past it.
Cleaning up the old keys
Do not just add federation and leave the keys in place — you have added a mechanism, not removed a risk. The sequence:
- Add the OIDC role with the same permissions as the key.
- Switch the pipeline, confirm it works.
- Check the key's last-used date in IAM. Wait until it stops moving.
- Deactivate the key (do not delete yet). Wait a week for the thing you forgot.
- Delete it.
Then set an org-level guardrail so the next one cannot appear: an SCP denying iam:CreateAccessKey outside a break-glass path, Azure Policy blocking client secrets on app registrations, or the GCP org policy disabling service account key creation. Without that, you will do this project again in two years.
The permissions of the federated role deserve the same scrutiny as any other — grant the deploy role exactly what the deploy needs, and check with Access Analyzer what it actually used after a month.