Stop leaking secrets in CI logs: masking and OIDC
Mask variables, avoid echoing secrets, and move to short-lived OIDC tokens so a leaked log isn't a breach.
A CI secret rarely leaks through the attack people imagine. It leaks because a debugging line prints the environment, because set -x expands it into the job log, because a job uploads a config file that contains it as an artifact, or because a pipeline on an untrusted branch was handed the same variables as the release pipeline. Each channel has its own control, and none of the controls is the masking checkbox alone.
Leak channels and the control that closes each one
| Channel | What leaks | Control |
|---|---|---|
Job log output (echo, env, error messages) | the raw value | masked variable; better, a value the job never prints |
Shell tracing (set -x, CI_DEBUG_TRACE) | every expanded command line | no tracing in credential jobs |
| Uploaded artifacts and cache | files that embed the value | masking does not apply here; keep secrets out of files, review artifact paths |
| Command-line arguments | visible in ps, /proc, some debug output | file type variables, stdin, env read by the tool |
| Untrusted pipelines (feature branches, forks) | the whole variable set | protected variables, protected branches |
| A stored long-lived key | everything the key can do, forever | an ID token exchanged per job instead of a stored key |
Masking is a log filter with rules
GitLab replaces a masked value with [MASKED] in job logs and nowhere else. The value has to be a single line of at least 8 characters and must not equal the name of another variable, and a value the shell prints in an escaped form (My\[value\] for My[value]) slips through unmasked. Since GitLab 18.3 new variables default to Masked; Masked and hidden additionally stops the value from ever being revealed in the settings UI again, and can only be chosen when the variable is created. GitHub masks values registered as secrets and anything a step passes to ::add-mask::; the same limits apply, and a secret transformed before printing (base64, split, uppercased) is not recognised.
echo "token is $API_TOKEN"token is [MASKED]echo "$API_TOKEN" | base64c2VjcmV0LXRva2VuLTQyCg==the encoded form does not match the registered value, so it is printed in fullGitLab's own documentation says masking is not a guaranteed protection and points at two stronger habits: file type variables, which write the value to a temporary file and hand the job the path, so env and printenv show a filename rather than the secret; and external secret stores. A tool that reads its credential from a file or from stdin also keeps it out of ps output, which is where a value passed as --password … ends up.
Scope: which pipelines see the variable at all
A protected variable is only injected into pipelines running on protected branches and tags, so a feature branch (or, on GitHub, an environment without the required reviewers) never has it in the environment to leak. Protected is the setting to audit first when a variable is production-grade: a masked but unprotected deploy token is available to any branch anyone can push, and masking does not stop a job from using the value, only from printing it. Fork merge request pipelines run in the fork project with the fork's variables by default, which is the isolation you want; the setting that runs them in the parent project is the one to review before enabling.
# Settings -> CI/CD -> Variables# DEPLOY_TOKEN Visibility: Masked and hidden [x] Protect variable Type: File# AWS_ROLE_ARN Visibility: Visible (not a secret: an ARN, used with an ID token)deploy_prod:rules:- if: $CI_COMMIT_BRANCH == "main" # a protected branch, so DEPLOY_TOKEN is presentscript:- deploy-tool --token-file "$DEPLOY_TOKEN" # file variable: the path, never the value
The secret that cannot leak is the one that is never stored
For cloud credentials the stronger move is to stop storing a key at all. GitLab's id_tokens keyword makes the job request a signed JWT with an aud you choose; GitHub issues one when the job has id-token: write. The cloud side (AWS STS, GCP Workload Identity Federation, Azure federated credentials, or HashiCorp Vault's JWT auth) validates the token and returns a credential that lives for minutes and is bound to the repository, ref or environment named in the token. A log that captures it captures something already expired by the time anyone reads it. GitHub Actions to AWS with OIDC walks the AWS trust policy in detail; the GitLab shape is below.
# GitLab: request an ID token for AWS, exchange it in the jobdeploy:id_tokens:AWS_TOKEN:aud: https://sts.amazonaws.comscript:- >aws sts assume-role-with-web-identity--role-arn "$AWS_ROLE_ARN" --role-session-name "gitlab-$CI_JOB_ID"--web-identity-token "$AWS_TOKEN" --duration-seconds 900# GitHub: the action performs the same exchangepermissions:id-token: writecontents: readsteps:- uses: aws-actions/configure-aws-credentials@v6with:role-to-assume: arn:aws:iam::111122223333:role/github-deployaws-region: eu-west-1
Audit the pipelines you already have
grep -rnE "set -x|CI_DEBUG_TRACE|CI_DEBUG_SERVICES" .gitlab-ci.yml .github/workflows/ ci/ci/deploy.sh:3:set -xgrep -rnE "(--password|--token|-p) ?\$" .gitlab-ci.yml ci/ci/publish.sh:12:twine upload --password $PYPI_TOKEN dist/*grep -rnE "artifacts:" -A4 .gitlab-ci.yml | grep -E "\.env|config\.(yml|yaml|json)".gitlab-ci.yml:48: - config/app.ymltrace on a credential job, a token on a command line, a config file uploaded as an artifact: three separate fixesThe order of work is the order of the table: remove tracing and command-line secrets first because they leak on every run, then fix scope, then replace stored cloud keys with ID tokens. The last step is the one that makes the previous ones matter less, and it is the one Software supply chain security builds on for keyless signing, where the same job identity signs what it deployed.