Secrets Management

Learn how to store, protect, and retrieve secrets in GitLab CI/CD. From masked variables to external secret managers, build a secure credential workflow for your pipelines.

CI/CD Variables External Secrets GitLab
Why Secrets Management Matters

Every pipeline needs credentials: API tokens to push images, database passwords to run migrations, SSH keys to deploy to servers, and cloud provider keys to provision infrastructure. These secrets are the keys to your kingdom. If a secret leaks — through a log, a misconfigured variable, or a compromised dependency — an attacker gains the same access your pipeline has. The Axios npm package compromise in March demonstrated exactly this risk: organizations running a poisoned version had to assume every credential their pipelines touched was in an attacker's hands [citation:8].

GitLab offers a spectrum of options for handling secrets, from simple CI/CD variables to dedicated secrets managers. The right choice depends on sensitivity. Non-sensitive configuration — a log level, a feature flag name — can live in plain CI/CD variables. Sensitive credentials should be stored in a secrets manager, where access is scoped, audited, and revocable [citation:1]. The challenge is that many teams default to CI/CD variables for everything because they're convenient, not because they're secure.

Understanding the layers of protection available for CI/CD variables — masking, hiding, protection, and environment scoping — lets you use them safely for moderately sensitive data. For the most sensitive credentials, GitLab's native integrations with external providers (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager) and the new GitLab Secrets Manager provide the strict access controls and audit trails that production security requires [citation:2].

Core Principle: Use CI/CD variables for non-sensitive configuration. Use masked and protected variables for moderately sensitive data. Use a dedicated secrets manager for credentials that would cause harm if exposed.

CI/CD Variables

Convenient but less secure. Stored in project/group settings. Suitable for non-sensitive data or when combined with masking and protection.

Masked Variables

Hidden in job logs. Prevents accidental exposure. Does not prevent access by malicious scripts.

Protected Variables

Only available on protected branches and tags. Prevents feature branches from accessing production secrets.

External Secrets

HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager. Secrets stored outside GitLab with strict access controls.

GitLab Secrets Manager

Native GitLab feature (Beta). Secrets scoped to specific jobs, environments, and branches. Built on OpenBao.

ID Tokens

JWT tokens for authenticating with external secrets providers. Enable keyless authentication to cloud providers.
CI/CD Variables: The Foundation

CI/CD variables are the simplest way to pass configuration and credentials to jobs. They are key-value pairs defined at the project, group, or instance level, and they're injected into every job that runs in that scope. A variable defined at the group level is available to all projects in the group; a project-level variable is available only to that project. Variables can also be defined directly in .gitlab-ci.yml, but this is only appropriate for non-sensitive values since the file is version-controlled [citation:1].

The convenience of variables is also their risk. A variable defined at the project level is available to every job, regardless of whether that job needs it. A test job that runs on a feature branch has the same access to a production database password as the deploy job — unless you use protection. This violates the principle of least privilege, and it's why GitLab provides masking, protection, and environment scoping as layers of defense.

Variables can also be overridden at the pipeline level (via the UI or API when triggering a pipeline), which makes it difficult to determine after the fact which value was actually used. And a misconfigured pipeline — for example, a script that accidentally echoes a variable — can expose the value in job logs. These risks don't mean variables are unusable, but they do mean variables alone are insufficient for highly sensitive credentials [citation:1].

# Define variables in .gitlab-ci.yml (non-sensitive only) variables: NODE_VERSION: "18" BUILD_ENV: "production" LOG_LEVEL: "info" # Variables are available in all jobs build: script: - echo "Building with Node $NODE_VERSION" - npm run build # Job-level variables override global ones test: variables: NODE_ENV: "test" script: - npm test # Variable precedence (highest to lowest): # 1. Pipeline variables (UI/API trigger) # 2. Project variables (UI) # 3. Group variables (UI) # 4. Instance variables (UI) # 5. .gitlab-ci.yml job-level variables # 6. .gitlab-ci.yml global variables # 7. Predefined variables # Never commit secrets to .gitlab-ci.yml # BAD: # variables: # DATABASE_PASSWORD: "supersecret123" # GOOD: Define in GitLab UI, not in code # Settings > CI/CD > Variables > Add Variable
Variable Risks:
  • Scope creep: Variables are injected into every job unless scoped
  • Overridability: Pipeline variables can override UI variables, making auditing difficult
  • Log exposure: A misconfigured script can print a variable's value
  • Settings access: Anyone with Maintainer access to project settings can view non-masked variables
Masked Variables

Masking is the first line of defense for sensitive variables. When a variable is masked, GitLab replaces its value with [MASKED] in job logs. If a script accidentally echoes the variable — or if a tool prints its environment — the actual value never appears in the log. This prevents the most common cause of secret leaks: accidental logging [citation:1].

Masking has requirements and limitations. The value must be at least 8 characters and cannot contain newlines. These constraints exist because masking works by pattern-matching the value in log output; short values would produce too many false positives, and multi-line values cannot be reliably masked. Masking is also not a complete defense: a malicious script can encode the value (base64, hex) and print the encoded form, bypassing the mask. Masking protects against accidents, not against a determined attacker who already has code execution in your pipeline [citation:17].

For variables that are both masked and hidden, GitLab adds another layer: the value is not displayed in the UI at all, even to users with Maintainer access. The masked_and_hidden attribute (available via the API) creates a variable whose value cannot be retrieved through the UI or API — only the job that uses it sees the value [citation:3]. This is the most secure form of variable storage within GitLab's native system, short of using a dedicated secrets manager.

# Masked variable configuration (UI) # Settings > CI/CD > Variables # # Key: DATABASE_PASSWORD # Value: supersecretpassword123 # Masked: ✓ # Protected: ✓ (recommended) # Environment scope: production (recommended) # Using masked variable in a job deploy_production: stage: deploy script: - echo "Connecting with password $DATABASE_PASSWORD" # Log shows: Connecting with password [MASKED] - ./deploy.sh environment: name: production rules: - if: $CI_COMMIT_BRANCH == "main" # Masking requirements: # - Minimum 8 characters # - No newlines # - Must be a single line # - Cannot contain certain special characters # Masking limitations: # - Does not prevent script access to the value # - Can be bypassed by encoding the value # - Does not protect against malicious pipeline code # Masked + Hidden (API only, GitLab 17.4+) # Variable value is not displayed in UI at all # Only the job that uses it can see the value curl --request POST \ --header "PRIVATE-TOKEN: " \ --url "https://gitlab.example.com/api/v4/projects/1/variables" \ --form "key=DATABASE_PASSWORD" \ --form "value=supersecret123" \ --form "masked=true" \ --form "hidden=true" \ --form "protected=true"
Masking Best Practices: