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
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 lintWhen 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.
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.0Restrict GITHUB_TOKEN permissions
Declaring permissions at both the top-level and job level restricts the token to the minimum required scope for each job.
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 testGuard 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.
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