Skip to content

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.

my-job:
  stage: scan
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://gitlab.shared.cwiq.io

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.

VAULT_ADDR = https://vault.shared.cwiq.io

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.

before_script:
  - apk add --no-cache curl
  - |
    AUTH_RESPONSE=$(curl -sf ...)

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