How to Secure Your CI/CD Pipeline: Vulnerability Scanning and Access Control

Harden your CI/CD pipeline against supply chain attacks. Covers runner security, artifact signing, RBAC, pipeline secrets management, and audit logging.

Your CI/CD pipeline is the engine of software delivery: code pushed to a branch triggers builds, tests, and deployments across environments. This page guides you through securing that engine—specifically, hardening the pipeline against vulnerabilities and enforcing granular access control. It matters when you’re deploying to production, when a security incident involves a compromised build or deployment artifact, or when a developer accidentally promotes a vulnerable dependency to staging.

Enforce Vulnerability Scanning at Every Stage

A vulnerability scan is not a one-off audit. It must be embedded in every phase of the CI/CD lifecycle: from source code and container images to dependencies and runtime artifacts.

Scan Source Code on Pull Request

When a developer opens a pull request, the pipeline should run static analysis and dependency scanning on the entire codebase. Use snyk for dependency vulnerability detection and sonarqube for code quality and security.


name: PR Security Scan
on: pull_request

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Snyk
        run: |
          snyk test --file=package.json --org=your-org --project-name=web-app
          snyk monitor --file=package.json
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
      - name: Run SonarQube Analysis
        run: |
          sonar-scanner \
            -Dsonar.projectKey=web-app \
            -Dsonar.sources=. \
            -Dsonar.host.url=https://sonar.yourcompany.com \
            -Dsonar.login=${{ secrets.SONAR_TOKEN }}

The snyk test command scans package.json and reports all known vulnerabilities in npm packages. It fails the job if any high or critical severity vulnerability is introduced. snyk monitor sends results to a central dashboard and sends alerts on new vulnerabilities.

Scan Container Images in the Build Step

Every image built in the pipeline must be scanned for vulnerabilities using trivy, a lightweight, open-source scanner.

# .github/workflows/build.yml
name: Build and Scan Image
on: push

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build Docker Image
        run: |
          docker build -t myapp:v1.2.3 .
      - name: Scan Image with Trivy
        run: |
          trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:v1.2.3
        env:
          TRIVY_TOKEN: ${{ secrets.TRIVY_TOKEN }}

The --exit-code 1 flag causes the job to fail if any high or critical vulnerability is found. Trivy reports vulnerabilities by CVE ID, severity, and a link to the upstream advisory. If a new image has a known RCE vulnerability in nginx:alpine, the pipeline stops before deployment.

Integrate with Dependency Update Automation

Use renovate to automatically update dependencies and trigger a security scan on every update.

{
  "extends": ["config:base"],
  "packageRules": [
    {
      "packagePatterns": ["^@types/"],
      "schedule": ["every 14 days"]
    },
    {
      "packageNames": ["react", "express"],
      "automerge": true,
      "automergeType": "pr"
    }
  ],
  "postUpdateTasks": {
    "commands": [
      "npm run test",
      "npm run build",
      "trivy image --exit-code 1 myapp:latest"
    ]
  }
}

Renovate creates PRs for dependency updates. When a PR is opened, it runs tests and a Trivy scan. If the scan fails, the PR is marked as requiring review, and the PR title includes the number of new vulnerabilities.

Enforce Access Control at Every Step

Access control in CI/CD ensures that only authorized people and systems can trigger, approve, or promote pipelines. It prevents unauthorized changes and provides auditability.

Define Roles and Permissions Using YAML-Based Policies

Use a declarative policy language in ci-policies.yaml to define who can do what across the pipeline.

# ci-policies.yaml
policies:
  - name: "Deploy to Production"
    description: "Allow team leads to deploy to prod after review"
    permissions:
      - pipeline: "deploy-prod"
        actions:
          - trigger
          - approve
          - promote
        conditions:
          - user: "team-leads"
          - branch: "main"
          - environment: "production"
    - name: "Merge to Main"
      description: "Allow all contributors to merge to main branch"
      permissions:
        - pipeline: "ci"
          actions:
            - trigger
            - merge
          conditions:
            - branch: "main"
            - user: "contributors"
    - name: "Security Review"
      description: "Require security team to approve before production"
      permissions:
        - pipeline: "security-review"
          actions:
            - approve
          conditions:
            - environment: "production"
            - approval: "required"

This file is checked into version control and applied at pipeline startup. Each job reads the policy, checks conditions, and enforces access.

Enforce Pull Request Approvals with Required Reviews

In the CI configuration, use require: true and reviewers: ["security-team", "platform-team"] to require approval before merging.

# .github/workflows/ci.yml
name: CI Pipeline
on: push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test
      - name: Merge to Main
        run: |
          git checkout main
          git merge --ff-only ${{ github.ref }}
          git push origin main
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          required_reviews: 2
          reviewers: ["security-team", "platform-team"]

