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:
- 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/credentialson a laptop. - Blast radius on leak. A key that can run
terraform applyworks just as well from the attacker's shell as from your CI. The network boundary is0.0.0.0/0. - 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 alwayssts.amazonaws.com. - An IAM role with a trust policy — the
Federatedprincipal points at the OIDC provider, and theConditionfilters requests by thesubclaim.
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:
AdministratorAccessattached 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 oldiam-usergave them, only with better attribution. Zero gain.- One job that runs
npm buildandterraform 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 callsconfigure-aws-credentials. A compromised dependency reaches only the pull privileges. AWS_ACCESS_KEY_IDin the runner's env "for compatibility". Environment variables overrideAWS_PROFILEin the aws CLI; one staleAWS_ACCESS_KEY_ID=…in group-level variables silently bypasses the OIDC session. Alwaysunset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKENin the shell beforeterraform.
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.