Note

OIDC → AWS STS: CI/CD Without Long-Lived Keys

Federated identity replaces AWS_ACCESS_KEY_ID in CI/CD: one pattern for GitHub, GitLab and Atlantis — no rotation, real CloudTrail attribution.

AWS_ACCESS_KEY_ID in CI variables was a 2018 standard. By 2026 it is an anti-pattern. Federated identity through OIDC gives the pipeline short-lived STS credentials scoped by the token's sub claim, and CloudTrail records the actual workflow, branch and repository instead of a nameless machine user. The same pattern works for GitHub Actions, GitLab CI and self-hosted Atlantis.

What is wrong with a long-lived key

An aws_iam_access_key lives for months. It sits in repository secrets or CI variables, and that creates three problems no "better password policy" can fix:

  1. Manual rotation. Reissuing the key means a PR in the SaaS, a new value in every secret of every repository, and a hope that nobody dropped it into ~/.aws/credentials on a laptop.
  2. Blast radius on leak. A key that can run terraform apply works just as well from the attacker's shell as from your CI. The network boundary is 0.0.0.0/0.
  3. CloudTrail without attribution. You see eventSource=ec2.amazonaws.com, userIdentity.userName=ci-deployer-prod, and no link to which PR or whose button triggered it.

OIDC as the replacement: three actors

The federated flow replaces "pass the key" with "trade a token for a short session". It has three actors:

  • The CI provider issues a signed JWT (the id_token) with claims for repository, branch, environment and project id.
  • An IAM OIDC Identity Provider in AWS — one per provider, ARN of the form arn:aws:iam::<acc>:oidc-provider/<issuer-host>. Audience is always sts.amazonaws.com.
  • An IAM role with a trust policy — the Federated principal points at the OIDC provider, and the Condition filters requests by the sub claim.

When a job starts, the CI puts the JWT into a temporary variable and calls sts:AssumeRoleWithWebIdentity. AWS verifies the signature, compares aud and sub against the trust policy, and returns AccessKeyId/SecretAccessKey/SessionToken valid for an hour or less. CloudTrail no longer shows iam-user/ci-prod but arn:aws:sts::…:assumed-role/<role>/<run-id> with the sub claim in webIdFederationData.

Minimal setup on three providers

GitHub Actions — issuer token.actions.githubusercontent.com, sub format repo:org/repo:ref:refs/heads/main (or :environment:prod, or :pull_request):

permissions:
  id-token: write     # required
  contents: read
jobs:
  deploy:
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<acct>:role/gha-deploy
          aws-region: eu-central-1
      - run: terraform apply -auto-approve

GitLab CI (SaaS) — issuer https://gitlab.com, sub format project_path:group/project:ref_type:branch:ref:main. The JWT arrives as id_tokens, and the exchange is an explicit aws sts assume-role-with-web-identity step.

Self-hosted Atlantis — issuer is either Atlantis itself or the IDP behind it. Scope by per-project IAM role, not a single shared "atlantis-runner".

The trust policy is the same modulo issuer and sub-pattern:

resource "aws_iam_role" "ci_deploy" {
  assume_role_policy = jsonencode({
    Statement = [{
      Effect = "Allow"
      Action = "sts:AssumeRoleWithWebIdentity"
      Principal = { Federated = "arn:aws:iam::${var.acc}:oidc-provider/${var.issuer}" }
      Condition = {
        StringEquals = { "${var.issuer}:aud" = "sts.amazonaws.com" }
        StringLike   = { "${var.issuer}:sub" = var.sub_pattern }
      }
    }]
  })
}

What you must not do, even when it "works"

Three anti-patterns keep showing up in public repositories and erase the gain from OIDC:

  • AdministratorAccess attached to the OIDC role "for the demo". A federated role has to be least-privilege. Otherwise, when a build script gets compromised (an npm dependency, a supply-chain hop), the attacker gets exactly what your old iam-user gave them, only with better attribution. Zero gain.
  • One job that runs npm build and terraform apply. The build runs on a default runner, can pull arbitrary dependencies, and the same process then receives a temporary token with production rights. The right shape: a build job assumes no role and pushes an artifact to a registry; a separate apply job calls configure-aws-credentials. A compromised dependency reaches only the pull privileges.
  • AWS_ACCESS_KEY_ID in the runner's env "for compatibility". Environment variables override AWS_PROFILE in the aws CLI; one stale AWS_ACCESS_KEY_ID=… in group-level variables silently bypasses the OIDC session. Always unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN in the shell before terraform.

A separate trap for Terraform: module { source = "s3::…" } does not respect AWS_PROFILE, so modules download under default credentials. Two fixes: run aws configure export-credentials --format env before terraform init, or keep modules in Git or a registry rather than S3.

What you will stop doing

After the migration the routine flow disappears: key rotation, audits of who added AWS_* to secrets, off-boarding of people who once put a key on their laptop. The persistent-secret count in CI drops to zero. The CI/CD attack surface shrinks to forging GitHub's or GitLab's JWT signature — which, under any realistic threat model, is the same as compromising the provider itself.

This is the rare security migration that removes procedures instead of adding them.