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:
- 7 attacks on your pipelines: the five vectors that target secrets
- CI/CD variables: define a variable and read it in a job
- GitLab registries: the registry credentials that jobs handle
- Security scanners: the Secret Detection template, shown here in the context of leaks
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.
deploy: script: - echo "Deploying with token $DEPLOY_TOKEN" # The token appears IN CLEAR TEXT in the job logsThis 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.
curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \ "https://gitlab.example.com/api/v4/projects/$PROJECT_ID/variables" \ | jq '.[] | select(.protected == true) | {key, protected, masked}'{ "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.
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.
-
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" -
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)
- SSH keys (
-
Restrict the environments
For each variable, specify the target environment:
production: only the jobs deploying to prodstaging: only the jobs deploying to pre-production*: all environments (avoid for critical secrets)
-
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.
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.ymlThe 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.
-
Enable JWT auth in Vault
Vault configuration vault auth enable jwtvault 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 itsissclaim:bound_issuermust therefore repeat it identically. -
Create a policy for the pipeline
policy-deploy.hcl path "secret/data/production/*" {capabilities = ["read"]}Terminal window vault policy write deploy policy-deploy.hcl -
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"}EOFThe
bound_audiencesfield must repeat theaudvalue of theid_tokens:block. With Vault 1.17 and later, it is required as soon as the token carries anaudclaim, which is always the case for an ID token.The
bound_claimsrestricts access to the pipelines of project 42, on protected branches only, provided that Vault actually recorded it. Check it withvault read auth/jwt/role/deploy: thebound_claimsline must displaymap[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.
include: - template: Jobs/Secret-Detection.gitlab-ci.ymlThe 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:
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: falseTwo 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.
gitleaks detect --source . --verbose○ INFO no leaks foundRotate secrets without interruption
Rotating a CI/CD secret without cutting the running pipelines follows this process:
-
Create the new value under a temporary name
In Settings > CI/CD > Variables, create
DEPLOY_TOKEN_V2with the new value (Protected + Masked). -
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 -
Check that the new pipelines use v2
Run a pipeline on the protected branch and check in the logs that the connection works.
-
Delete the old secret and rename
Delete
DEPLOY_TOKEN, then recreateDEPLOY_TOKENwith the value ofDEPLOY_TOKEN_V2. DeleteDEPLOY_TOKEN_V2. -
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.
| Symptom | Likely cause | Solution |
|---|---|---|
| Secret visible in clear text in the logs | Variable not flagged Masked | Check Masked in Settings > CI/CD > Variables |
| Variable missing on a feature branch | Variable flagged Protected, branch not protected | Normal, this is the expected behavior. Check the list of protected branches |
Masked variable cannot be set (error) | Value < 8 characters or contains line breaks | Use a file variable for multi-line keys |
Vault job fails with a 401 error | id_tokens: block missing: CI_JOB_JWT and CI_JOB_JWT_V2 have been removed since GitLab 17.0 | Declare id_tokens: with an aud identical to bound_audiences on the Vault side |
Vault job fails with permission denied | bound_claims does not match the project or the branch | Check project_id and ref_protected in the Vault role |
| Secret Detection finds nothing | Template not included or image not available | Check the Jobs/Secret-Detection.gitlab-ci.yml include and registry access |
| Token rotated, old pipelines fail | The old token was deleted before the pipelines finished | Use 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 byid_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
- GitLab CI/CD Variables, Official documentation
- Using external secrets in CI, GitLab Docs
- Vault JWT/OIDC Auth Method, HashiCorp
- GitLab Secret Detection, GitLab Docs
- gitleaks, Open source secret scanner
- OWASP Top 10 CI/CD, CICD-SEC-02: Inadequate Identity and Access Management, OWASP
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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: GitLab CI: protected branches, environments and approvals
A new full quiz with random questions
Work again only on the questions you missed