GitLab CI Security Cheat Sheet: Variables, Runners, Environments, and OIDC

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=true so secrets are only available on protected branches.
  • Set masked=true so 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_protected to 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 on merge_request_event pipelines.
  • 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: true to save runner capacity.
  • Mark deployment jobs as interruptible: false to prevent partial deployments.
  • Use retry for 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:

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.