The required_reviews field sets the minimum number of approvals, and reviewers specifies which teams must approve. If a PR is merged without a security team review, the pipeline logs an error: PR missing required review from security-team.

Restrict Pipeline Triggering by Source and Identity

Only allow certain identities to trigger specific pipelines. Use trigger: "workflow_dispatch" with inputs and permissions to define who can start a job.

# .github/workflows/manual-deploy.yml
name: Manual Deploy to Staging
on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        type: choice
        options: ["staging", "canary", "production"]
      version:
        description: "Version to deploy"
        required: true
        type: string
    permissions:
      contents: read
      deployments: write
      pull-requests: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        run: |
          echo "Deploying version $VERSION to $ENVIRONMENT"
          # Deploy logic here
        env:
          VERSION: ${{ github.event.inputs.version }}
          ENVIRONMENT: ${{ github.event.inputs.environment }}

When a user triggers this workflow, GitHub checks the user’s permissions. The permissions block ensures that only users with pull-requests: write can trigger it. A developer without the required permissions sees: Permission denied: user does not have pull-requests: write.

Automate Pipeline Promotions with Approval Gates

Promoting a build from one environment to another must require explicit approval and validation.

Set Up Environment-Specific Approval Gates

Define a promotion workflow that checks for approvals before moving to the next stage.

# .github/workflows/promote-to-prod.yml
name: Promote to Production
on:
  workflow_dispatch:
    inputs:
      release-notes:
        description: "Release notes for this version"
        required: true
        type: string

jobs:
  promote:
    runs-on: ubuntu-latest
    steps:
      - name: Check Approval
        run: |
          if ! git log --oneline -1 | grep -q "Approved by security-team"; then
            echo "Error: No approval from security-team"
            exit 1
          fi
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Run Smoke Tests
        run: |
          curl -f http://prod.myapp.com/health
          curl -f http://prod.myapp.com/api/v1/users
        env:
          PROD_URL: "http://prod.myapp.com"
      - name: Deploy to Production
        run: |
          kubectl apply -f manifests/prod.yaml
          kubectl rollout status deployment/web-app -n production
        env:
          KUBECONFIG: ${{ secrets.KUBECONFIG }}

The git log check ensures that the PR has a commit message including "Approved by security-team". If the smoke tests fail, the deployment job logs: HTTP 503: Service Unavailable. The kubectl rollout status command waits until the deployment is fully ready or times out after 5 minutes.

Integrate with External Approval Systems

Use curl to call an external approval service when a promotion is pending.

# .github/workflows/external-approval.yml
name: External Approval for Production
on:
  workflow_dispatch:
    inputs:
      approval-url:
        description: "URL to external approval service"
        required: true
        type: string

jobs:
  approve:
    runs-on: ubuntu-latest
    steps:
      - name: Request Approval
        run: |
          curl -X POST \
            -H "Content-Type: application/json" \
            -d '{
              "title": "Promote version $VERSION to production",
              "url": "$APPROVAL_URL",
              "metadata": {
                "version": "$VERSION",
                "environment": "production"
              }
            }' \
            https://approval.yourcompany.com/api/v1/request
        env:
          VERSION: ${{ github.event.inputs.version }}
          APPROVAL_URL: ${{ github.event.inputs.approval-url }}

The external service returns a JSON response with approval_id, status, and expires_at. The pipeline waits for the approval ID to be updated or for the status to be approved. If the service returns {"status": "pending", "reason": "Review in progress"}, the workflow logs: Approval pending: waiting for external review.

Handle Pipeline Failures with Precision

The pipeline fails silently if not properly instrumented.

Log All Critical Events

Log every significant action in the pipeline using structured JSON.

# .github/workflows/logging.yml
name: Log Events
on: workflow_run

jobs:
  log:
    runs-on: ubuntu-latest
    steps:
      - name: Log Pipeline Event
        run: |
          echo '{
            "event": "pipeline_started",
            "pipeline": "${{ github.workflow }}",
            "ref": "${{ github.ref }}",
            "sha": "${{ github.sha }}",
            "author": "${{ github.event.sender.login }}",
            "timestamp": "'"$(date -Iseconds)"'"
          }' > /tmp/event.json
          curl -X POST \
            -H "Content-Type: application/json" \
            -d @/tmp/event.json \
            https://logging.yourcompany.com/api/v1/events

The date -Iseconds command produces 2024-04-05T12:34:56+00:00, which is consumed by a central logging system.

Handle Common Failure Modes

Summary

Your CI/CD pipeline is the first line of defense. Vulnerability scanning must run at every stage: source, dependency, and artifact. Access control must be enforced through declarative policies, required approvals, and identity-based triggers. Promotions across environments require structured logs, approval gates, and integration with external systems. When the pipeline fails, it must do so explicitly—no silent failures. Use exact commands, exact configurations, and precise failure modes. This is how you secure a CI/CD pipeline.

This page was rewritten on 16 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.