CI/CD & Software Supply Chain›04 · OIDC federation & secret hygiene
Learning Hub / Delivery & Infrastructure as Code / CI/CD & Software Supply Chain

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.

Practitioner → Advanced
Key wordsOIDC federationid-token: writesub claimAssumeRoleWithWebIdentityworkload identity federationVault JWT authenvironment secretssecret scanningrotation

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

  1. In a sandbox AWS account, create the GitHub OIDC provider and a role that trusts only repo:<you>/<repo>:ref:refs/heads/main with read-only permissions.
  2. Add the workflow above (without environment) and run aws sts get-caller-identity.
  3. Run the same workflow from another branch and read the access-denied error. Explain why it failed.
  4. Add an environment: prod with a required reviewer and switch the trust condition to the environment subject.
  5. 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 AssumeRoleWithWebIdentity from 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.