GitLab CI AWS OIDC: Replace Static Keys with Short-Lived Credentials

That AWS access key sitting in your GitLab CI/CD variables has the same TTL as your employment — and when it leaks, it leaks permanently. GitLab CI AWS OIDC federation solves this by replacing static credentials with short-lived tokens that expire after the job ends, require zero rotation, and leave no long-lived secret stored anywhere in your pipeline configuration.

I’ve seen the alternative play out twice. A developer adds AWS_ACCESS_KEY_ID to a public repo by accident. Or a GitLab instance gets compromised and every stored variable is exfiltrated. In both cases, the damage window is “until someone notices and rotates the key manually” — which in practice means hours to days. With OIDC, the blast radius is one job, one hour. Here’s how we set this up for a production deployment pipeline, step by step.

The Scenario

The typical setup looks like this: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are stored as masked GitLab CI/CD variables at the project or group level. They belong to an IAM user with whatever permissions someone attached six months ago. They never expire. When a team member leaves, rotating them means updating every project that references them — and someone always forgets one.

The OIDC trust model eliminates all of that. When a GitLab CI job runs, GitLab’s identity provider issues a signed JWT specific to that job. The JWT contains claims about the project, branch, and pipeline source. The job sends this token to AWS STS, which validates the signature against GitLab’s public JWKS endpoint and returns short-lived credentials via AssumeRoleWithWebIdentity. Those credentials are valid for up to 3600 seconds by default and cease to exist the moment the job ends.

What we’re building here: GitLab SaaS (gitlab.com), AWS IAM OIDC provider, an IAM role with a tightly scoped trust policy written in Terraform, and a .gitlab-ci.yml that requests and exchanges the token. No Vault, no Secrets Manager, no third-party tooling required. If you’re on a self-hosted GitLab instance, the issuer URL changes to your instance URL — everything else is identical. You can find more pipeline patterns on kuryzhev.cloud.

Prerequisites

Before touching a config file, verify these exact versions and settings. Skipping this check is the fastest way to spend two hours debugging something that was never going to work.

  • GitLab Runner 15.7+ — OIDC ID token support was added in the December 2022 release. Check with gitlab-runner --version. Note that CI_JOB_JWT was deprecated in GitLab 17.0 — if you’re seeing that variable referenced in older docs, ignore it and use the id_tokens block instead.
  • AWS CLI v2.13+ — Earlier versions have inconsistent behavior with the file:// URI in --web-identity-token. The job image I use is amazon/aws-cli:2.13.0 pinned explicitly.
  • Terraform 1.6+ — If you’re applying the HCL examples below. The hashicorp/aws provider must be >= 4.0 for aws_iam_openid_connect_provider.
  • IAM permissions on your operator account: iam:CreateRole, iam:AttachRolePolicy, iam:CreateOpenIDConnectProvider, and sts:AssumeRoleWithWebIdentity.
  • GitLab Token Access enabled: Settings → CI/CD → Token Access → “Limit JSON Web Token (JWT) access” must be configured. The issuer URL for GitLab SaaS is exactly https://gitlab.com — no trailing slash.

One thing I always set before anything else: AWS_DEFAULT_REGION as a CI variable. The AWS CLI silently fails on region-scoped API calls even with valid credentials if this isn’t set. It’s not obvious from the error output and it will waste your time.

Step 1 — Create the IAM OIDC Identity Provider

GitLab CI AWS OIDC illustration

This registers GitLab as a trusted identity provider in your AWS account. Without this, STS has no way to validate the JWT signature. You can do this through the console (IAM → Identity Providers → Add Provider → OpenID Connect, provider URL: https://gitlab.com, audience: https://gitlab.com), but I prefer Terraform so the configuration is version-controlled and reproducible.

First, fetch the current TLS thumbprint for GitLab’s JWKS endpoint. AWS requires this to validate the identity provider’s certificate chain:

# Fetch the SHA-1 thumbprint of gitlab.com's TLS leaf certificate
# AWS uses this to validate the OIDC provider on every token exchange
echo | openssl s_client -servername gitlab.com -connect gitlab.com:443 2>/dev/null \
  | openssl x509 -fingerprint -noout -sha1

Copy the hex string from the output (strip the colons) and use it in the Terraform resource. Here’s the full OIDC provider and IAM role configuration:

# terraform/oidc_trust.tf
# Creates the IAM OIDC provider and deploy role for GitLab CI
# Provider: hashicorp/aws >= 4.0, Terraform >= 1.6

locals {
  gitlab_url      = "https://gitlab.com"
  gitlab_audience = "https://gitlab.com"
  # Restrict to a specific project and protected branch only
  allowed_sub = "project_path:mygroup/myproject:ref_type:branch:ref:main"
}

resource "aws_iam_openid_connect_provider" "gitlab" {
  url             = local.gitlab_url
  client_id_list  = [local.gitlab_audience]
  # Fetch current thumbprint: see openssl command above
  thumbprint_list = ["b3dd7606d2b5a8b4a13771dbecc9ee1cecafa38a"]
}

data "aws_iam_policy_document" "gitlab_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.gitlab.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "gitlab.com:aud"
      values   = [local.gitlab_audience]
    }

    condition {
      # StringEquals (not StringLike) for production — no wildcards allowed
      test     = "StringEquals"
      variable = "gitlab.com:sub"
      values   = [local.allowed_sub]
    }

    condition {
      # Restrict role assumption to a single AWS region
      # Prevents credential replay from a different region
      test     = "StringEquals"
      variable = "aws:RequestedRegion"
      values   = ["us-east-1"]
    }
  }
}

