Lesson 04 of 8 · Modules
OIDC federation & secret hygiene
Remove long-lived cloud keys from CI: how GitHub's OIDC tokens work, trusting them in AWS, GCP, Azure and Vault with tight subject conditions, and the secret hygiene that remains necessary: scoping, masking, scanning and rotation.
The problem with static keys
A classic pipeline stores AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY as CI secrets. Those keys don't expire, work from anywhere, get copied between repos, and leak through logs, forks and compromised actions.
How OIDC federation works
job (permissions: id-token: write)
└─► GitHub OIDC provider issues a signed JWT:
iss: https://token.actions.githubusercontent.com
sub: repo:acme/infra:environment:prod
aud: sts.amazonaws.com
└─► cloud STS verifies signature + trust conditions
└─► temporary credentials (valid for minutes to an hour)
Instead of giving the delivery robot a permanent house key (which could be copied or lost), the robot shows a fresh, signed permission slip from its school every time it arrives: "This is robot #7 from class Infra, delivering for the Prod team, valid for 10 minutes." The house checks the school's signature and the details, then opens the door just for that visit.
AWS
Create an IAM OIDC provider for token.actions.githubusercontent.com, then a role whose trust policy checks aud and sub (a full example is in Amazon EKS in Production with Terraform, lesson 10):
jobs:
deploy:
runs-on: ubuntu-latest
environment: prod
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-deploy-prod
aws-region: eu-west-1
- run: aws sts get-caller-identity
GCP, Azure and Vault
# GCP: Workload Identity Federation
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github
service_account: deployer@my-project.iam.gserviceaccount.com
# Azure: app registration with a federated credential
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
# Vault: JWT auth method with a role bound to repo/environment claims
- uses: hashicorp/vault-action@v3
with:
url: https://vault.example.com
method: jwt
role: gha-prod
secrets: kv/data/shop/prod db_password | DB_PASSWORD
The IDs above aren't secrets, so store them as configuration variables, not secrets. The trust configuration lives on the cloud side.
Writing tight trust conditions
| Pattern | Use for |
|---|---|
repo:acme/infra:environment:prod |
Production deploys (the environment requires approval) |
repo:acme/infra:ref:refs/heads/main |
Main-branch jobs without environments |
repo:acme/infra:pull_request |
PR plans with read-only roles |
repo:acme/* |
❌ Too broad for anything that writes |
Check the exact sub format for your setup (organisations can customise the claim template), and always check aud too.
Secret hygiene for what remains
- Prefer environment secrets (released only to jobs in that environment, after approvals) over repository-wide secrets.
- GitHub masks registered secrets in logs, but derived values (base64-encoded, substrings) aren't masked. Never print secrets, and use
::add-mask::for values you generate. - Turn on secret scanning with push protection, and run a scanner (e.g. gitleaks) in CI.
- Rotate remaining tokens on a schedule, and immediately after anyone with access leaves or a leak is suspected.
- Give each integration its own token with minimal scope, so revoking one doesn't break everything.
Try it: remove a static key
- In a sandbox AWS account, create the GitHub OIDC provider and a role that trusts only
repo:<you>/<repo>:ref:refs/heads/mainwith read-only permissions. - Add the workflow above (without
environment) and runaws sts get-caller-identity. - Run the same workflow from another branch and read the access-denied error. Explain why it failed.
- Add an
environment: prodwith a required reviewer and switch the trust condition to the environment subject. - Delete any static AWS keys from the repo's secrets, and enable secret scanning.
Going deeper: federation everywhere
- Kubernetes deploys can use OIDC too: an EKS access entry or IAM role, or a cluster that trusts GitHub's issuer for a CI service account. With GitOps, CI often needs no cluster access at all.
- Watch session duration and role permissions: short sessions, and separate plan/apply roles.
- Log and alert on
AssumeRoleWithWebIdentityfrom unexpected subjects (CloudTrail), and review trust policies regularly.
Recap
- OIDC federation: the job gets a short-lived signed token; the cloud checks issuer, audience and subject, then issues temporary credentials.
- Needs
permissions: id-token: write; works with AWS, GCP, Azure and Vault. - Tight subject conditions (repo + branch or environment) are the security boundary.
- For remaining secrets: environment scope, masking, scanning, rotation, one token per integration.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.