Short-lived cloud credentials for CI

Replace stored cloud access keys in CI with short-lived credentials from your CI provider's OIDC issuer, scoped per project, branch and environment.

Updated 2026-11-11Raw markdown

Strategy sections
On this page

Use this page when a pipeline still uses long-lived cloud keys as variables, or when setting up deploys for a migrated repo. OIDC means no long-lived cloud keys live in CI at all. The pattern is the same across CI systems. Only the issuer URL and claim names change. Look those up in your provider's OIDC docs.

How it works

sequenceDiagram
  participant Job as CI job
  participant CI as CI provider (issuer)
  participant STS as Cloud STS
  Job->>CI: request id_token (aud = configured audience)
  CI-->>Job: signed JWT with project, ref, environment claims
  Job->>STS: AssumeRoleWithWebIdentity(role, JWT)
  STS->>STS: check issuer, audience, sub conditions
  STS-->>Job: temporary credentials (1 h)

Prerequisites

  • Permission to create an OIDC identity provider and roles, through Terraform in an MR.
  • The issuer URL for your CI provider.
  • A decision on the audience value, often the same issuer URL.

Step 1: Create the identity provider (Terraform)

resource "aws_iam_openid_connect_provider" "ci" {
  url             = "https://oidc.example.com"
  client_id_list  = ["https://oidc.example.com"]
  # thumbprint_list is optional on recent AWS provider versions; AWS verifies well-known CAs itself
}

Step 2: One role per project and environment

Scope the trust policy tightly with the sub claim. The exact sub format depends on your CI provider (project or repo, branch, environment). Copy it from that provider's docs. Do not use a bare *.

data "aws_iam_policy_document" "deploy_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.ci.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "oidc.example.com:aud"
      values   = ["https://oidc.example.com"]
    }
    condition {
      test     = "StringLike"
      variable = "oidc.example.com:sub"
      values   = ["<sub-for-this-project-and-branch>"]
    }
  }
}

resource "aws_iam_role" "deploy_prod" {
  name                 = "<project>-deploy-prod"
  assume_role_policy   = data.aws_iam_policy_document.deploy_trust.json
  max_session_duration = 3600
}

Attach a permission policy that allows only what the deploy needs (for a container service: register the task definition, update the named services, pass the named task roles, read and write the one deploy parameter). See least privilege.

Step 3: Use it in the job

Wire this into your CI provider's OIDC or id-token feature. The YAML differs by vendor. The job itself does this:

echo "$OIDC_TOKEN" > /tmp/web-identity-token
export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/web-identity-token
export AWS_ROLE_ARN="$DEPLOY_ROLE_ARN"   # non-secret CI variable per environment
export AWS_ROLE_SESSION_NAME="ci-$JOB_ID"
aws sts get-caller-identity
./ci/deploy.sh prod

Restrict the production job to the default branch, and make it manual.

Verify

  • aws sts get-caller-identity in the job prints the expected role.
  • A job on a feature branch is denied for the prod role (test this on purpose).
  • Old access key variables are removed, and the IAM user's keys are deactivated, then deleted after a quiet period.

Pitfalls

  • sub condition with a bare *. Any project on the instance could assume the role.
  • Using StringLike with a wildcard ref on a prod role. Restrict prod to the default branch or protected tags.
  • Forgetting that merge-request or pull-request pipelines have a different ref. Give them a read-only or dev role, not prod.
  • A self-managed CI server behind a private network: the cloud must reach the issuer's /.well-known/openid-configuration and JWKS over the internet, or OIDC fails.