Least Privilege for CI/CD Pipelines

Every workflow run in GitHub Actions gets its own temporary GITHUB_TOKEN, and by default that token can do a lot more than most jobs actually need. Least privilege means every job gets exactly the permissions its steps use — nothing more — so that a compromised dependency, a leaked log, or a bug in your own script can’t be turned into repository-wide write access. This lesson covers how to read, set, and audit those permissions, and how to replace long-lived cloud secrets with short-lived, tightly scoped credentials.

Overview / How it works

When a workflow starts, GitHub mints a GITHUB_TOKEN scoped to that single run and expires it when the job finishes. What that token is allowed to do is controlled two ways: the repository or organization’s default workflow permissions setting (usually “read-only” or the older “read and write” default), and the permissions: key inside the workflow file itself, which overrides the default. If you don’t set permissions: at all, your workflow inherits whatever the org or repo default is — which may be far broader than any of your jobs require.

The risk isn’t abstract. A workflow token with contents: write, packages: write, and pull-requests: write can push commits, publish packages, and edit any pull request in the repository. If a malicious npm postinstall script or a compromised third-party action runs during that job, it inherits every one of those permissions. Scoping permissions down to only what each job does shrinks the blast radius of that kind of failure to something recoverable.

Syntax or workflow structure

The permissions: key can appear at the top of the workflow file (the default for every job) and again inside an individual job (which completely replaces the top-level value for that job — it does not add to it). Each permission scope is set to read, write, or none. Common scopes include:

  • contents — read or write repository code, tags, and releases
  • pull-requests — comment on, label, or merge pull requests
  • issues — create or modify issues and comments
  • packages — publish or read GitHub Packages / container images
  • id-token — request an OpenID Connect (OIDC) token for cloud provider federation
  • checks, statuses, deployments — report build and deployment status
  • actions — manage workflow runs and caches
  • security-events — upload code scanning (SARIF) results

Setting permissions: {} or permissions: read-all is more restrictive; setting permissions: write-all grants every scope write access and should be treated as a last resort, not a default fix for a permissions error.

Examples

Example 1: an explicit, minimal default

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: npm test

Expected behavior: checkout and npm test only need to read the repository, so contents: read is enough. If a later step tries to push a commit or open a PR, it fails with a 403 — a signal that the job needs a narrowly scoped addition, not a broader default.

Example 2: elevating only the job that needs it

name: PR Feedback

on:
  pull_request:

permissions:
  contents: read

jobs:
  lint:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

  comment:
    needs: lint
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Post lint summary
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: 'Lint checks completed.'
            })

Expected behavior: the lint job only ever reads the repository. Only the comment job, which posts to the pull request, is granted pull-requests: write — and even then it doesn’t get contents: write, since it never touches code.

Example 3: OIDC instead of a stored cloud secret

name: Deploy to AWS

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-role
          aws-region: us-east-1
      - name: Deploy
        run: ./scripts/deploy.sh

Expected behavior: id-token: write lets the job request a short-lived OIDC token from GitHub, which AWS exchanges for temporary credentials scoped to the trust policy on gha-deploy-role. No long-lived AWS access key is stored as a repository secret at all, and the trust policy can restrict which repository, branch, or environment is allowed to assume the role.

Step by step

  1. Set the organization or repository default workflow permissions to read-only, so any workflow that forgets a permissions: block fails closed instead of open.
  2. Add an explicit permissions: block to the top of every workflow file, even if it’s just contents: read — this documents intent and protects against future default changes.
  3. For each job, list only the scopes its steps actually call. Run the workflow; if a step returns a 403, identify the exact API call that failed and add only that scope.
  4. Replace stored cloud credentials with OIDC federation (id-token: write plus a provider-side trust policy) wherever the target platform supports it.
  5. Put deployment jobs behind a protected GitHub Environment that requires reviewer approval, so elevated credentials are only exercised after a human or an automated gate signs off.
  6. Periodically audit workflows for permissions: write-all or missing permissions: blocks, and check the repository’s default token permission setting.

Common Mistakes

Mistake 1: reaching for write-all to silence a 403

A job fails with a permissions error, and the fix becomes granting everything instead of the one scope that was actually missing.

permissions: write-all

Correction: find the specific failing call (often a single API request inside an action) and grant only that scope at the job level, for example pull-requests: write for a PR comment or packages: write for a publish step. The rest of the job keeps read-only or no access.

Mistake 2: pull_request_target with elevated permissions and a fork checkout

pull_request_target runs with the base repository’s token and secrets, even for pull requests from forks — but the workflow author sometimes also checks out the untrusted fork’s head commit and runs its code, combining attacker-controlled code with a privileged token.

# Do not use this pattern
name: Untrusted PR Handler

on:
  pull_request_target:

permissions: write-all

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm ci && npm run build

A malicious pull request can add a build script or dependency that runs during npm ci or npm run build, and that code executes with a write-all token — able to push commits, publish packages, or exfiltrate secrets. Correction: don’t check out or execute the fork’s code under pull_request_target. Keep the job to safe, metadata-only actions and grant only the scope that action needs.

name: Untrusted PR Handler

on:
  pull_request_target:

permissions:
  contents: read

jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - name: Label PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.addLabels({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              labels: ['needs-review']
            })

This version never checks out or runs the fork’s contents, so the elevated pull-requests: write scope is never exposed to untrusted code. If you need to build or test fork PRs, use the regular pull_request trigger instead, which runs with a read-only token and no access to repository secrets.

Best Practices

  • Set org-level default workflow permissions to read-only so workflows fail closed if a permissions: block is missing.
  • Add an explicit permissions: block to every workflow, even when it’s just contents: read.
  • Scope permissions at the job level, not just the workflow level — a job that comments on issues doesn’t need write access to code.
  • Never use permissions: write-all as a quick fix; find the one missing scope instead.
  • Prefer OIDC (id-token: write) over storing long-lived cloud provider credentials as secrets.
  • Treat pull_request_target, workflow_run, and self-hosted runners handling fork PRs as high-risk; never combine them with checking out and executing untrusted code under a privileged token.
  • Gate deployment jobs behind protected Environments with required reviewers before elevated credentials are used.
  • Review workflows periodically for scope creep — permissions added for a one-off task that were never removed.

Practice Exercises

  1. Take an existing workflow in one of your repositories that has no permissions: block. Add one at the top with contents: read, run it, and note which jobs fail — then add only the specific scope each failure requires.
  2. Split a single job that both runs tests and comments results on a pull request into two jobs, giving only the commenting job pull-requests: write.
  3. Find a workflow using a stored cloud credential as a secret and sketch what an OIDC-based replacement with id-token: write and a scoped trust policy would look like.
  4. Locate a workflow (yours or an open-source example) that uses pull_request_target. Check whether it checks out and executes the pull request head — if so, describe how you would rework it to avoid that combination.

Summary

Least privilege in CI/CD means every job’s token can do exactly what its steps require and nothing else. Set a read-only default, declare permissions: explicitly in every workflow, scope elevated access to the specific job that needs it, and replace long-lived cloud secrets with short-lived OIDC credentials wherever possible. Treat triggers that expose privileged tokens to untrusted code, especially pull_request_target combined with a fork checkout, as a design decision to avoid rather than a convenience to reach for.