Skip to content
CI/CD7 min read

GitLab CI OIDC without Premium: id_tokens on the Free tier

The secrets keyword needs GitLab Premium, but id_tokens works on Free. How to exchange a job's ID token for AWS, Google Cloud, Azure or Crunu by hand.

Crunu teamPublished 7 min read

Example. Kiteline is a fictional company.
On this page

Yes, you can use OIDC from GitLab CI on the Free tier. The secrets: keyword, which fetches values from Vault, cloud secret managers or GitLab's own Secrets Manager for you, is listed for Premium and Ultimate only. But id_tokens, which gives a job a signed JWT, is available on Free, Premium and Ultimate. With an ID token, the job can exchange the JWT for short-lived credentials itself: one command for AWS, two HTTP calls for Google Cloud, one az login for Azure. The cloud's trust policy decides which projects and branches may do that.

This post shows the split between the two features, the exchange for each cloud, how to keep tokens narrow, and how the same JWT binds a Crunu service token to a project and branch. The guide to CI/CD secrets with OIDC compares all CI systems and clouds.

What's Premium and what isn't

GitLab's documentation marks the tiers on each feature:

  • secrets: is "Tier: Premium, Ultimate". It lets a job name secrets to "retrieve from an external secrets provider" and receive them as CI/CD variables, with integrations for HashiCorp Vault, Google Cloud Secret Manager, Azure Key Vault and AWS Secrets Manager (GitLab: external secrets). GitLab Secrets Manager, built on OpenBao and generally available since GitLab 19.5, is also Premium and Ultimate.
  • id_tokens is "Tier: Free, Premium, Ultimate" (GitLab: OIDC authentication using ID tokens). The secrets: integrations use ID tokens under the hood, and GitLab notes you can "use ID tokens to manually authenticate with any secrets provider that supports OIDC authentication with JSON web tokens (JWT)."

So on Free you lose the declarative syntax, not the security model. The cloud still checks a GitLab-signed token, and no long-lived cloud key needs to sit in CI/CD variables.

The old predefined token is gone. GitLab's AWS page warns that "CI_JOB_JWT_V2 was removed in GitLab 17.0"; pipelines that used it need id_tokens now (GitLab: OIDC in AWS).

Declaring an ID token

A job asks for a token by name, with an audience:

YAML
deploy:
  id_tokens:
    AWS_ID_TOKEN:
      aud: sts.amazonaws.com
  script:
    - ./deploy.sh

GitLab puts the JWT in the variable AWS_ID_TOKEN for that job. The default subject is project_path:{group}/{project}:ref_type:{type}:ref:{branch_name}, and the token expires "at the job's timeout if specified, or 5 minutes if no timeout is specified."

AWS: one command

Register GitLab as an OIDC provider in IAM, create a role whose trust policy names your project and branch, then exchange the token in the job. This is GitLab's example, with the audience set to sts.amazonaws.com:

YAML
assume role:
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: sts.amazonaws.com
  script:
    - >
      aws_sts_output=$(aws sts assume-role-with-web-identity
      --role-arn ${ROLE_ARN}
      --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
      --web-identity-token ${GITLAB_OIDC_TOKEN}
      --duration-seconds 3600
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text)
    - export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $aws_sts_output)
    - aws sts get-caller-identity

GitLab's documentation says the audience "typically matches the audience value configured in your IAM OIDC identity provider (often sts.amazonaws.com or your GitLab instance URL)", and notes that using the GitLab URL "might work" but "is semantically misleading", because the audience should represent the service that accepts the token.

For the trust policy, GitLab recommends pinning stable IDs as well as the path. On GitLab.com, AWS accepts namespace_id and project_id as condition keys, and project_id "remains the same for the entire lifetime of the project, including across group renames, project renames, and project transfers." On GitLab Self-Managed and Dedicated, only sub and aud are supported as AWS condition keys.

JSON
"Condition": {
  "StringEquals": {
    "gitlab.com:sub": "project_path:kiteline/web-app:ref_type:branch:ref:main",
    "gitlab.com:aud": "sts.amazonaws.com",
    "gitlab.com:project_id": "67890"
  }
}

Kiteline is a fictional company; use your own project path and ID.

Example. Kiteline is a fictional company. Claim names and endpoints follow GitLab CI and AWS STS documentation.

Google Cloud: two HTTP calls

