What you will build
The same Node.js service, contoso/payments-api, built into the project's own container registry and deployed to Azure Container Apps. GitLab puts security scanning, the vulnerability report and policy enforcement in one product, so much of this tutorial is configuration rather than YAML.
contoso/payments-api # application project ├── .gitlab-ci.yml # build → test+scan → sign → verify → deploy ├── CODEOWNERS ├── Dockerfile └── src/ contoso/security-policies # security policy project, owned by AppSec └── .gitlab/security-policies/ └── policy.yml # scan execution + merge request approval policies
Features differ by tier, so each one below is marked Free, Premium or Ultimate. The pipeline itself works on every tier; Ultimate adds the vulnerability report, dependency scanning and policies.
Step 1Protect branches, variables and runners
main Free
Settings›Repository›Protected branches
Allowed to merge: Maintainers. Allowed to push and merge: No one. Force push: off. Turn on Code owner approval (Premium) so changes to .gitlab-ci.yml need the owners listed in CODEOWNERS.
production environment.
protected-docker. Protected runners only pick up jobs from protected refs, so merge request code never runs on them. Use autoscaling runners that create a fresh VM or pod per job.
production: only the release managers group can deploy, and at least one approval is required. A deployment job waits until it is approved.
[Pipeline][1]
/.gitlab-ci.yml @contoso/platform-security
/.gitlab/ @contoso/platform-security
/.trivyignore.yaml @contoso/platform-security
Step 2Job tokens and OIDC
Keep the job token allowlist closed
Every job gets CI_JOB_TOKEN, which can pull from registries and call some APIs with the permissions of the user who started the pipeline. The CI/CD job token allowlist under Settings›CI/CD›Job token permissions decides which other projects' jobs may use their token against this project. By default only the project itself is on the list. Add other projects only when you must, and use fine-grained job token permissions to limit the API endpoints they can reach.
OIDC with id_tokens
A job declares the ID tokens it needs and the audience of each. GitLab signs a JWT and exposes it as a variable for that job only. Use one token per destination, so a token issued for Sigstore cannot be replayed against Azure.
deploy-production:
id_tokens:
AZURE_ID_TOKEN:
aud: api://AzureADTokenExchange # Entra ID expects this audience
script:
- az login --service-principal
--username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID"
--federated-token "$AZURE_ID_TOKEN"
In Entra ID, add a federated credential to the deploy identity:
| Field | Value |
|---|---|
| Issuer | https://gitlab.com (or your instance URL) |
| Subject | project_path:contoso/payments-api:ref_type:branch:ref:main |
| Audience | api://AzureADTokenExchange |
The default subject names the project and branch. Because main is protected, only reviewed code can obtain the token, and the protected environment adds the approval. The token also carries environment and environment_protected claims, which Vault and AWS trust policies can check directly. For HashiCorp Vault, the secrets: keyword uses the same ID tokens to fetch secrets at job start.
Watch out
The old predefined CI_JOB_JWT variables are gone. If a guide tells you to use them, it predates id_tokens; a deprecated token is a common cause of 401 errors from cloud providers.
Step 3Built-in scanners
GitLab ships its scanners as CI templates. Including them adds jobs to the test stage that write reports GitLab understands. Dependency scanning now uses the SBOM-based analyzer in the v2 template; the Gemnasium analyzer is deprecated.
include:
- template: Jobs/SAST.gitlab-ci.yml # Free
- template: Jobs/Secret-Detection.gitlab-ci.yml # Free
- template: Jobs/SAST-IaC.gitlab-ci.yml # Free
- template: Jobs/Container-Scanning.gitlab-ci.yml # Free
- template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml # Ultimate
variables:
CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # what container scanning scans
SAST_EXCLUDED_PATHS: "spec, test, tests, tmp, node_modules"
| Template | Finds | Report |
|---|---|---|
| SAST | Code vulnerabilities, using Semgrep-based analyzers (GitLab Advanced SAST on Ultimate) | sast |
| Secret Detection | Secrets in the latest commits | secret_detection |
| SAST-IaC | Terraform, Kubernetes, Dockerfile and cloud template misconfigurations | sast |
| Container Scanning | OS and language packages in the image, plus a CycloneDX SBOM | container_scanning |
| Dependency Scanning v2 | Vulnerable dependencies from lockfiles, plus a CycloneDX SBOM | dependency_scanning |
These jobs report; they do not block. The template jobs succeed when they find vulnerabilities. Blocking happens in one of two places: a merge request approval policy (step 4, Ultimate) or blocking jobs you add yourself. The pipeline in this tutorial adds one blocking job on every tier, an image gate that fails on fixable critical and high CVEs before anything is signed:
image-gate:
stage: test
image:
name: aquasec/trivy:0.74.0
entrypoint: [""]
variables:
TRIVY_USERNAME: $CI_REGISTRY_USER
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1
"$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
Blocking gates on Free and Premium
Without approval policies, add open-source scanners as jobs that exit non-zero on findings. Mark them required by keeping "Pipelines must succeed" on.
secrets-gate:
stage: test
image:
name: ghcr.io/gitleaks/gitleaks:v8.30.1
entrypoint: [""]
script:
- gitleaks dir . --redact
sast-gate:
stage: test
image: semgrep/semgrep:1.179.0
script:
- semgrep scan --config p/default --severity ERROR --error
sca-gate:
stage: test
image:
name: aquasec/trivy:0.74.0
entrypoint: [""]
script:
- trivy fs --scanners vuln --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 .
Step 4Enforce with security policies Ultimate
A template in .gitlab-ci.yml can be deleted by anyone who can edit that file. Security policies live in a separate project owned by your security team and apply to every project linked to it. Two kinds matter here.
Scan execution policy
Scans always run
Injects the chosen scanners into every pipeline of the linked projects, even if the project's own CI file does not include them.
Merge request approval policy
New risk needs sign-off
When a merge request would introduce findings above a threshold, it requires approval from the security group before it can merge. This is the merge gate.
scan_execution_policy:
- name: Baseline scans on every pipeline
description: Runs even if a project removes the templates
enabled: true
rules:
- type: pipeline
branch_type: all
actions:
- scan: secret_detection
- scan: sast
- scan: sast_iac
- scan: dependency_scanning
approval_policy:
- name: Block new critical and high vulnerabilities
description: AppSec approves any MR that introduces them
enabled: true
rules:
- type: scan_finding
branch_type: protected
scanners: [sast, secret_detection, dependency_scanning, container_scanning]
vulnerabilities_allowed: 0
severity_levels: [critical, high]
vulnerability_states: [new_needs_triage, new_dismissed]
actions:
- type: require_approval
approvals_required: 1
group_approvers: [contoso/appsec]
- type: send_bot_message
enabled: true
Including new_dismissed in the vulnerability states means a developer cannot dismiss a new finding to get around the approval. For jobs that must run in every pipeline but are not scanners, such as a license check or an audit step, use a pipeline execution policy, the successor to compliance pipelines.
Step 5Build and sign with Sigstore
The image is built only on main, on the protected privileged runner. The digest is passed to later jobs through a dotenv report, so every later job works with the immutable digest rather than a tag.
build-image:
stage: build
image: docker:28.3
services:
- docker:28.3-dind
tags: [protected-docker] # only a protected runner may run privileged builds
variables:
DOCKER_TLS_CERTDIR: "/certs"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
- digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" | cut -d@ -f2)
- echo "IMAGE_DIGEST=$digest" >> build.env
artifacts:
reports:
dotenv: build.env
Signing uses Sigstore keyless signing. The job requests an ID token with the audience sigstore; cosign reads it from SIGSTORE_ID_TOKEN, gets a short-lived certificate that names this project, branch and CI file, and records the signature in the public Rekor transparency log. There is no signing key to store or rotate.
sign-image:
stage: sign
image: alpine:3.22
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
- apk add --no-cache cosign
- echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- cosign sign --yes "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
Self-managed GitLab
The public Sigstore certificate authority accepts ID tokens from GitLab.com. On a self-managed instance, sign with a key in a cloud KMS instead (cosign sign --key with a KMS URI) or run your own Sigstore stack.
Step 6Verify and deploy
Verification checks that the signature's certificate was issued to this project's CI file on main. An image signed by any other project or branch fails. Because the digest is immutable, verifying in one job and deploying the same digest in the next is safe.
verify-signature:
stage: verify
image: alpine:3.22
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- apk add --no-cache cosign
- echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- cosign verify "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
--certificate-identity "https://gitlab.com/$CI_PROJECT_PATH//.gitlab-ci.yml@refs/heads/$CI_DEFAULT_BRANCH"
--certificate-oidc-issuer "https://gitlab.com"
deploy-production:
stage: deploy
image: mcr.microsoft.com/azure-cli:2.70.0
interruptible: false
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
environment:
name: production # protected environment: approval required
deployment_tier: production
resource_group: production # one deployment at a time
id_tokens:
AZURE_ID_TOKEN:
aud: api://AzureADTokenExchange
script:
- az login --service-principal
--username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID"
--federated-token "$AZURE_ID_TOKEN"
- az containerapp update
--name payments-api --resource-group rg-payments-prod
--image "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
- Stage order is the gate. These jobs do not use
needs, so each stage waits for every job in the previous one. Aneedslist lets a job skip ahead of jobs it does not list, which can silently bypass the image gate. resource_groupmakes concurrent pipelines queue their deployments instead of racing.- Environment-scoped variables. Scope
AZURE_CLIENT_IDandAZURE_TENANT_IDtoproductionand mark them protected, so only this job sees them.
Pitfalls that get pipelines owned
Re-parsing untrusted values
The shell expands $CI_MERGE_REQUEST_TITLE or $CI_COMMIT_MESSAGE safely when you quote it as an argument. It becomes code when it ends up inside a string that is parsed again, such as sh -c, eval or a generated script.
script:
- sh -c "./notify.sh $CI_MERGE_REQUEST_TITLE"
script:
- ./notify.sh "$CI_MERGE_REQUEST_TITLE"
Unprotected secrets
A variable that is not protected is available on every branch. Any developer can push a branch whose pipeline prints it. Protect every secret and scope it to an environment.
Fork merge requests in the parent project
Pipelines for merge requests from forks run in the fork by default, without the parent's variables. Running them in the parent project gives fork code access to parent resources; do it only after reviewing the change.
Unpinned includes
include: project: without ref: follows the other project's default branch, and include: remote: fetches whatever the URL serves at run time. Pin includes to a tag or commit SHA, and pin CI/CD catalog components to an exact version rather than ~latest.
Debug logging
CI_DEBUG_TRACE prints every variable the job can see and can reveal values that are normally masked. Never enable it on jobs that hold secrets.
Complete files
stages: [build, test, sign, verify, deploy]
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
- template: Jobs/SAST-IaC.gitlab-ci.yml
- template: Jobs/Container-Scanning.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml # Ultimate
variables:
CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
SAST_EXCLUDED_PATHS: "spec, test, tests, tmp, node_modules"
default:
interruptible: true
timeout: 30m
build-image:
stage: build
image: docker:28.3
services:
- docker:28.3-dind
tags: [protected-docker]
variables:
DOCKER_TLS_CERTDIR: "/certs"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
- digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" | cut -d@ -f2)
- echo "IMAGE_DIGEST=$digest" >> build.env
artifacts:
reports:
dotenv: build.env
unit-tests:
stage: test
image: node:22-alpine # pin by digest in production
script:
- npm ci --ignore-scripts
- npm test
container_scanning: # template job: scan only the image built on main
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
image-gate:
stage: test
image:
name: aquasec/trivy:0.74.0
entrypoint: [""]
variables:
TRIVY_USERNAME: $CI_REGISTRY_USER
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1
"$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
sign-image:
stage: sign
image: alpine:3.22
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
- apk add --no-cache cosign
- echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- cosign sign --yes "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
verify-signature:
stage: verify
image: alpine:3.22
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- apk add --no-cache cosign
- echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- cosign verify "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
--certificate-identity "https://gitlab.com/$CI_PROJECT_PATH//.gitlab-ci.yml@refs/heads/$CI_DEFAULT_BRANCH"
--certificate-oidc-issuer "https://gitlab.com"
deploy-production:
stage: deploy
image: mcr.microsoft.com/azure-cli:2.70.0
interruptible: false
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
environment:
name: production
deployment_tier: production
resource_group: production
id_tokens:
AZURE_ID_TOKEN:
aud: api://AzureADTokenExchange
script:
- az login --service-principal
--username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID"
--federated-token "$AZURE_ID_TOKEN"
- az containerapp update
--name payments-api --resource-group rg-payments-prod
--image "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
scan_execution_policy:
- name: Baseline scans on every pipeline
description: Runs even if a project removes the templates
enabled: true
rules:
- type: pipeline
branch_type: all
actions:
- scan: secret_detection
- scan: sast
- scan: sast_iac
- scan: dependency_scanning
approval_policy:
- name: Block new critical and high vulnerabilities
description: AppSec approves any MR that introduces them
enabled: true
rules:
- type: scan_finding
branch_type: protected
scanners: [sast, secret_detection, dependency_scanning, container_scanning]
vulnerabilities_allowed: 0
severity_levels: [critical, high]
vulnerability_states: [new_needs_triage, new_dismissed]
actions:
- type: require_approval
approvals_required: 1
group_approvers: [contoso/appsec]
- type: send_bot_message
enabled: true
Multi-line commands
Script lines that continue on the next, more-indented line are YAML plain-scalar continuation: GitLab joins them with spaces into one command. It keeps long commands readable without backslashes.
References
- Pipeline security
- OpenID Connect ID token authentication
- CI/CD job token and allowlist
- Migrating to SBOM-based dependency scanning
- Security policies
- Sigstore keyless signing examples
Next: compare the three platforms and work through the hardening checklist.