Securing Workflows: Least Privilege & Pinned Actions

Lock down GITHUB_TOKEN, pin third-party actions to a SHA, and keep untrusted PR code from stealing secrets.

A workflow runs code with access to a token and possibly your secrets. Treat it like production. Three habits cover most of the risk.

1. Least-privilege GITHUB_TOKEN

By default the automatic GITHUB_TOKEN may be broad. Set permissions: read-all (or an empty block) at the top and grant only what each job truly needs, e.g. contents: read plus pull-requests: write. A read-only token can't be abused to push tags or edit releases.

2. Pin actions to a full SHA

A tag like @v4 is mutable — the owner (or an attacker who compromises them) can move it to malicious code that runs in your pipeline. Pin third-party actions to a full commit SHA and let Dependabot bump them deliberately.

3. Beware pull_request_target

pull_request_target runs with write permissions and access to secrets in the base repo's context — but for a PR whose code you don't control. Never check out and execute untrusted PR code in that context. For untrusted contributions use plain pull_request, which has no secrets and a read-only token.

Example

Example · yaml
name: PR checks
on: [pull_request]

# Default everything to read-only; grant more only where needed
permissions:
  contents: read

jobs:
  lint:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write   # just enough to leave review comments
    steps:
      # Third-party action pinned to a full commit SHA, not @v4
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
        with:
          node-version: 20
      - run: npm ci && npm run lint

When to use it

  • A security team pins all third-party actions to full commit SHAs so a compromised action tag cannot silently inject malicious code into the pipeline.
  • A developer scopes GITHUB_TOKEN to the minimum permissions needed for the job, reducing the blast radius if the token is exfiltrated.
  • An organisation enforces that untrusted pull requests from forks cannot access repository secrets by checking github.event.pull_request.head.repo.fork.

More examples

Pin actions to commit SHA

A tag like @v4 can be moved to a different commit by the action author; pinning the SHA makes the exact version immutable.

Example · yaml
steps:
  # Pinned to a specific commit SHA, not a mutable tag
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
  - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af  # v4.1.0

Restrict GITHUB_TOKEN permissions

Declaring permissions at both the top-level and job level restricts the token to the minimum required scope for each job.

Example · yaml
permissions:
  contents: read          # only what is needed

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write  # allow posting a comment
    steps:
      - uses: actions/checkout@v4
      - run: npm test

Guard secrets from fork PRs

Fork PRs run in a restricted environment where secrets are not available; this condition also prevents the deploy step from running for fork-sourced code.

Example · yaml
jobs:
  deploy:
    # Only run if the PR is from the same repo (not a fork)
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    steps:
      - run: ./scripts/deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Discussion

  • Be the first to comment on this lesson.