Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

GitLab CI: manage secrets without exposing them

By updated

Improve this page

A GitLab CI pipeline that deploys to production necessarily handles secrets: API tokens, SSH keys, Docker registry credentials, database passwords. GitLab offers four mechanisms to protect these values, but none is enabled by default. This guide shows you how to configure each layer of protection, avoid the classic pitfalls and plug in an external secrets manager when native variables are no longer enough.

What you will learn

  • Tell the 4 types of variables CI/CD apart (standard, protected, masked, file) and their limits
  • Configure protected + masked secrets to prevent exposure in logs and on unprotected branches
  • Use file variables for SSH keys and certificates
  • Integrate HashiCorp Vault for dynamic secrets with a limited lifetime
  • Detect secret leaks with GitLab Secret Detection and gitleaks
  • Set up a rotation strategy that does not interrupt pipelines

Prerequisites

This lesson assumes that you have read the seven attacks that target secrets, that you know what a CI/CD variable is and how a job reads it, and that you have already built an image in a pipeline. The Developer role is not enough: creating or editing a variable in Settings > CI/CD > Variables requires the Maintainer role on the project (with a Developer role, the variables endpoint answers 403 Forbidden), and the API calls below need a token with the api scope. A group or instance variable needs even higher rights. The Vault part also assumes a Premium or Ultimate edition of GitLab and administrator access to a Vault server, to enable JWT authentication and write a policy.

On the tooling side, you need curl and jq to query the API, the vault CLI for the Vault side, and gitleaks for the local test. The final lab also requires DSOXLab. Upstream lessons:

In which context?

You administer a GitLab CE instance on KVM with one or more runners. Your pipelines do Docker builds, Ansible deployments or Terraform provisioning, all cases where credentials pass through the jobs. You want to guarantee that:

  • a developer cannot read a production secret in a feature branch;
  • a secret is never displayed in clear text in the job logs;
  • a stolen token has a limited lifetime;
  • a leak is detected automatically before the merge.

The 4 protection levels of GitLab CI variables

GitLab does not offer a single switch but four independent mechanisms that you combine: the Protected flag limits the branches where the variable is injected, the Masked flag filters its display in the logs, the File type keeps it from passing through an environment variable, and the environment scope restricts the jobs concerned. None is active by default, and none is enough on its own. The four subsections below start from the standard variable, which has none of these protections, then detail what each of the three flags guarantees, and above all what it does not guarantee. The environment scope is set in step 3 of the next section.

Standard variables (unprotected)

By default, a CI/CD variable is injected into all jobs, on all branches, and appears in clear text in the logs if an echo or a script references it.

.gitlab-ci.yml: exposed variable
deploy:
script:
- echo "Deploying with token $DEPLOY_TOKEN"
# The token appears IN CLEAR TEXT in the job logs

This is the most dangerous behavior, and it is the default one.

Protected variables (Protected)

A variable flagged Protected is injected only into jobs that run on protected branches or protected tags.

Check protected variables via the API
curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/variables" \
| jq '.[] | select(.protected == true) | {key, protected, masked}'
Expected result
{
"key": "DEPLOY_TOKEN",
"protected": true,
"masked": true
}

Masked variables (Masked)

A variable flagged Masked is replaced by [MASKED] in the job logs. GitLab enforces constraints on the value:

  • At least 8 characters
  • No line break
  • Made up only of Base64 or URL-safe characters

Multi-line SSH keys and PEM certificates cannot be masked, so use a file variable for these cases.

File variables (File)

Instead of injecting the value into the environment, GitLab writes the content to a temporary file and exposes the path through the variable.

.gitlab-ci.yml: SSH key via a file variable
deploy:
script:
- chmod 600 "$SSH_PRIVATE_KEY"
- ssh -i "$SSH_PRIVATE_KEY" deploy@prod.example.com "systemctl restart app"

The $SSH_PRIVATE_KEY variable contains the path /tmp/gitlab_ci_var_xxx, not the key itself. The key itself is not injected into the environment and does not appear in the logs, even without masking, unless a command prints the file (a cat "$SSH_PRIVATE_KEY", for example).

Configure your secrets correctly

