OIDC Federation Beyond GitHub — GitLab, Buildkite, and Generic Providers
GitHub Actions OIDC gets all the attention, but GitLab, Buildkite, and CircleCI issue the same signed tokens. Here's how to trust them without opening a hole.
Most teams that adopt keyless authentication do it for GitHub Actions first, because that's where the tutorials point. Then someone moves a pipeline to GitLab, or the platform team standardizes on Buildkite, and the question comes up: do we go back to static keys for the other CI systems? No. Every one of these systems is an OIDC identity provider. The mechanics are identical to what we already documented for OIDC federation from GitHub Actions to AWS. What changes is the issuer URL and the exact shape of the subject claim, and the subject claim is where people cut themselves.
The shape is the same everywhere
An OIDC provider does one job for you: it signs a JWT describing the running job and publishes a JWKS document so anyone can verify the signature. The cloud side does the rest. You register the provider once (issuer URL plus a thumbprint or the JWKS it serves), then write a role trust policy that pins three things:
- issuer (
iss) — which provider signed this, so a token from some other GitLab instance can't walk in - subject (
sub) — which specific job, keyed to project, branch, environment, or pipeline - audience (
aud) — who the token was minted for, so a token meant for someone else's API can't be replayed against your role
Validate those three correctly and there is nothing to leak. The token lives for the length of the job and is scoped to a role that can only do what that job needs. Get the subject match wrong and you've built a very modern way to hand your account to a stranger.
GitLab CI: id_tokens
GitLab retired CI_JOB_JWT and its _V2 successor. The current mechanism is explicit id_tokens, where you name the audience yourself and GitLab injects the token as an env var. The issuer is your GitLab instance URL (https://gitlab.com for SaaS, your own host for self-managed).
deploy:
stage: deploy
image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
id_tokens:
AWS_ID_TOKEN:
aud: https://gitlab.com # must match the trust policy exactly
script:
- >
export $(aws sts assume-role-with-web-identity
--role-arn "$AWS_ROLE_ARN"
--role-session-name "gitlab-$CI_JOB_ID"
--web-identity-token "$AWS_ID_TOKEN"
--duration-seconds 900
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
--output text | awk '{print "AWS_ACCESS_KEY_ID="$1"\nAWS_SECRET_ACCESS_KEY="$2"\nAWS_SESSION_TOKEN="$3}')
- aws sts get-caller-identity
The AWS trust policy that accepts it. Note the subject format: project_path:GROUP/PROJECT:ref_type:branch:ref:BRANCH.
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/gitlab.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"gitlab.com:aud": "https://gitlab.com",
"gitlab.com:sub": "project_path:acme/payments:ref_type:branch:ref:main"
}
}
}
That subject pins the role to the main branch of one project. A merge request pipeline or a fork gets a different sub and is rejected.
Buildkite
Buildkite exposes OIDC through the agent CLI rather than an env var, which is cleaner because the token is requested at the moment it's needed and never sits around. Issuer is https://agent.buildkite.com.
steps:
- label: "deploy"
command: |
export AWS_WEB_IDENTITY_TOKEN_FILE=$(mktemp)
buildkite-agent oidc request-token \
--audience "sts.amazonaws.com" \
> "$AWS_WEB_IDENTITY_TOKEN_FILE"
export AWS_ROLE_ARN="arn:aws:iam::123456789012:role/buildkite-deploy"
aws sts get-caller-identity
Buildkite's subject looks like organization:acme:pipeline:api-deploy:ref:refs/heads/main:commit:HASH:step:.... Because the commit and step are in there, don't try to match the whole string — pin the stable prefix with StringLike on organization and pipeline and let the rest float, or use Buildkite's dedicated claims (organization_slug, pipeline_slug) if your provider config surfaces them.
CircleCI and the generic case are the same story with different strings. CircleCI's issuer is https://oidc.circleci.com/org/ORG_ID and its subject encodes the org, project, and context. Any conformant provider — HashiCorp Vault, an internal Spinnaker, a Kubernetes service account issuer — plugs in the same way: register the issuer, verify the JWKS is reachable, pin sub and aud.
Three gotchas that bite
Wildcard subjects are a footgun. StringLike with project_path:acme/* on GitLab, or sub: repo:myorg/* on GitHub, means any project or repo under that namespace can assume the role. Fine for a throwaway sandbox, a standing breach for anything with real permissions. Match the exact sub unless you have a documented reason not to, and when you do wildcard, wildcard the branch, never the project.
Audience has to match byte for byte. GitLab defaults aud to the instance URL; AWS's SDK defaults the web-identity audience to sts.amazonaws.com. If the token says one thing and the trust policy checks another, you get an opaque AccessDenied with no hint why. Decode the JWT (jwt.io locally, never paste a live token into a website) and read the actual aud before you go debugging IAM.
The issuer's JWKS endpoint must be reachable from the validator. For self-managed GitLab behind a firewall, or an air-gapped runner, the cloud provider fetches /.well-known/openid-configuration and the JWKS URL it points to. If that path isn't publicly resolvable with a valid TLS chain, federation silently fails to set up. This is the number-one reason self-hosted OIDC "works in the demo, breaks in prod."
The call we'd make
Standardize on OIDC across every CI system before you standardize on the CI system itself. The token shape is portable; the static-key habit is not. Register each provider, pin the exact sub and aud, and treat any wildcard in a subject claim as a change that needs a second reviewer. The five minutes you spend reading the actual JWT claims will save the afternoon you'd otherwise lose to a silent AccessDenied.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Best LLM APIs in 2026: By Use Case
A practitioner's guide to picking the right LLM API for coding, long docs, cheap extraction, reasoning, vision, voice, and on-device work.
Production RAG Reliability — Making LLM Answers Trustworthy
A demo RAG app is easy; one users trust is not. This is the map for reliable retrieval-augmented generation: grounding, evaluation, retrieval quality, guardrails, and safe rollout.
More from DevOps
Explore more articles in this category
GitHub Copilot CLI Sandboxing Is GA: What Actually Changed
GitHub made local sandboxing generally available for Copilot CLI on October 7, so the default now restricts the agent instead of trusting it.
The Tensorlake npm Compromise Targeted Your AI Tool Configs
A compromised npm release went after cloud credentials as expected, and local AI coding-assistant config files, which most teams never treated as secrets.
GitLab's New Rate Limits: What to Fix Before Oct 19
GitLab is capping unauthenticated API calls at 60 an hour starting October 19, and the preview windows land before most teams will have noticed.
You might have missed
Evergreen posts worth revisiting.