Why GitLab CI Security Matters
GitLab CI/CD pipelines are powerful — but with power comes risk. A misconfigured variable can leak secrets. An unscoped runner can execute malicious code. An unprotected environment can let a junior developer push straight to production. This cheat sheet gives you copy-paste YAML for every critical GitLab CI security control, from protected variables to OIDC federation.
Bookmark this page. Use it as your go-to reference every time you configure a new project or audit an existing one.
1. Protected, Masked, and Hidden Variables
GitLab CI/CD variables control how secrets flow into your pipelines. Getting this wrong is the number-one cause of credential leaks in CI/CD. Every sensitive value should be protected (only available on protected branches/tags), masked (hidden from job logs), and where supported, hidden (invisible in the UI after creation).
Setting Variables via the API
# Create a protected + masked variable via the GitLab API
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/variables" \
--form "key=AWS_SECRET_ACCESS_KEY" \
--form "value=$MY_SECRET" \
--form "protected=true" \
--form "masked=true" \
--form "variable_type=env_var"
Using Variables in .gitlab-ci.yml
variables:
# Group or project-level variables are injected automatically.
# File-type variables are written to a temp path.
DEPLOY_TOKEN:
description: "Token for deploying to production"
value: "" # Intentionally blank — set in CI/CD Settings
deploy_production:
stage: deploy
script:
- echo "Deploying with masked token..."
- ./deploy.sh --token "$DEPLOY_TOKEN"
rules:
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production
Key rules:
- Never hardcode secrets in
.gitlab-ci.yml— always use CI/CD variable settings. - Set
protected=trueso secrets are only available on protected branches. - Set
masked=trueso values are redacted from job logs. - Use group-level variables for secrets shared across projects (e.g., cloud credentials).
2. Runner Types and Scoping
Runners execute your CI/CD jobs. If any runner can pick up any job, a malicious merge request could exfiltrate secrets from a production runner. Proper scoping is essential.
Runner Registration with Tags and Protection
# Register a runner scoped to protected branches only
gitlab-runner register \
--non-interactive \
--url "https://gitlab.example.com" \
--token "$RUNNER_REG_TOKEN" \
--executor docker \
--docker-image alpine:latest \
--tag-list "production,protected" \
--access-level ref_protected
Scoping Jobs to Specific Runners
# .gitlab-ci.yml — ensure production jobs only run on protected runners
deploy_production:
stage: deploy
tags:
- production
- protected
script:
- kubectl apply -f k8s/production/
rules:
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production
# Development jobs use a separate, unprivileged runner
test:
stage: test
tags:
- shared
- development
script:
- pytest tests/
Best practices:
- Use
--access-level ref_protectedto restrict runners to protected branches and tags. - Use project-specific runners for sensitive workloads — never share production runners across unrelated projects.
- Prefer ephemeral runners (Docker, Kubernetes executors) so the environment is destroyed after each job.
- Disable shared runners on projects that handle sensitive deployments.
3. Protected Environments with Approvals
Protected environments add a human gate before deployments. This is your last line of defense against unauthorized production changes.
Environment Configuration in .gitlab-ci.yml
# .gitlab-ci.yml — deployment with environment protection
deploy_staging:
stage: deploy
script:
- ./deploy.sh staging
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy_production:
stage: deploy
script:
- ./deploy.sh production
environment:
name: production
url: https://example.com
deployment_tier: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: false
Setting Up Approval Rules via the API
# Protect the production environment with required approvals
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/protected_environments" \
--data '{"name": "production", "deploy_access_levels": [{"group_id": 9899826}], "required_approval_count": 2, "approval_rules": [{"group_id": 9899826, "required_approvals": 2}]}'
Configure this under Settings > CI/CD > Protected Environments in the GitLab UI. Require at least two approvals for production deployments. Restrict deploy access to specific groups or users — never “All maintainers.”
4. CI_JOB_TOKEN Scoping
CI_JOB_TOKEN is an automatic token GitLab injects into every job. By default, it can access other projects in your group — a serious lateral movement risk. Since GitLab 16.0, you should restrict its scope.
Restricting Token Access
# Check current CI_JOB_TOKEN access scope
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/job_token_scope"
# Limit CI_JOB_TOKEN to only access specific projects
curl --request PATCH \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/job_token_scope" \
--data '{"enabled": true}'
# Add an allowlisted project
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/job_token_scope/allowlist" \
--data '{"target_project_id": 12345}'
Using CI_JOB_TOKEN Safely in Pipelines
# .gitlab-ci.yml — using CI_JOB_TOKEN for cross-project triggers
trigger_deploy:
stage: deploy
trigger:
project: my-group/deploy-project
branch: main
strategy: depend
# CI_JOB_TOKEN is used automatically for the trigger.
# The target project must allowlist this project's token.
Key rules: Enable the CI/CD job token scope limit on every project. Only allowlist the specific projects that genuinely need cross-project access. Audit allowlists quarterly.
5. OIDC id_tokens for AWS and GCP
OIDC federation eliminates long-lived cloud credentials in your CI/CD variables entirely. GitLab issues a short-lived JWT, and your cloud provider exchanges it for temporary credentials. This is the gold standard for cloud authentication in pipelines.
AWS OIDC Federation
# .gitlab-ci.yml — OIDC authentication with AWS
deploy_aws:
stage: deploy
image: amazon/aws-cli:latest
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.example.com
variables:
ROLE_ARN: arn:aws:iam::123456789012:role/gitlab-ci-deploy
script:
- >
export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
$(aws sts assume-role-with-web-identity
--role-arn $ROLE_ARN
--role-session-name "GitLabCI-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
--web-identity-token "$GITLAB_OIDC_TOKEN"
--duration-seconds 3600
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
--output text))
- aws s3 sync ./build s3://my-production-bucket/
rules:
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production
GCP Workload Identity Federation
# .gitlab-ci.yml — OIDC authentication with GCP
deploy_gcp:
stage: deploy
image: google/cloud-sdk:latest
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.example.com
script:
- echo "$GITLAB_OIDC_TOKEN" > /tmp/gitlab_token.txt
- >
gcloud iam workload-identity-pools create-cred-config
projects/$GCP_PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID
--service-account="$GCP_SERVICE_ACCOUNT"
--output-file=/tmp/gcp_creds.json
--credential-source-file=/tmp/gitlab_token.txt
- export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp_creds.json
- gcloud config set project $GCP_PROJECT_ID
- gcloud run deploy my-service --image gcr.io/$GCP_PROJECT_ID/my-app:$CI_COMMIT_SHA
rules:
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production
On the cloud side, configure the trust policy to validate claims like project_path, ref, and ref_protected so that only specific projects and branches can assume the role.
6. Merge Request Pipeline Security
Merge request pipelines run on code that hasn’t been reviewed yet. Treat them as untrusted. Never expose production secrets to MR pipelines.
# .gitlab-ci.yml — separate rules for MR vs. branch pipelines
test:
stage: test
script:
- pytest tests/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
deploy_production:
stage: deploy
script:
- ./deploy.sh production
rules:
# NEVER run on merge request pipelines
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: never
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production
Critical controls:
- Use
rules:to ensure deployment jobs never run onmerge_request_eventpipelines. - Require pipeline success before merging (Settings > Merge Requests).
- Enable “Pipelines must succeed” and “All discussions must be resolved.”
- Consider enabling merged results pipelines to test the post-merge state.
7. Secret Detection Template
GitLab’s built-in secret detection scanner catches accidentally committed credentials before they reach the default branch.
# .gitlab-ci.yml — include the secret detection template
include:
- template: Jobs/Secret-Detection.gitlab-ci.yml
# Override to make the pipeline fail if secrets are found
secret_detection:
variables:
SECRET_DETECTION_HISTORIC_SCAN: "true" # Scan full git history
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
allow_failure: false # Block the pipeline on detection
For more comprehensive scanning, add SAST and dependency scanning templates as well:
include:
- template: Jobs/Secret-Detection.gitlab-ci.yml
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
Review findings in the Security Dashboard (available on GitLab Ultimate) or parse the JSON artifacts in lower tiers.
8. Push Rules
Push rules enforce policies at the Git level — before code even enters a pipeline. Use them to prevent secrets from being pushed, enforce commit message standards, and restrict file types.
# Set push rules via the API
curl --request PUT \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/push_rule" \
--data '{"deny_delete_tag": true, "prevent_secrets": true, "commit_message_regex": "^(feat|fix|chore|docs|refactor|test|ci)\\(.*\\):.*", "max_file_size": 50, "member_check": true, "reject_unsigned_commits": true}'
Recommended push rules:
prevent_secrets: true— Rejects pushes containing files that look like secrets (keys, tokens, certificates).reject_unsigned_commits: true— Requires GPG-signed commits (GitLab Premium+).commit_message_regex— Enforces conventional commit messages for clean audit trails.max_file_size— Prevents accidentally committing large binaries.member_check: true— Rejects commits from non-project members.
9. Job Timeouts and interruptible
Runaway jobs waste resources and can be exploited for cryptomining. Set explicit timeouts and mark non-critical jobs as interruptible so they are cancelled when a new pipeline starts on the same branch.
# .gitlab-ci.yml — timeouts and interruptible
default:
timeout: 30m # Global default timeout for all jobs
interruptible: true # Cancel running jobs when a new commit is pushed
retry:
max: 1
when:
- runner_system_failure
- stuck_or_timeout_failure
test:
stage: test
timeout: 15m
interruptible: true
script:
- pytest tests/ --timeout=600
deploy_production:
stage: deploy
timeout: 20m
interruptible: false # NEVER cancel a running production deployment
script:
- ./deploy.sh production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
environment:
name: production
Guidelines:
- Set a project-level timeout in Settings > CI/CD > General Pipelines (recommended: 60 minutes max).
- Set job-level timeouts that are tighter than the project default.
- Mark test and lint jobs as
interruptible: trueto save runner capacity. - Mark deployment jobs as
interruptible: falseto prevent partial deployments. - Use
retryfor transient infrastructure failures only — never for test failures.
Quick Reference Table
| Control | Where to Configure | Minimum Tier |
|---|---|---|
| Protected/Masked Variables | Settings > CI/CD > Variables | Free |
| Runner Scoping | Settings > CI/CD > Runners | Free |
| Protected Environments | Settings > CI/CD > Protected Environments | Premium |
| CI_JOB_TOKEN Scope | Settings > CI/CD > Token Access | Free |
| OIDC id_tokens | .gitlab-ci.yml |
Free |
| Secret Detection | include: template |
Free (Ultimate for dashboard) |
| Push Rules | Settings > Repository > Push Rules | Premium |
| Job Timeouts | Settings > CI/CD + .gitlab-ci.yml |
Free |
Further Reading and Labs
Continue hardening your GitLab CI/CD pipelines with these related resources:
- GitLab CI/CD Security Guide — A comprehensive walkthrough of every security setting in GitLab CI/CD.
- CI/CD Secrets Management Best Practices — Deep dive into vault integration, rotation, and least-privilege secrets.
- OIDC Authentication in CI/CD Pipelines — Step-by-step lab for configuring OIDC with AWS, GCP, and Azure.
- CI/CD Pipeline Security Checklist — The full audit checklist covering GitHub Actions, GitLab CI, and Jenkins.
- GitLab CI/CD Official Documentation — The authoritative reference for all GitLab CI/CD features.
Security is not a one-time configuration — it is an ongoing practice. Review your pipeline security settings quarterly, rotate credentials, audit runner access, and keep your GitLab instance updated. This cheat sheet gives you the building blocks. Now go lock down your pipelines.