# OIDC Federation Beyond GitHub — GitLab, Buildkite, and Generic Providers

URL: https://www.devopsness.com/blog/oidc-federation-beyond-github-gitlab-buildkite
Author: Kiril Urbonas
Published: 2026-07-13
Updated: 2026-08-30
Categories: DevOps
Tags: CI/CD, Security, Cloud, OIDC, Identity

Summary: 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.

---
# OIDC Federation Beyond GitHub — GitLab, Buildkite, and Generic Providers

Most teams that adopt [keyless authentication](/blog/keyless-cloud-authentication-ending-static-credentials) 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](/blog/oidc-federation-github-actions-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).

```yaml
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`.

```json
{
  "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`.

```yaml
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`.

