Vault JWT Authentication in CI/CD¶
How GitLab CI/CD pipeline jobs authenticate with Vault and read secrets without storing any credentials in GitLab.
JWT authentication is the standard method for all CI/CD jobs that need secrets. GitLab issues a short-lived JWT token per job. The job exchanges that token for a Vault token, then uses it to read secrets. The Vault token expires when the job ends.
How It Works¶
sequenceDiagram
participant JOB as GitLab CI Job
participant GL as GitLab (JWT issuer)
participant V as Vault /auth/jwt/login
participant KV as Vault KV v2
GL->>JOB: Inject VAULT_ID_TOKEN (JWT)
JOB->>V: POST /v1/auth/jwt/login {role, jwt}
V-->>JOB: client_token (short-lived)
JOB->>KV: GET /v1/secret/data/nexus/svc-orchestrator
KV-->>JOB: {username, password}
JOB->>JOB: Use credentials (docker login, scan, etc.)
Note over JOB,KV: Token expires when job ends
Step-by-Step Usage¶
Step 1 — Declare the JWT ID token¶
In your job definition, declare an id_tokens block. GitLab will inject the token as an environment variable before the job's before_script runs.
The aud claim must match the audience configured in Vault's JWT auth method. For CWIQ, it is always https://gitlab.shared.cwiq.io.
Step 2 — Exchange the JWT for a Vault token¶
AUTH_RESPONSE=$(curl -sf \
--request POST \
--header "Content-Type: application/json" \
--data "{\"role\": \"nexus-ci\", \"jwt\": \"${VAULT_ID_TOKEN}\"}" \
"${VAULT_ADDR}/v1/auth/jwt/login")
VAULT_TOKEN=$(echo "${AUTH_RESPONSE}" \
| grep -o '"client_token":"[^"]*"' \
| head -1 \
| sed 's/"client_token":"//;s/"$//')
Step 3 — Read secrets using the Vault token¶
SECRET_RESPONSE=$(curl -sf \
--header "X-Vault-Token: ${VAULT_TOKEN}" \
"${VAULT_ADDR}/v1/secret/data/nexus/svc-orchestrator")
NEXUS_USER=$(echo "${SECRET_RESPONSE}" \
| grep -o '"username":"[^"]*"' \
| head -1 \
| sed 's/"username":"//;s/"$//')
NEXUS_PASSWORD=$(echo "${SECRET_RESPONSE}" \
| grep -o '"password":"[^"]*"' \
| head -1 \
| sed 's/"password":"//;s/"$//')
Step 4 — Use the secrets¶
echo "${NEXUS_PASSWORD}" | docker login nexus.shared.cwiq.io:8443 \
--username "${NEXUS_USER}" \
--password-stdin
The nexus-ci Role¶
All platform CI/CD jobs use the nexus-ci role. This role is bound to GitLab's JWT issuer and grants read access to a specific set of secret paths.
# Paths accessible under nexus-ci
secret/data/nexus/svc-orchestrator # Docker registry credentials
secret/data/nexus/svc-executor # Executor RPM build credentials
secret/data/sonarqube/svc-orchestrator # SonarQube token + URL
secret/data/defectdojo/svc-orchestrator # DefectDojo API token
secret/data/orchestrator/e2e-test-user # E2E test admin credentials
secret/data/reportportal/svc-orchestrator # ReportPortal API key
The role is not configurable per project. All platform service pipelines share the same nexus-ci role.
Common Secret Paths Used in CI¶
| Vault Path | Keys Read | Used By |
|---|---|---|
secret/data/nexus/svc-orchestrator |
username, password |
Docker push/pull, Kaniko builds, Trivy image scan |
secret/data/sonarqube/svc-orchestrator |
token, url |
sonarqube-scan job |
secret/data/defectdojo/svc-orchestrator |
token |
defectdojo-import job |
secret/data/orchestrator/e2e-test-user |
username, password |
e2e-test job — global setup auth |
secret/data/reportportal/svc-orchestrator |
api_key |
e2e-test job — ReportPortal reporter |
VAULT_ADDR Variable¶
The VAULT_ADDR environment variable must be set for every job that uses Vault. It is defined as a GitLab CI/CD variable at the group level (group 9 — orchestrator), so it is automatically available in all projects within the group. You do not need to define it in per-project YAML.
If VAULT_ADDR is not set, the curl auth step silently fails.
The curl command will attempt to reach https:///v1/auth/jwt/login (empty hostname) and return an error. Always verify the variable is available if you encounter authentication failures. Check the group-level CI/CD variables in GitLab if the variable appears unset in a job trace.
Working with Alpine-Based Images¶
Alpine images use BusyBox wget, which is incompatible with the curl patterns above.
Images like sonar-scanner-cli and the Trivy aquasec/trivy image are Alpine-based and do not include curl by default. BusyBox's wget does not support --header or --post-data. You must install curl before the Vault auth steps.
This applies to any Alpine-based image in a job that needs Vault access.
Debugging Authentication Failures¶
| Symptom | Likely Cause | Fix |
|---|---|---|
curl: (6) Could not resolve host |
VAULT_ADDR not set or empty |
Check group-level CI variable is defined and not masked |
HTTP 400 from /v1/auth/jwt/login |
Wrong aud claim in id_tokens block |
Verify aud: https://gitlab.shared.cwiq.io |
HTTP 403 from /v1/auth/jwt/login |
Job is not bound to the JWT role | Check the nexus-ci role's bound_claims in Vault |
| HTTP 403 when reading a secret path | Path not in the nexus-ci policy |
Request a policy update or use a more permissive role |
Empty VAULT_TOKEN after parsing |
grep/sed parsing failed on the response |
Print AUTH_RESPONSE to the job log for debugging |
Related Documentation¶
- Vault Integration Overview — Authentication methods and secrets layout
- Secret Path Conventions — All available secret paths and their keys
- Vault Agent Sidecar — How application containers receive secrets at runtime (AppRole-based)
- SonarQube Setup — How SonarQube uses JWT auth in its scan job