GitLab CI/CD with HashiCorp Vault: Stop Storing Secrets in CI Variables
Every GitLab project accumulates secrets. Database passwords, API tokens, cloud credentials — they all end up as CI/CD variables in project settings. It works. It’s also a security problem hiding in plain sight.
If you’ve ever wondered who has access to your production database password, or when it was last rotated, or whether that former contractor’s token was actually revoked — you already know why this matters.
The Problem with GitLab CI/CD Variables
GitLab CI/CD variables are convenient. You paste a value into the UI, reference it in your pipeline, and move on. But convenience has costs:
- Plaintext storage. Even “masked” variables are just hidden from job logs. Every project maintainer can read them in Settings > CI/CD > Variables.
- No rotation. Secrets sit unchanged for months or years. When someone leaves the team, do you rotate every variable they had access to? Be honest.
- No audit trail. GitLab doesn’t log who read a variable or when. You have zero visibility into secret access patterns.
- Blast radius. A single compromised maintainer account exposes every secret in every project they touch.
This is not a GitLab-specific flaw — it’s a pattern across CI systems. The fix is to stop treating your CI platform as a secrets store. Use a real one.
sequenceDiagram
participant GL as GitLab CI Job
participant V as Vault
participant S as Secret Engine
GL->>V: Authenticate with JWT
V->>GL: Short-lived token
GL->>V: Read secret
V->>S: Fetch credential
S-->>V: Secret value
V-->>GL: Inject into job
HashiCorp Vault as the External Secrets Backend
Vault is purpose-built for this. It handles secret storage, access control, rotation, dynamic credential generation, and full audit logging. GitLab has native integration with Vault using the JWT authentication method, which means your pipelines can fetch secrets directly from Vault without storing anything in GitLab.
The flow is straightforward:
- GitLab generates a JWT token for each pipeline job.
- The job presents that token to Vault.
- Vault validates the token against GitLab’s JWKS endpoint.
- If the token’s claims match the Vault role’s bound claims (project, branch, etc.), Vault issues a short-lived token.
- The job uses that Vault token to read secrets.
No long-lived credentials stored anywhere. No shared passwords. Every access is authenticated, authorized, and logged.
Step-by-Step Setup
1. Configure the JWT Auth Method in Vault
Enable JWT auth and point it at your GitLab instance:
vault auth enable jwt
vault write auth/jwt/config \
jwks_url="https://gitlab.example.com/-/jwks" \
bound_issuer="https://gitlab.example.com"
2. Create a Vault Policy
Define what secrets the pipeline can access. Be specific — scope it to the paths your project actually needs:
# policy: gitlab-myapp-prod
path "secret/data/myapp/prod/*" {
capabilities = ["read"]
}
path "database/creds/myapp-prod" {
capabilities = ["read"]
}
vault policy write gitlab-myapp-prod gitlab-myapp-prod.hcl
3. Create a JWT Auth Role
Bind the role to your specific GitLab project and branch. This is where you control which pipelines can access which secrets:
vault write auth/jwt/role/myapp-prod - <<EOF
{
"role_type": "jwt",
"policies": ["gitlab-myapp-prod"],
"token_explicit_max_ttl": 300,
"user_claim": "user_email",
"bound_claims": {
"project_id": "42",
"ref": "main",
"ref_type": "branch",
"ref_protected": "true"
}
}
EOF
Note token_explicit_max_ttl: 300. The Vault token lives for five minutes — long enough for a deploy step, short enough to limit damage if something goes wrong.
4. Use the secrets Block in .gitlab-ci.yml
GitLab’s native Vault integration uses a secrets keyword that injects values as file-based variables:
deploy-production:
stage: deploy
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com
secrets:
DATABASE_PASSWORD:
vault:
engine:
name: secret
path: myapp/prod
field: db_password
path: credentials
token: $VAULT_ID_TOKEN
API_KEY:
vault:
engine:
name: secret
path: myapp/prod
field: api_key
path: credentials
token: $VAULT_ID_TOKEN
script:
- echo "Deploying with secrets from Vault"
- ./deploy.sh
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
The id_tokens block tells GitLab to generate a JWT with the specified audience claim. The secrets block maps Vault paths to environment variables. GitLab handles the authentication and injection transparently.
No secrets in project settings. No tokens to rotate manually. No access for anyone who shouldn’t have it.
Dynamic Secrets: The Real Power
Static secrets in Vault are already an improvement. Dynamic secrets are where it gets interesting.
Instead of storing a database password, Vault generates a unique, short-lived credential for each pipeline run. When the pipeline finishes, the credential expires automatically.
Enable the database secrets engine and configure a role:
vault secrets enable database
vault write database/config/myapp-postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="myapp-prod" \
connection_url="postgresql://{{username}}:{{password}}@db.example.com:5432/myapp" \
username="vault_admin" \
password="vault_admin_password"
vault write database/roles/myapp-prod \
db_name=myapp-postgres \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="15m" \
max_ttl="1h"
Now update your .gitlab-ci.yml to use the dynamic credentials path:
secrets:
DATABASE_USERNAME:
vault:
engine:
name: database
path: ""
field: username
path: creds/myapp-prod
token: $VAULT_ID_TOKEN
DATABASE_PASSWORD:
vault:
engine:
name: database
path: ""
field: password
path: creds/myapp-prod
token: $VAULT_ID_TOKEN
Each pipeline gets its own database user with its own password, valid for 15 minutes. If a credential leaks, it’s already expired before anyone can use it. If you need to investigate, Vault’s audit log tells you exactly which pipeline run generated which credential and when.
Audit Trail: Finally, Visibility
Every request to Vault — successful or denied — gets logged. You can see:
- Which pipeline accessed which secret
- The exact timestamp
- The source IP
- Whether the request was allowed or denied
- The token TTL and policies applied
Compare that to GitLab’s CI/CD variables, where you have no idea who read what or when. For compliance, incident response, or just basic security hygiene, this is non-negotiable.
If you’re running Vault in production, make sure audit logging is enabled:
vault audit enable file file_path=/var/log/vault/audit.log
Ship those logs to your SIEM. Set alerts on access anomalies. You now have an actual security posture around your CI/CD secrets.
Putting It Together
This pattern fits naturally into a broader GitOps pipeline workflow — secrets management becomes just another part of the infrastructure-as-code story. If you want a deeper look at Vault’s secrets management capabilities beyond CI/CD, see our post on Vault secrets management.
The migration path is incremental. Start with one project. Move its most sensitive secrets to Vault. Get the JWT auth working. Then expand to dynamic secrets and additional projects. You don’t have to boil the ocean.
Need Help?
At robto, we design and implement Vault integrations for teams running GitLab, Kubernetes, and cloud-native infrastructure. If your CI/CD secrets are still sitting in project variables, let’s fix that. Reach out — we understand it.