Vault JWT auth from GitLab CI: no static tokens

Published: 2026-05-21

Static Vault tokens in CI variables are a liability: they don't expire, they have no context, and a single leaked .gitlab-ci.yml exposes every secret the token can read. The JWT auth method solves this — GitLab provides a signed OIDC token per job, Vault verifies it against the GitLab JWKS endpoint, and issues a short-lived Vault token with exactly the policy you defined.


How GitLab JWT works

Every GitLab CI job has a CI_JOB_JWT variable: a signed JWT containing claims like project_path, ref, environment, and user_login. Vault's JWT auth method is configured with the GitLab JWKS URL and validates the token's signature without any pre-shared secret.

The flow:

  1. GitLab mints a JWT signed with its private key
  2. CI job calls vault write auth/jwt/login role=... jwt=$CI_JOB_JWT
  3. Vault fetches GitLab's JWKS and validates the JWT signature
  4. Vault checks bound_claims against the JWT payload
  5. If all checks pass, Vault returns a short-lived token

Vault configuration

bash# Enable JWT auth
vault auth enable jwt

# Point to GitLab's JWKS
vault write auth/jwt/config \
  jwks_url="https://gitlab.example.com/-/jwks" \
  bound_issuer="https://gitlab.example.com"

Roles per environment

Create one role per deployment target. The bound_claims restrict which pipelines can authenticate:

bashvault write auth/jwt/role/access-Development \
  role_type="jwt" \
  bound_audiences="https://vault.test.antonnovikov.com" \
  user_claim="user_login" \
  bound_claims='{"project_path": ["company/backend/*"], "ref": ["dev", "feat/*"]}' \
  policies="app-development" \
  ttl="1h"

vault write auth/jwt/role/access-Production \
  role_type="jwt" \
  bound_audiences="https://vault.test.antonnovikov.com" \
  user_claim="user_login" \
  bound_claims='{"project_path": ["company/backend/*"], "ref": ["rel/*"]}' \
  policies="app-production" \
  ttl="30m"

Key points:

  • bound_claims.ref limits which git branches can deploy to prod — rel/* only.
  • ttl=30m for production. The job must finish within that window.
  • Each environment gets its own policy with separate KV paths.
  • Glob patterns in project_path allow multiple services to use the same role.

Vault policy

hcl# app-development policy
path "Microservices/+/Development/*" {
  capabilities = ["read"]
}
path "common-secret/Development/" {
  capabilities = ["read"]
}
hcl# app-production policy
path "Microservices/+/Production/*" {
  capabilities = ["read"]
}
path "common-secret/Production/" {
  capabilities = ["read"]
}

Policies only grant read. No create, update, or delete — CI should never be able to modify secrets.


CI side

yamlvariables:
  VAULT_ADDR: https://vault.test.antonnovikov.com
  DOCKER_IMAGE: registry.example.com/ci-images/vault-cli:1.0.1

.auth:jwt: &auth_jwt |
  export VAULT_TOKEN="$(vault write -field=token auth/jwt/login \
    role=access-${APP_ENV} \
    jwt=$CI_JOB_JWT)"
  vault kv get -field=kubeconfig_${NAMESPACE} common-secret/${APP_ENV} > kube_config
  chmod 600 kube_config
  export KUBECONFIG=kube_config

deploy_dev:
  image: ${DOCKER_IMAGE}
  stage: deploy
  before_script:
    - *auth_jwt
  script:
    - helm upgrade --install ...
  variables:
    APP_ENV: Development
    NAMESPACE: app

deploy_prod:
  image: ${DOCKER_IMAGE}
  stage: deploy
  before_script:
    - *auth_jwt
  script:
    - helm upgrade --install ...
  variables:
    APP_ENV: Production
    NAMESPACE: app
  rules:
    - if: $CI_COMMIT_REF_NAME =~ /^rel\//

The *auth_jwt YAML anchor is declared once and reused in every deploy job. The only thing that changes per job is APP_ENV.


The custom CI image

The vault-cli image contains vault, consul-template, helm, kubectl, and curl. Building it once and pinning the tag in DOCKER_IMAGE means every deploy job uses an identical environment regardless of upstream changes:

dockerfileFROM hashicorp/vault:1.15
RUN apk add --no-cache curl helm kubectl
COPY --from=hashicorp/consul-template:0.33 /bin/consul-template /bin/consul-template

Kubeconfig from Vault

Instead of storing kubeconfig in a GitLab CI variable (which is plain-text in the UI), store it in Vault under common-secret/<env>/kubeconfig_<cluster> and pull it at deploy time:

bashvault kv get -field=kubeconfig_app common-secret/Production > kube_config
chmod 600 kube_config
export KUBECONFIG=kube_config

The kubeconfig contains a service account token scoped to helm upgrade in the target namespace. No cluster-admin. No permanent shell access.


What the token can't do

Capability Status
Access secrets from a different environment Not possible — bound_claims prevents it
Reuse the token after job ends No — ttl expires
Deploy prod from a feature branch No — bound_claims.ref only allows rel/*
Modify Vault secrets No — policy only grants read
Access other projects' secrets No — project_path claim is bound

This is the minimum viable secret management pattern for CI without needing a full External Secrets Operator setup on the build side.


Debugging authentication errors

bash# Test JWT auth manually
vault write auth/jwt/login \
  role=access-Development \
  jwt=$CI_JOB_JWT

# Check JWT claims (decode without verifying)
echo $CI_JOB_JWT | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool

# List configured roles
vault list auth/jwt/role

# Check role details
vault read auth/jwt/role/access-Development

Common errors:

  • bound claim mismatch — the ref or project_path in the JWT doesn't match the role's bound_claims
  • role not foundAPP_ENV has a typo
  • invalid audiencebound_audiences doesn't match your Vault URL

Summary

  • GitLab issues a signed JWT per job; Vault validates it against the GitLab JWKS endpoint — no static tokens stored anywhere
  • bound_claims on Vault roles restrict which projects and branches can authenticate: prod can only be deployed from rel/*
  • Vault policies grant only read — CI can never modify secrets
  • Kubeconfig is stored in Vault, not in GitLab CI variables, so it never appears in the GitLab UI in plain text
  • Token TTL is 30m for production, 1h for development — expired automatically when the job ends