resource "aws_iam_role" "gitlab_deploy_production" {
  name               = "gitlab-deploy-production"
  assume_role_policy = data.aws_iam_policy_document.gitlab_trust.json
  max_session_duration = 3600  # match --duration-seconds in CI job
}

# Least-privilege: scoped to one S3 bucket only — never AdministratorAccess
resource "aws_iam_role_policy" "s3_deploy" {
  name = "s3-deploy-policy"
  role = aws_iam_role.gitlab_deploy_production.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
      Resource = [
        "arn:aws:s3:::my-production-bucket",
        "arn:aws:s3:::my-production-bucket/*"
      ]
    }]
  })
}

Watch out for this: the thumbprint must match the TLS leaf certificate of GitLab’s JWKS endpoint, not the root CA. AWS validates this on every token exchange. If GitLab rotates their certificate (it happens), your OIDC provider will start rejecting tokens until you update the thumbprint. I add a calendar reminder every six months to re-run the openssl command and check for drift.

Step 2 — Write the IAM Role and Trust Policy

The trust policy Condition block is where most people either get this right or leave a security hole. The sub claim format from GitLab is: project_path:mygroup/myproject:ref_type:branch:ref:main. That’s the full string you’re matching against.

For production roles, use StringEquals — never StringLike with a wildcard. I’ve reviewed configurations where the trust policy had StringLike with project_path:mygroup/myproject:* on the production role. That means any branch in the project, including a developer’s feature branch, can assume the production role. That completely defeats the purpose of environment separation.

For staging, StringLike is acceptable if you want to allow deployments from any branch — just be explicit about the scope. Create separate roles for each environment. The Terraform code above shows the production role; for staging, duplicate the module and swap StringEquals for StringLike on the sub condition with a value like project_path:mygroup/myproject:ref_type:branch:ref:*.

One more thing: scheduled pipelines and merge request pipelines produce different sub claim formats. A scheduled pipeline running against main uses ref_type:branch:ref:main. A tag pipeline uses ref_type:tag:ref:v1.2.3. If your production deploy triggers on a Git tag, your trust policy needs to reflect that. I’ve seen people spend an hour debugging an AccessDenied error that was simply a tag pipeline hitting a branch-only trust condition. Check the GitLab OIDC documentation for the full claim reference.

Step 3 — Configure the GitLab CI Pipeline

Here’s the complete .gitlab-ci.yml configuration. The key addition is the id_tokens block — this is what tells GitLab to issue a signed JWT for the job. Without it, GITLAB_OIDC_TOKEN is never populated and the STS call fails immediately.

# .gitlab-ci.yml
# GitLab CI pipeline with OIDC-based AWS role assumption
# Requires: GitLab Runner 15.7+, AWS CLI v2 in the job image

stages:
  - deploy

variables:
  AWS_DEFAULT_REGION: us-east-1                                          # critical — don't skip this
  AWS_ROLE_ARN: arn:aws:iam::123456789012:role/gitlab-deploy-production
  AWS_ROLE_SESSION_NAME: gitlab-ci-${CI_PROJECT_ID}-${CI_JOB_ID}        # unique per job for audit trail

# Reusable OIDC credential block — anchor for DRY pipeline config
.aws_oidc_credentials: &aws_oidc_credentials
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.com   # must match IAM OIDC provider Client ID exactly — no trailing slash
  before_script:
    # Write token to a temp file — avoids the token appearing in the process list
    - echo "${GITLAB_OIDC_TOKEN}" > /tmp/oidc_token.txt
    # Assume role and capture the full JSON response from STS
    - >
      STS_RESPONSE=$(aws sts assume-role-with-web-identity
      --role-arn "${AWS_ROLE_ARN}"
      --role-session-name "${AWS_ROLE_SESSION_NAME}"
      --web-identity-token file:///tmp/oidc_token.txt
      --duration-seconds 3600
      --output json)
    # Parse and export short-lived credentials from STS response
    - export AWS_ACCESS_KEY_ID=$(echo "${STS_RESPONSE}" | jq -r '.Credentials.AccessKeyId')
    - export AWS_SECRET_ACCESS_KEY=$(echo "${STS_RESPONSE}" | jq -r '.Credentials.SecretAccessKey')
    - export AWS_SESSION_TOKEN=$(echo "${STS_RESPONSE}" | jq -r '.Credentials.SessionToken')
    # Confirm identity — output will show the assumed role ARN, not an IAM user
    - aws sts get-caller-identity
    # Clean up — token file has no further use
    - rm -f /tmp/oidc_token.txt

