← Back to blog
GitLabVaultSecurityCI/CD

GitLab CI/CD with HashiCorp Vault: Stop Storing Secrets in CI Variables

· 6 min read

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:

  1. GitLab generates a JWT token for each pipeline job.
  2. The job presents that token to Vault.
  3. Vault validates the token against GitLab’s JWKS endpoint.
  4. If the token’s claims match the Vault role’s bound claims (project, branch, etc.), Vault issues a short-lived token.
  5. 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.