Here is the procedure to apply to every secret, in order. It combines the mechanisms seen above: Protected and Masked systematically, the File type whenever a value contains line breaks, and a scope limited to the relevant environment. The last step, often neglected, is to check in the logs that no value leaked before these settings were put in place.

  1. Flag every secret as Protected + Masked

    In Settings > CI/CD > Variables:

    • Check Protected: the secret is injected only on protected branches/tags
    • Check Masked: the value is replaced by [MASKED] in the logs
    • Choose the scope: project, group or instance
    Create a protected + masked variable via the API
    curl --request POST \
    --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \
    --form "key=DEPLOY_TOKEN" \
    --form "value=glpat-xxxxxxxxxxxxxxxxxxxx" \
    --form "protected=true" \
    --form "masked=true" \
    "https://gitlab.example.com/api/v4/projects/$PROJECT_ID/variables"
  2. Use file variables for keys and certificates

    In Settings > CI/CD > Variables, select Type: File for:

    • SSH keys (id_rsa, id_ed25519)
    • TLS certificates (.pem, .crt)
    • Sensitive configuration files (kubeconfig, .env)
  3. Restrict the environments

    For each variable, specify the target environment:

    • production: only the jobs deploying to prod
    • staging: only the jobs deploying to pre-production
    • *: all environments (avoid for critical secrets)
  4. Check that nothing leaks in the logs

    Look for exposed tokens in a pipeline's logs
    curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \
    "https://gitlab.example.com/api/v4/projects/$PROJECT_ID/jobs/$JOB_ID/trace" \
    | grep -iE 'glpat-|ghp_|AKIA|sk-|token|password|secret'

    If the command returns results, you have a leak. Revoke the token immediately.

Integrate HashiCorp Vault for dynamic secrets

When native CI/CD variables are no longer enough, because you need secrets with a limited lifetime, automatic rotation or fine-grained traceability, Vault is the recommended integration.

Enable the Vault integration in GitLab

GitLab CI supports Vault natively through the secrets: keyword. The mechanism relies on an ID token: GitLab issues a signed JWT for the job (the id_tokens: block), then uses it to authenticate against Vault, which checks the signature and the claims before returning the secret.

.gitlab-ci.yml: native Vault secrets with id_tokens
deploy:
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com
secrets:
DATABASE_PASSWORD:
vault: production/db/password@secret
token: $VAULT_ID_TOKEN
script:
- echo "The password is injected into $DATABASE_PASSWORD"
- ansible-playbook deploy.yml

The id_tokens: block declares the token and its audience (aud), which must match the one Vault expects. The short notation vault: production/db/password@secret reads as path/to/secret/field@engine: here the password field of the production/db secret in the secret engine. The token: field is optional as soon as the job defines an id_tokens: the secrets keyword then uses it automatically, but writing it makes the link explicit.

Configure JWT authentication in Vault

On the Vault side, the configuration happens in three steps: enable the JWT authentication method, write a policy that describes the readable paths, then create a role that binds this policy to precise conditions on the calling pipeline. The role carries the real security: without a restriction on the project and on branch protection, any pipeline of the instance could obtain the secret.

  1. Enable JWT auth in Vault

    Vault configuration
    vault auth enable jwt
    vault write auth/jwt/config \
    jwks_url="https://gitlab.example.com/-/jwks" \
    bound_issuer="https://gitlab.example.com"

    An ID token carries the instance URL, https:// scheme included, in its iss claim: bound_issuer must therefore repeat it identically.

  2. Create a policy for the pipeline

    policy-deploy.hcl
    path "secret/data/production/*" {
    capabilities = ["read"]
    }
    Terminal window
    vault policy write deploy policy-deploy.hcl
  3. Create a role bound to the GitLab project

    Vault role bound to the project
    vault write auth/jwt/role/deploy - <<'EOF'
    {
    "role_type": "jwt",
    "policies": "deploy",
    "token_explicit_max_ttl": 300,
    "bound_audiences": "https://vault.example.com",
    "bound_claims": { "project_id": "42", "ref_protected": "true" },
    "user_claim": "user_email"
    }
    EOF

    The bound_audiences field must repeat the aud value of the id_tokens: block. With Vault 1.17 and later, it is required as soon as the token carries an aud claim, which is always the case for an ID token.

    The bound_claims restricts access to the pipelines of project 42, on protected branches only, provided that Vault actually recorded it. Check it with vault read auth/jwt/role/deploy: the bound_claims line must display map[project_id:42 ref_protected:true]. The 5-minute TTL limits the window of exploitation in case of theft.

Detect secret leaks

Protecting variables is pointless if a developer commits an API key in the code. Detection happens in two places: in the repository, by analyzing commits for known patterns (glpat-, AKIA, PEM private keys), and in the job logs, where an unlucky echo can expose an unmasked value. Two tools cover the first case, one built into GitLab, the other open source and usable on any edition.

GitLab Secret Detection (SAST)

GitLab embeds a secret scanner in CI/CD pipelines. It detects tokens, API keys and passwords committed in the code.

.gitlab-ci.yml: enable Secret Detection
include:
- template: Jobs/Secret-Detection.gitlab-ci.yml

The report appears in Security & Compliance > Vulnerability Report (GitLab Ultimate) or in the job artifacts (GitLab CE).

gitleaks as an open source alternative

For GitLab CE instances without the Ultimate license:

.gitlab-ci.yml: gitleaks in the pipeline
secret-scan:
stage: test
image:
name: ghcr.io/gitleaks/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
entrypoint: [""]
script:
- gitleaks detect --source . --report-format sarif --report-path gitleaks.sarif
artifacts:
when: always
paths:
- gitleaks.sarif
allow_failure: false

