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
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_tokensis "Tier: Free, Premium, Ultimate" (GitLab: OIDC authentication using ID tokens). Thesecrets: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:
deploy:
id_tokens:
AWS_ID_TOKEN:
aud: sts.amazonaws.com
script:
- ./deploy.shGitLab 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:
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-identityGitLab'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.
"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.
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:
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 showThat 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
audclaim 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_tokensis per job. Don't put it indefault: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 addref_protectedtoo. 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.
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 deploycrunu 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.
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
- Use external secrets in CI/CD, GitLab Docs, checked October 30, 2026.
- OpenID Connect (OIDC) authentication using ID tokens, GitLab Docs, checked October 30, 2026.
- Configure OpenID Connect in AWS to retrieve temporary credentials, GitLab Docs, checked October 30, 2026.
- Configure OpenID Connect with GCP Workload Identity Federation, GitLab Docs, checked October 30, 2026.
- Configure OpenID Connect in Azure to retrieve temporary credentials, GitLab Docs, checked October 30, 2026.