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.
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:
iss) — which provider signed this, so a token from some other GitLab instance can't walk insub) — which specific job, keyed to project, branch, environment, or pipelineaud) — who the token was minted for, so a token meant for someone else's API can't be replayed against your roleValidate 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 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 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.
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."
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 latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Verifying signed tokens at the edge with WebCrypto blocks bad traffic early and saves a full origin hop. Here's the pattern we ship, and the traps.
Static keys leak. The question isn't if but how fast you notice and how clean your response runbook is when the pager goes off.
Explore more articles in this category
How pods can talk to AWS, GCP, and Azure with no static keys — using audience-bound projected ServiceAccount tokens and the cluster OIDC issuer.
Our best engineer quit citing on-call. We rebuilt the whole thing: saner rotations, runbooks that actually help at 3am, and escalation that doesn't punish asking for help.
We used to ship code and turn it on in the same breath, so every deploy was a bet. Feature flags split those two events apart and made rollbacks a config toggle.
Evergreen posts worth revisiting.