Two details of this job decide whether it produces anything. The gitleaks image declares an ENTRYPOINT (gitleaks), while the runner expects an image with no entrypoint or one that starts a shell: entrypoint: [""] neutralizes it so the script: block runs. Then artifacts:when: always keeps the report even when the job fails, which is exactly the case where gitleaks found a leak (exit code 1).

Here the report is a plain downloadable artifact. The artifacts:reports:sast keyword expects GitLab's own JSON format, not SARIF; ingesting a SARIF report into the Security tab goes through artifacts:reports:sarif, which is GitLab Ultimate only (generally available in 19.2), so it is of no use on a CE instance.

Test locally before pushing
gitleaks detect --source . --verbose
Expected result (no leak)
○
INFO no leaks found

Rotate secrets without interruption

Rotating a CI/CD secret without cutting the running pipelines follows this process:

  1. Create the new value under a temporary name

    In Settings > CI/CD > Variables, create DEPLOY_TOKEN_V2 with the new value (Protected + Masked).

  2. Update the pipelines to accept both

    .gitlab-ci.yml: dual token source
    deploy:
    script:
    - TOKEN="${DEPLOY_TOKEN_V2:-$DEPLOY_TOKEN}"
    - curl -H "Authorization: Bearer $TOKEN" https://api.example.com/deploy
  3. Check that the new pipelines use v2

    Run a pipeline on the protected branch and check in the logs that the connection works.

  4. Delete the old secret and rename

    Delete DEPLOY_TOKEN, then recreate DEPLOY_TOKEN with the value of DEPLOY_TOKEN_V2. Delete DEPLOY_TOKEN_V2.

  5. Revoke the old token on the provider side

    Depending on the service: GitLab (revoke the PAT), AWS (deactivate the access key), Docker Hub (revoke the token).

Troubleshooting

Half of these symptoms are not an incident but the expected behavior of a well-configured protection: a Protected variable missing from a feature branch is exactly what you asked for. Before disabling a setting to "unblock" a pipeline, check in this table that you are not removing the protection that is doing its job.

SymptomLikely causeSolution
Secret visible in clear text in the logsVariable not flagged MaskedCheck Masked in Settings > CI/CD > Variables
Variable missing on a feature branchVariable flagged Protected, branch not protectedNormal, this is the expected behavior. Check the list of protected branches
Masked variable cannot be set (error)Value < 8 characters or contains line breaksUse a file variable for multi-line keys
Vault job fails with a 401 errorid_tokens: block missing: CI_JOB_JWT and CI_JOB_JWT_V2 have been removed since GitLab 17.0Declare id_tokens: with an aud identical to bound_audiences on the Vault side
Vault job fails with permission deniedbound_claims does not match the project or the branchCheck project_id and ref_protected in the Vault role
Secret Detection finds nothingTemplate not included or image not availableCheck the Jobs/Secret-Detection.gitlab-ci.yml include and registry access
Token rotated, old pipelines failThe old token was deleted before the pipelines finishedUse the dual-token strategy described above

Hands-on lab

The lab "Secrets renewed without breaking the pipeline" replays the three gestures of this guide on a GitLab CE forge of your own (a GitLab server you host yourself), set up on a virtual machine with its runner. Its starting project has a green pipeline even though its service token is neither protected nor masked, and its SSH deploy key, an ordinary variable, is copied by hand into a file by the job that installs it.

You protect and mask the token, you turn the key into a file-type variable while adapting the job without breaking it, then you renew the token without interrupting the service: the new one first, a pipeline that proves it works, and only then the revocation of the old one. Grading is taken on the state the forge holds, variable settings, active and revoked tokens, executed jobs and their logs, never on the content of .gitlab-ci.yml: the log of the job that installs the key must still show CLE-SOURCE=fichier, and the job that calls the API must never have failed.

The lab starts with dsoxlab run gl-20-secrets-avances, after dsoxlab provision which prepares the virtual machine and its runner. The DSOXLab installation and its prerequisites are described in the DSOXLab documentation.

Key takeaways

  • GitLab CI/CD variables are neither protected nor masked by default, the initial configuration is the most dangerous
  • Always combine Protected + Masked for real secrets, and use file variables for SSH keys and certificates
  • Protected variables are injected only on protected branches and tags, this is the first line of defense
  • The secrets: keyword with Vault relies on an ID token declared by id_tokens: (mandatory since GitLab 17.0, Premium and Ultimate) and provides ephemeral tokens with a short TTL, limiting the window of exploitation
  • gitleaks in CI/CD detects committed secrets before the merge, even on GitLab CE without an Ultimate license
  • Safe rotation relies on a dual-token scheme: create the new value, migrate the pipelines, delete the old one

Sources

Test your knowledge

This quiz covers the distinctions that cost dearly in production: what Protected guarantees compared to Masked, the format constraints of a masked value, and the role of bound_claims in a Vault role.

Knowledge check

Check what you have learned with this interactive quiz

10 questions
5 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn