Skip to main content

GitLab OIDC Pipeline

Authenticating GitLab CI to AWS without long-lived credentials.

GitLabCI/CDAWS

The short version: nothing in these pipelines holds an AWS access key. Every job that needs AWS credentials gets them by exchanging a GitLab-issued OIDC token for short-lived, narrowly scoped ones, and those credentials expire on their own within the hour whether or not anyone remembers to rotate anything.

What actually happens

GitLab CI can issue a signed OIDC ID token to a job (id_tokens in .gitlab-ci.yml), scoped to a specific audience:

id_tokens:
  GITLAB_OIDC_TOKEN:
    aud: https://gitlab.com

The job then trades that token for temporary AWS credentials via sts assume-role-with-web-identity, against an IAM role whose trust policy is configured to accept tokens from this specific GitLab instance:

aws sts assume-role-with-web-identity \
  --role-arn "$ROLE_ARN" \
  --role-session-name GitLabRunner \
  --web-identity-token "$GITLAB_OIDC_TOKEN" \
  --duration-seconds 3600

ROLE_ARN is a CI/CD variable, not a value baked into the pipeline — in this GitLab group it's set once at the group level so every project underneath it shares the same pattern without redeclaring it per repo. The credentials that come back live for that one job and then they're gone; there's no key sitting in a variable for someone to leak, rotate, or forget about.

Threat model

The things this setup is actually protecting against, and how:

  • A leaked long-lived key never exists to leak. No static AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY pair lives in CI variables at all for these jobs.
  • The trust relationship is scoped, not just the credentials. The IAM role's trust policy restricts which issuer (this GitLab instance) and audience can assume it, and can further condition on which project/ref is allowed to — a token from an unrelated project or a feature branch that shouldn't have prod access won't satisfy the trust policy even though it's a validly signed GitLab token.
  • IAM permissions stay scoped to the job, not the account. The assumed role only has what that specific pipeline step needs, so a compromised job is bounded by the role's policy, not full account access.
  • Logs and artifacts are a disclosure path, not just the credentials themselves. Nothing in these jobs echoes the token or the assumed credentials — a leaked CI log is a much smaller problem if it never contained a secret in the first place.

Troubleshooting order

When a job fails to assume its role, this is the order that actually narrows it down fastest:

  1. Issuer/audience mismatch first. Confirm the aud claim in the token matches exactly what the role's trust policy expects — this is the most common first-time setup mistake and produces an AWS-side rejection that doesn't always make the actual mismatch obvious from the error text alone.
  2. Project/ref claims against the trust policy. If the role trusts only specific projects or refs (e.g. main, protected branches), confirm the job actually ran on one of them — a correctly configured pipeline on the wrong branch fails for a subtler reason than a broken pipeline.
  3. Clock and token expiration. OIDC tokens are time-bound; a runner with a skewed clock or a long queue time between token issue and use can present a token AWS considers expired.
  4. Downstream permissions on the assumed role. Once the role itself assumes successfully, a later AWS API call can still fail on its own IAM policy — a separate failure mode from the OIDC exchange itself, worth checking independently rather than assuming it's the same root cause.

Account IDs, role ARNs, and any other environment-specific identifiers above are deliberately illustrative, not the real values — the pattern is what's useful here, not the specifics of one account.