deploy_to_production:
  stage: deploy
  image: amazon/aws-cli:2.13.0
  <<: *aws_oidc_credentials
  script:
    # Sync build artifacts to S3
    - aws s3 sync ./dist s3://my-production-bucket/ --delete
    # Force a new ECS deployment
    - >
      aws ecs update-service
      --cluster production-cluster
      --service my-app
      --force-new-deployment
  rules:
    # Only run on protected main branch — must match trust policy Condition
    - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
  environment:
    name: production

The credentials are valid for 3600 seconds by default. You can push this up to 43200 seconds (12 hours) by adjusting max_session_duration on the IAM role and passing a higher --duration-seconds value — but I wouldn’t. Match the session duration to your job timeout. A job that should run for 10 minutes doesn’t need credentials valid for an hour. The AWS STS documentation covers the full parameter reference for AssumeRoleWithWebIdentity.

Step 4 — Lock Down the Trust Policy for Multi-Branch Workflows

Real pipelines are messier than the single-branch example above. You have merge request pipelines, scheduled jobs, hotfix branches, and release tags all potentially needing AWS access at different permission levels. Here’s how I structure this without opening the trust too wide.

For production, the rule is absolute: StringEquals on the sub claim, scoped to a single protected branch or tag pattern. No exceptions. For staging, StringLike with a branch wildcard is acceptable — developers need to test deployments from feature branches, and the staging environment is not production-critical. Keep them as separate IAM roles with separate permission scopes.

The aws:RequestedRegion condition I included in the Terraform above is worth highlighting. It restricts role assumption to a specific AWS region. If credentials from a job are somehow replayed (unlikely but not impossible), they can’t be used to spin up resources in eu-west-1 or wherever your security team isn’t watching. It’s a cheap extra layer and costs nothing to add.

At scale — more than 100 concurrent jobs — be aware that each job makes one STS API call with approximately 200ms latency. STS AssumeRoleWithWebIdentity is free but counts against STS API rate limits. If you’re running large parallel pipelines, consider caching the credentials within a single job stage rather than re-assuming the role in every script step.

Verify and Test

Here’s the checklist I run every time I set this up on a new project. Don’t skip any of these — each one catches a specific failure mode.

1. Trigger a manual pipeline job and check the identity output. The aws sts get-caller-identity call in before_script should print something like:

{
    "UserId": "AROAEXAMPLEID:gitlab-ci-12345678-987654321",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/gitlab-deploy-production/gitlab-ci-12345678-987654321"
}

If you see an IAM user ARN instead of an assumed-role ARN, the OIDC exchange failed silently and you’re still using static credentials from somewhere. Check that AWS_ACCESS_KEY_ID isn’t set as a project-level variable overriding the OIDC-derived value.

2. Check for InvalidIdentityToken errors. The full error message is: “InvalidIdentityToken: Couldn’t retrieve verification key from your identity provider.” This almost always means the aud value in your id_tokens block doesn’t exactly match the Client ID registered in the IAM OIDC provider. Check for trailing slashes — https://gitlab.com/ is not the same as https://gitlab.com.

3. Debug AccessDenied on the assume-role call. If you see “AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity”, the trust policy condition isn’t matching the actual sub claim in your token. Decode the JWT on the fly to see exactly what’s in it:

# Decode the JWT payload to inspect all claims
# Run this as a debug step in the CI job before the assume-role call
echo $GITLAB_OIDC_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .

# The 'sub' field output will look like:
# "sub": "project_path:mygroup/myproject:ref_type:branch:ref:main"
# Copy this exactly into your trust policy Condition value

Watch out for this: if you’re running from a merge request pipeline, the sub claim uses a different format that includes the MR reference. If your trust policy only allows ref_type:branch:ref:main, MR pipelines will always get AccessDenied. That’s usually intentional for production — but make sure it’s a deliberate choice, not a surprise.

4. Confirm no static credentials exist. After switching to OIDC, delete the IAM user and its access keys entirely. If the user still exists and its keys are still stored in GitLab variables, a misconfiguration in the OIDC setup will silently fall back to the static keys. Remove the fallback so failures are loud.

Closing

Replacing static AWS credentials with GitLab CI AWS OIDC federation is one of the highest-value security improvements you can make to a CI pipeline in an afternoon. You eliminate an entire category of credential leak, remove the rotation burden, and gain per-job audit trails in CloudTrail automatically — every AssumeRoleWithWebIdentity call is logged with the GitLab job ID as the session name. Once this pattern is in place for one project, templating it across your entire GitLab group takes maybe an hour. From here, the logical next step is applying the same principle to Kubernetes workloads with IRSA, or extending the trust model to multi-cloud setups where the same GitLab JWT is validated by GCP Workload Identity or Azure Federated Credentials.

Related

Leave a Reply

Your email address will not be published. Required fields are marked *

Support us · 💳 Monobank