Google Cloud uses Workload Identity Federation: a workload identity pool with a provider for GitLab, and a service account the pool may impersonate. GitLab's documentation shows the exchange with curl: the job first sends its ID token to https://sts.googleapis.com/v1/token with a token-exchange grant and gets a federated token, then calls the IAM Credentials API's generateAccessToken for the service account and gets an OAuth access token (GitLab: OIDC with GCP Workload Identity Federation). The result can be passed to gcloud "by setting the environment variable CLOUDSDK_AUTH_ACCESS_TOKEN."

The restriction to a project and branch lives in the provider's attribute condition and in which principals may impersonate the service account.

Azure: one login

For Azure, you create an Entra ID application with a federated identity credential that names the GitLab subject. The job then signs in with the token:

YAML
auth:
  image: mcr.microsoft.com/azure-cli:latest
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.com
  script:
    - az login --service-principal -u $AZURE_CLIENT_ID -t $AZURE_TENANT_ID --federated-token $GITLAB_OIDC_TOKEN
    - az account show

That example comes from GitLab's Azure page; the audience must match the one in the federated credential (GitLab: OIDC in Azure).

Self-managed GitLab: the issuer has to be reachable

Every service that checks a GitLab ID token fetches GitLab's public signing keys from the issuer, which is the address of your GitLab instance. For AWS, GitLab's documentation says the provider URL "must be publicly accessible." For a private instance it describes a workaround, introduced in GitLab 18.1, that copies the OpenID configuration and public keys to a public location such as an S3 bucket, with a warning that a badly secured bucket "could lead to the takeover of any cloud accounts associated with this OpenID Connect identity."

The same applies to Crunu: on a self-managed GitLab, the binding's issuer is your instance's address, and the Crunu server must be able to fetch that instance's keys through OIDC discovery to check the JWT. If it can't reach them, the sign-in fails rather than skipping the check.

Keep each token narrow

Three habits apply whatever the cloud.

  • One audience per service. GitLab explains that services "can be configured to reject tokens that do not have an aud claim matching their bound audience", and to "use this functionality to reduce the number of services with which a token can authenticate." A job that talks to AWS and to Crunu should declare two tokens with two audiences, not one shared one.
  • Only the jobs that need it. id_tokens is per job. Don't put it in default: unless every job really needs cloud access.
  • Branch and environment in the trust policy. A role that deploys production should require ref:main, and on GitLab.com you can add ref_protected too. A policy that matches only the project path accepts any branch anyone with push access can create.

The OIDC policy builder drafts these policies, and the glossary entry on JWT explains the token itself.

When Premium's secrets: is worth it

The manual exchange is a few lines of script per cloud, and scripts drift. On Premium or Ultimate, secrets: turns them into configuration: a job names the secret, and GitLab fetches it and exposes it as a file-type CI/CD variable by default, so it isn't printed by accident in an environment dump. If you already run Vault or a cloud secret manager and have many projects, that consistency is the real benefit. On Free, put the exchange in one shared template that every project includes, and review it like code.

The same JWT, bound to a Crunu service token

Crunu uses the GitLab ID token in the same way: as proof of which job is calling. A pipeline signs in to Crunu with a service token stored in CRUNU_TOKEN. When that token has a CI binding (OIDC), the server accepts it only together with a JWT from GitLab whose claims match the binding's rules, and a replayed JWT is refused. A typical rule for production: provider GitLab, project_path: kiteline/web-app, ref: main, ref_type: branch, environment: production, with the Crunu server address as the audience.

YAML
deploy:
  image: node:24
  environment: production
  id_tokens:
    CRUNU_ID_TOKEN:
      aud: https://app.crunu.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - crunu run --env web-app/production -- npm run deploy

crunu reads CRUNU_ID_TOKEN by itself. This works on GitLab Free, because it uses id_tokens, not secrets:. The Crunu template file includes a .crunu-oidc job that installs a pinned crunu, checks the SHA-256 of the installer and the binary, and declares the ID token for you; the GitLab CI integration and the GitLab CI docs cover installation.

Example. Kiteline is a fictional company. Claim names follow GitLab CI documentation.

A copied CRUNU_TOKEN then doesn't work from a laptop or another project, because it needs a fresh JWT from that project's pipeline on that branch. The values it decrypts are still visible to the job's own commands, so keep each token's grant to the environments its job needs.

Checklist

Sources