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:
- GitLab mints a JWT signed with its private key
- CI job calls
vault write auth/jwt/login role=... jwt=$CI_JOB_JWT - Vault fetches GitLab's JWKS and validates the JWT signature
- Vault checks
bound_claimsagainst the JWT payload - 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.reflimits which git branches can deploy to prod —rel/*only.ttl=30mfor production. The job must finish within that window.- Each environment gets its own policy with separate KV paths.
- Glob patterns in
project_pathallow 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— thereforproject_pathin the JWT doesn't match the role'sbound_claimsrole not found—APP_ENVhas a typoinvalid audience—bound_audiencesdoesn'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_claimson Vault roles restrict which projects and branches can authenticate: prod can only be deployed fromrel/*- 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