GitLab CI/CD: Securing Credentials with Protected Variables and Environment Scopes
Protected variables stop secrets from leaking through unprotected branches. This guide shows how to scope credentials to protected branches and environments in GitLab CI/CD, with verification steps and limitations.
08 Jan 2026, 09:16 UTC

The problem: secrets leaking through unprotected branches
In a typical GitLab project, users with the Developer role can push to feature branches and modify .gitlab-ci.yml. If a CI/CD variable holds a production database password or cloud API key and is not protected, a Developer can add a job that echoes the variable and captures the value. The pipeline runs, the secret appears in the job log, and the credential is compromised.
The useful takeaway: mark the variable as protected so GitLab only injects it into pipelines running on protected branches or protected tags. Combine this with environment scoping so that production credentials are not even visible to staging pipelines, and enable masking so accidental log output is replaced with [MASKED].
Prerequisites
Before you begin, ensure the following:
- You have the Maintainer or Owner role in the project. Only these roles can create protected variables and manage protected branches.
- Protected branches or protected tags already exist, or you are prepared to create them under Settings > Repository > Protected branches. A protected branch typically restricts who can push or merge.
- You know the exact secret value you need to store, and you have a plan to rotate it if it is accidentally exposed.
- You are using a GitLab version where the Settings > CI/CD > Variables UI is available. The exact menu labels can shift between versions; if the path differs, search for "Variables" in the project settings sidebar.
Configuring the variable
Create the variable
- In the left sidebar, select Settings > CI/CD > Variables.
- Select Add variable.
- Enter a key, for example
PROD_DEPLOY_TOKEN. Use uppercase letters, numbers, and underscores. Avoid spaces. - Paste the secret into the Value field.
- Under Flags, select Protect variable. This restricts the variable to pipelines on protected branches or tags.
- Select Mask variable. This tells GitLab to replace the value in job logs with
[MASKED]if it is accidentally printed. - (Optional) Set Environment scope. Enter an environment name such as
productionif the credential should only be available to jobs that define that environment. Leave it as*(all environments) only if the secret is genuinely needed everywhere. - Select Add variable.
A note on masking requirements: GitLab imposes strict constraints on masked values. The value must be at least 8 characters long and cannot contain certain special characters. If your secret does not meet these requirements, masking will fail silently or GitLab will reject the mask flag. Verify the exact character restrictions for your GitLab version before relying on [MASKED] in logs.
Scope to an environment
Environment scoping adds a second layer of separation. Suppose you have two tokens: one for staging and one for production. If you scope the production token to the production environment, a pipeline job that targets staging will not receive it, even if that pipeline runs on a protected branch.
To make this work, the job definition must declare the matching environment:
deploy_staging:
stage: deploy
environment:
name: staging
script:
- ./deploy.sh --token "$STAGING_TOKEN"
deploy_production:
stage: deploy
environment:
name: production
script:
- ./deploy.sh --token "$PROD_DEPLOY_TOKEN"
In this example, STAGING_TOKEN and PROD_DEPLOY_TOKEN are separate protected variables with environment scopes of staging and production, respectively. A pipeline that only runs deploy_staging cannot see the production token.
Verifying the behavior
After saving the variable, run these checks. They require you to push commits and view pipeline results.
- Unprotected branch check: Create or switch to a non-protected branch. Add a temporary job that echoes the variable:
Run the pipeline. The output should showtest_unprotected: script: - echo "Value: $PROD_DEPLOY_TOKEN"Value:followed by nothing, or an empty string. If the secret appears, the variable is not actually protected—revisit the Protect variable flag. - Protected branch check: Merge or push the same configuration to a protected branch. Run the pipeline. The variable should now be injected. If the job uses it for authentication, verify that authentication succeeds.
- Masking check: In a job on the protected branch, intentionally echo the variable. The job log should display
[MASKED]instead of the real value. If the plain text appears, the value likely violates masking requirements (too short, or contains disallowed characters).
Limitations and risks
Protected variables reduce exposure, but they are not a complete secret-management solution.
- Masking is not encryption. Masking only hides exact string matches in logs. A user who intentionally runs
echo "$PROD_DEPLOY_TOKEN" | base64can still exfiltrate the value. Masking does not protect against transformed output. - Push permissions matter. A pipeline triggered by a user who cannot push to the protected branch will not receive the protected variable, even if the pipeline targets a protected branch. This can cause confusing "missing variable" failures.
- Masking constraints. Values under 8 characters or with certain special characters cannot be masked. Short API keys or passwords with symbols may appear in logs in plain text.
- Environment scope is not a security boundary by itself. It controls visibility, but a job on a protected branch with the correct environment name can still access the secret. Use it to reduce blast radius, not as the only control.
Recovery: rotating and removing variables
Changing variable settings alters project state. If you discover that a secret has been exposed—through logs, a misconfigured scope, or an unprotected branch—take these recovery steps:
- Rotate the credential immediately. Masking and protection do not undo exposure. Generate a new token or password at the source system.
- Update the variable. In Settings > CI/CD > Variables, edit the existing variable and paste the new value. Pipelines will use the new value on the next run.
- Remove the variable if necessary. If the credential is no longer needed, delete the variable. Any pipeline that references it will fail with an empty value, which is the expected safe failure mode.
- Audit recent job logs. Search for the old secret string in completed pipelines. If found, treat the exposure window as lasting until the secret was rotated.
Final check
After configuration, the practical way to confirm success is to run the three checks above: an unprotected branch returns null, a protected branch receives the value, and a masked log shows [MASKED]. If all three pass, the credential is correctly restricted to the intended pipeline contexts.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.