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 releasespull-requests— comment on, label, or merge pull requestsissues— create or modify issues and commentspackages— publish or read GitHub Packages / container imagesid-token— request an OpenID Connect (OIDC) token for cloud provider federationchecks,statuses,deployments— report build and deployment statusactions— manage workflow runs and cachessecurity-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
- Set the organization or repository default workflow permissions to read-only, so any workflow that forgets a
permissions:block fails closed instead of open. - Add an explicit
permissions:block to the top of every workflow file, even if it’s justcontents: read— this documents intent and protects against future default changes. - 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.
- Replace stored cloud credentials with OIDC federation (
id-token: writeplus a provider-side trust policy) wherever the target platform supports it. - 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.
- Periodically audit workflows for
permissions: write-allor missingpermissions: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 justcontents: 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-allas 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
- Take an existing workflow in one of your repositories that has no
permissions:block. Add one at the top withcontents: read, run it, and note which jobs fail — then add only the specific scope each failure requires. - 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. - Find a workflow using a stored cloud credential as a secret and sketch what an OIDC-based replacement with
id-token: writeand a scoped trust policy would look like. - 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.
