CoursesSecure CI/CD with GitLabSecuring the pipeline itself

CI/CD variables, masking & secret managers

Secrets that do not leak into logs or forks.

Advanced14 min · lesson 7 of 17

Pipelines need secrets, and GitLab’s CI/CD variables are the built-in home for them — set in project settings, injected as environment variables into jobs, and (crucially) never committed to the repo. But the defaults are not enough on their own: a variable must be marked masked so it is hidden in job logs, and protected so only protected-ref pipelines can read it. Both flags, on every sensitive variable, is the baseline.

Two flags every secret variable needs
masked
hidden in job logs
the value shows as [MASKED]
protected
only on protected refs
forks/feature branches cannot read it
Masked stops accidental log leaks; protected stops untrusted pipelines from reading it. You want both.

Masking is limited — do not fight it

Masking replaces the exact secret string with [MASKED] in logs, but like Jenkins it is best-effort: it only catches the literal value, so a secret that is base64-encoded, split, or transformed before printing slips through. It also has format constraints (minimum length, no newlines). Treat masking as a safety net, not a strategy — never deliberately echo a secret, and never write it to a file that gets artifacted or cached.

.gitlab-ci.yml
deploy:
script:
# GOOD: pass the masked variable straight to the tool via stdin/env
- echo "$REGISTRY_TOKEN" | docker login -u ci --password-stdin registry.acme.internal
# BAD: any of these defeat masking / leak the secret
# - echo "$REGISTRY_TOKEN" # printed (masking may still catch it)
# - echo "$REGISTRY_TOKEN" | base64 # transformed — masking misses it
# - env > debug.txt # writes the secret to a file

The better pattern: short-lived, external secrets

A stored CI variable is a static, long-lived secret — better than a committed one, but still a standing credential. The stronger pattern fetches secrets at run time from a manager (HashiCorp Vault, or a cloud secrets manager) so they are short-lived and centrally rotated, or uses OIDC: the job presents its GitLab identity to the cloud/Vault and receives a short-lived token, with no long-lived secret stored in GitLab at all. GitHub Actions and Jenkins have the same OIDC pattern — it is the modern default.

.gitlab-ci.yml
deploy:
id_tokens:
VAULT_ID_TOKEN: { aud: https://vault.acme.internal }
script:
- export VAULT_TOKEN="$(vault write -field=token auth/jwt/login role=ci jwt=$VAULT_ID_TOKEN)"
- vault kv get -field=password secret/registry # short-lived, audited, nothing stored in GitLab
Rotate, scope, and never trust a printed secret
Every secret in CI should be scoped to the fewest jobs, marked masked + protected, and rotated on a schedule (and immediately if a pipeline log or fork could have leaked it). Prefer OIDC/short-lived tokens so there is little to leak in the first place. A long-lived, broadly-scoped, unmasked variable is the single most common and most damaging CI mistake.