Skip to main content
INS // Insights

GitLab OIDC AWS Federation Without Access Keys

Updated September 2026 · 5 min read

A surprising number of GitLab shops still keep an AWS_ACCESS_KEY_ID in CI variables "just for deploys." The rotation calendar is a spreadsheet. The blast radius is every job that inherited the variable. GitLab OIDC AWS federation replaces that standing key with a job-scoped id_token that AWS STS will exchange for temporary credentials — and the trust policy can name the project, the branch, and the environment in a way a shared access key never could.

This is not a GitHub Actions rewrite with the issuer swapped. GitLab's claim set, the id_tokens keyword, self-managed issuer URLs, and protected-environment rules are a different contract. Copying an Actions blog post into .gitlab-ci.yml is how teams ship a federation that trusts the wrong sub.

Why GitLab OIDC AWS Is Its Own Cutover

GitHub Actions OIDC is well-documented and we have shipped it. GitLab is the runner we actually see on a lot of mid-market and regulated teams — self-hosted, group-level runners, deploy jobs gated on protected environments. The AWS side is the same family of APIs (AssumeRoleWithWebIdentity). The identity document is not.

GitLab issues a JWT as a CI id_token with an audience you choose. Claims include iss (GitLab.com or your self-managed base URL), sub (a project path plus ref), project_id, namespace_id, ref_protected, and environment name when the job defines one. The IAM OIDC provider must point at that issuer, with the thumbprint GitLab publishes for the environment you run. A provider built for token.actions.githubusercontent.com will never accept a GitLab token.

GitLab's own ID token / AWS walkthrough is the authoritative job syntax. What it does not do is design your role split, your deny-on-wrong-project conditions, or the evidence trail a SOC 2 sample wants when they ask how CI reaches production.

The Federation We Build

We add one IAM OIDC provider per GitLab issuer (usually one for GitLab.com, or one per self-managed hostname). Then we mint roles per job class, not one GitLabDeploy role that every pipeline shares.

# .gitlab-ci.yml — job-scoped id_token, not CI variables
deploy_prod:
  stage: deploy
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.com
  environment: production
  script:
    - >
      aws sts assume-role-with-web-identity
      --role-arn "$PROD_DEPLOY_ROLE_ARN"
      --role-session-name "gitlab-$CI_PROJECT_ID-$CI_JOB_ID"
      --web-identity-token "$GITLAB_OIDC_TOKEN"
      --duration-seconds 3600

The matching trust policy is where the standing key dies for real:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::ACCOUNT:oidc-provider/gitlab.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "gitlab.com:aud": "https://gitlab.com",
          "gitlab.com:project_id": "4242"
        },
        "StringLike": {
          "gitlab.com:sub": "project_path:org/payments:ref_type:branch:ref:main"
        }
      }
    }
  ]
}

Three decisions we will not skip:

  • Audience is pinned. A token minted for another audience is useless against this role.
  • project_id is numeric and stable. Path renames happen. Project IDs do not. We condition on both path-shaped sub and project_id when the issuer provides it.
  • Environment-bearing jobs only assume the prod role. A review/* job must fail STS even if someone copies the role ARN into an ad-hoc pipeline.

Session duration stays short — one hour is the default we ship unless a migration job has a documented reason. The role session name includes CI_JOB_ID so CloudTrail can answer "which pipeline did this."

What We Refuse to Leave Standing

Federation that still has the old IAM user key in group CI variables is not done. The cutover we run:

  1. Inventory every GitLab group and project file/variable that looks like AKIA or a secret named AWS_*.
  2. Stand up OIDC + roles beside the key. Dual-run one non-prod pipeline until assume-role is boring.
  3. Flip prod jobs to id_tokens. Confirm CloudTrail AssumeRoleWithWebIdentity for the canary.
  4. Delete the IAM user access keys, then delete the CI variables. Order matters. Removing variables first with the key still live just moves the secret into someone's laptop.

This is the same elimination posture as rotating long-lived AWS credentials — rotation is a bridge, not the destination — applied to GitLab instead of humans and broken cron jobs.

Self-hosted runners sitting on VMs are a different remaining secret: the runner host itself may still hold an instance profile or, worse, a key in ~/.aws/credentials. If that host is not an AWS compute identity, we point that leftover at AWS IAM Roles Anywhere rather than stuffing another AKIA into the runner image.

Tradeoffs We Accept on Purpose

One role per environment, not one role per micro-job. Fine-grained roles reduce blast radius and multiply IAM objects. For a mid-market org we split plan / apply-nonprod / apply-prod and stop. Twelve roles for twelve job names is how the next engineer copies a prod ARN into a lint job.

Protected branches are not a substitute for IAM conditions. GitLab protected branches stop pushes. They do not stop a leaked id_token from a misconfigured job on a fork if your sub condition is a wildcard. We do not ship StringLike: "project_path:org/*".

Self-managed GitLab needs a reachable issuer. STS has to fetch JWKS from the issuer URL. An air-gapped GitLab with no published JWKS endpoint cannot use this pattern without a split that we will tell you is a different design. We will not pretend a private issuer "just works" because the YAML keyword exists.

GitLab.com vs self-managed is a provider fork. Mixing both issuers on one IAM provider is a misconfiguration. Two providers, two role trust statements.

Evidence the Sample Can Use

CloudTrail events for AssumeRoleWithWebIdentity with the GitLab roleSessionName and the OIDC sub are the proof CI used federation. A CI variable change log showing the AKIA removed is the proof the standing credential is gone. Together they answer CC6-style questions about how non-human identities reach AWS — the same class of work as a workload identity federation migration, with GitLab as the identity source.

We build this on the AWS account layout we already run for production SaaS and content platforms: OIDC-federated CI, no long-lived deploy users. The pattern is documented, not ceremonial.

GitLab OIDC AWS federation is part of AWS cloud infrastructure delivery and the credential-elimination slice of security automation.

Talk to us about your credential elimination pilot: rutagon.com/contact · 907-841-8407 · contact@rutagon.com.

Frequently Asked Questions

Can we reuse our GitHub Actions OIDC provider for GitLab?

No. The issuer, JWKS, and claim names are different. A GitLab id_token will not validate against token.actions.githubusercontent.com. Add a GitLab issuer as its own IAM OIDC provider and write trust policies against that provider ARN.

What GitLab claim should we pin if our project path might change?

Pin project_id (stable) and keep a tight sub prefix as defense in depth. If the group is renamed, update the sub condition in the same change as the GitLab move — do not wildcard the path to avoid that ticket.

Do we still need AWS keys for local developer deploys?

Developers should not share the pipeline IAM user. Humans use SSO-assumed roles. Pipelines use GitLab OIDC. Local break-glass is a separately named role with logging, not the CI key copied into a laptop.

How do protected environments interact with the IAM role?

Protected environments decide which GitLab jobs may run against production. The IAM condition must still name that environment or the main-branch sub. Either control alone is incomplete; we implement both.

What if we use GitLab self-managed with a custom hostname?

The IAM OIDC provider URL must match the issuer in the id_token exactly, including scheme and host. We register that hostname, not gitlab.com, and we verify STS can retrieve JWKS from it before any prod cutover.