Safeguard
DevSecOps

GitLab CI Security Best Practices for 2026

GitLab CI hands every job a CI_JOB_TOKEN, a runner, and your variables. This guide covers the real attack surface — remote includes, token scope, privileged runners — with hardened .gitlab-ci.yml examples, OIDC, and scanning.

Daniel Osei
DevSecOps Engineer
6 min read

GitLab CI is a build system, a secrets store, and a credential broker in one .gitlab-ci.yml file — and most teams treat it as configuration rather than the privileged program it actually is. Every job runs with a CI_JOB_TOKEN, inherits your CI/CD variables, and can pull configuration and components from other repositories at runtime. That combination is exactly what supply chain attackers exploit. The lessons transfer directly from the incidents that reshaped the industry: the 2021 Codecov Bash Uploader compromise, where a tampered CI script quietly exfiltrated environment variables and tokens from thousands of pipelines for roughly two months, and the March 2025 tj-actions attack (CVE-2025-30066), where a mutable reference let malicious code reach ~23,000 downstream repos. GitLab pipelines have the same trust boundaries. This guide walks the attack surface and the hardening that closes it.

The attack surface

GitLab CI's risk concentrates in four places. Remote includes and components: include:remote and include:project pull YAML from elsewhere, and if you reference a branch instead of a pinned commit, whoever controls that branch controls your pipeline. CI_JOB_TOKEN scope: historically this token could reach other projects' resources far more broadly than most teams realized, enabling cross-project pivots. Runner configuration: a shared runner in privileged mode gives a build container Docker-in-Docker access and a path to the host. Variables: masked variables are only redacted from logs on a best-effort basis, and masking is not the same as protection — an unprotected variable is exposed to pipelines on unprotected branches, including some fork and MR scenarios.

Hardening step 1: pin includes and components

Never include CI configuration by branch. Pin include:project to a commit SHA and pin catalog components to an exact version so a change upstream cannot silently alter your pipeline.

include:
  - project: 'security/ci-templates'
    ref: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0'  # pinned SHA, not a branch
    file: '/templates/scan.yml'
  - component: gitlab.example.com/security/sast/scan@1.4.2  # pinned version

Treat the templates repository as production code: require merge request review and protect its default branch, because a change there executes with the permissions of every pipeline that includes it.

Hardening step 2: scope the CI_JOB_TOKEN

In GitLab 16 and later, the CI/CD job token access allowlist is the control that matters. Under Settings, CI/CD, Token Access, keep "Limit access to this project" enabled and explicitly allowlist only the projects that legitimately need to call in with this project's job token. New projects default to the restricted behavior; audit older projects, where the token may still have broad reach. This one setting turns a cross-project pivot from trivial into blocked.

Hardening step 3: lock down runners

Do not run untrusted or public-facing pipelines on privileged runners. Tag runners by trust level and require jobs to opt in with matching tags, so a build job cannot accidentally land on a runner with host access.

build-image:
  tags:
    - trusted-docker      # only runners with this tag pick up the job
  script:
    - buildah bud -t "$IMAGE" .   # rootless build, no privileged Docker-in-Docker

Prefer rootless image builders such as Buildah or Kaniko over Docker-in-Docker, and make CI runners ephemeral where possible so state and cached credentials do not carry from one job to the next.

Secrets and OIDC

The strongest secrets posture is to stop storing long-lived cloud keys at all. GitLab issues signed JWTs through id_tokens, which you exchange for short-lived cloud credentials at runtime.

deploy:
  id_tokens:
    AWS_ID_TOKEN:
      aud: https://gitlab.example.com
  script:
    - >
      export $(aws sts assume-role-with-web-identity
      --role-arn "$AWS_ROLE_ARN"
      --role-session-name gitlab-ci
      --web-identity-token "$AWS_ID_TOKEN"
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text | awk '{print "AWS_ACCESS_KEY_ID="$1"\nAWS_SECRET_ACCESS_KEY="$2"\nAWS_SESSION_TOKEN="$3}')

Scope the cloud trust policy to your project path and, ideally, to a protected branch or tag via the JWT claims, so a fork's pipeline cannot assume the role. For variables that must persist, mark them Protected and Masked, restrict them to protected branches, and never echo a secret. Protected variables are not exposed to pipelines running on unprotected branches, which closes the most common fork-secret leak.

Adding Safeguard scanning to the pipeline

Hardened YAML still does not tell you whether the dependencies you ship are exploitable. Add a scan stage that runs the Safeguard CLI on every merge request so software composition analysis runs before merge.

safeguard-scan:
  stage: test
  image: alpine:3.20
  variables:
    SAFEGUARD_TOKEN: $SAFEGUARD_TOKEN   # protected, masked project variable
  script:
    - apk add --no-cache curl bash
    - curl -sSfL https://get.safeguard.sh/install.sh | sh
    - safeguard scan --fail-on high --sbom cyclonedx
  artifacts:
    paths:
      - sbom.cdx.json

Safeguard's SCA engine ranks findings by reachability, so the pipeline gates on the exploitable minority instead of the entire dependency tree, and where a fix exists Auto-Fix opens the upgrade merge request automatically.

Hardening checklist

  • Pin include:project to commit SHAs and catalog components to exact versions
  • Enable the CI/CD job token allowlist and restrict cross-project access
  • Tag runners by trust; keep public pipelines off privileged runners
  • Use rootless builders (Buildah, Kaniko) instead of Docker-in-Docker
  • Mark secrets Protected and Masked; restrict them to protected branches
  • Replace static cloud keys with id_tokens OIDC scoped to your project
  • Scan every MR with reachability-aware SCA and fail on exploitable findings

Frequently Asked Questions

Is a masked variable the same as a protected variable?

No, and conflating them causes leaks. Masking only attempts to redact the value from job logs, and it fails on transformed values. Protection controls whether the variable is exposed to pipelines at all — protected variables are only available on protected branches and tags. Sensitive values should be both.

Why pin an include when it comes from my own GitLab instance?

Because "my own instance" still means "whoever can push to that branch." Pinning to a commit SHA means a change to the templates repository must go through a reviewed merge request and an explicit pin bump before it affects your pipeline, rather than taking effect on the next run.

Do fork merge requests get access to my CI/CD variables?

Protected variables are not exposed to pipelines from forks or on unprotected branches, which is the safe default. The risk appears when teams mark a secret as merely masked rather than protected, or run privileged jobs on shared runners. Keep secrets protected and keep untrusted pipelines on isolated runners.

How does Safeguard fit a GitLab-centric team?

Safeguard integrates through the CLI in any .gitlab-ci.yml and via native SCM integration, ingesting your SBOMs and prioritizing findings by reachability. Compare the approach against alternatives on the comparison page and see plan options on pricing.

To wire these controls into your pipeline, see Safeguard CLI, SCA, Auto-Fix, the comparison page, pricing, and full setup steps in the documentation at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.