>
Software

how to give a GitHub Actions workflow only the permissions it uses

A workflow that builds, tests, and deploys your code can also push tags, write to package registries, and rewrite release notes, all without anyone noticing. GitHub Actions will happily run a job that has more permissions than it needs, because the workflow still passes and the extra access is invisible until something goes wrong. Tightening that default is one of the highest-impact security improvements you can make to a repository, and the work itself is mostly reading the YAML and asking one question per job. The pattern below takes an existing workflow and turns it into a tighter version without rewriting the steps.

The pattern that works in practice is to treat each job as a separate trust boundary. A test job usually only needs to read code and upload artifacts. A release job may need to push a tag and create a release. A deployment job that reaches into AWS or another cloud should use short-lived credentials issued by OIDC (OpenID Connect, a standard that lets a workflow exchange its identity for a temporary access token) rather than a long-lived secret stored in the repository. The goal of this rewrite is to take an existing workflow and bring its permission model into line with what each job actually does.

Start by listing what each job does

Before you touch the YAML, write down what each job in the workflow actually does. A test job usually clones the repository, runs the test suite, and uploads a coverage report. A release job usually builds a binary, signs it, and pushes it to a registry. A deploy job usually authenticates to a cloud provider and runs a Terraform plan or applies a configuration change. That split is what tells you which permission scope each job needs.

For most workflows the audit looks like this:

  • Clone and run tests: contents: read is enough.
  • Upload build artifacts: no extra write scope on the repository token; the artifact upload has its own scope.
  • Push a tag or create a release: contents: write for that job only.
  • Publish to a package registry: the registry-specific scope for that job only.
  • Comment on a pull request: pull-requests: write for that job only.
  • Reach a cloud provider with OIDC: no long-lived credentials, just the workflow identity.

The point is to make the permission scope per job, not per workflow. A workflow-level permissions: write-all is the failure mode you are trying to remove.

Three concrete edits you can make today

There are three changes that catch most of the risk without much work.

Set a workflow-level default of permissions: read-all or permissions: {}. This makes every job start with the smallest possible scope and forces each job that needs more to declare it explicitly. The choice between read-all and {} is a matter of taste. read-all is more forgiving when a job needs to read metadata and forgets to declare it; {} is stricter because it forces every job to declare everything it needs.

Override permissions at the job level for the jobs that need write access. A release job that creates a GitHub release needs contents: write. Set that on the job, not on the workflow, and the test job stays read-only. A job that comments on a pull request needs pull-requests: write. Set that on the job.

Replace stored cloud credentials with OIDC where your cloud supports it. AWS, Azure, GCP, and most major clouds now accept OIDC tokens issued by GitHub Actions. The workflow presents its identity, the cloud’s trust policy evaluates it, and the workflow gets a short-lived token with the exact scope its role grants. The long-lived access key in the repository secrets becomes unnecessary and can be deleted.

A common pattern that uses all three looks like this:

name: build-test-deploy
on:
  push:
    branches: [main]

permissions: {}

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

  release:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - run: npx semantic-release

The test job has read-only access. The release job has write access to create a release. No job has more scope than it needs.

How to verify the tightening did not break the workflow

The verification step matters because a workflow that fails because you removed too much access is the moment most teams revert the change and call it a day. The cleaner approach is to tighten incrementally and watch the run.

  • Run the workflow on a pull request branch and look at the job logs. Each step that needs write access will fail with a permission error if you removed too much, and the error message will tell you which scope the step needs.
  • Read the audit log for the workflow in the GitHub Actions UI. The “Workflow permissions” view shows which scope each job used. A test job that used contents: write is a sign that a step asked for more than it needed.
  • Use GITHUB_TOKEN permissions in the step output if your step is sensitive to the scope. Most steps just need to know whether the token can write or only read.
  • Re-run the workflow after each tightening to confirm the previous step still passes. Do not batch tightenings into one commit; the diff per commit should be reviewable and small enough to revert without drama.

The worst outcome is to remove a permission and discover it was load-bearing for a step you did not know about. Catching that in a branch build is fine. Catching it in a production deploy is not.

Trade-offs

A few honest limits to keep in mind.

The per-job permissions model works for most workflows, but it gets awkward when a single job genuinely needs several scopes at once. A job that comments on issues, writes to discussions, and publishes a package has three scopes to declare, and the YAML starts to look like a permission inventory. The right response is to split the job into three jobs that share a build artifact, not to widen the scope of one job.

OIDC is the right default for cloud access, but it requires the cloud side to be configured with a trust policy that accepts the workflow’s identity. If your cloud account does not have that policy yet, the migration is a separate piece of work and you cannot just flip a switch. The intermediate state is to use a fine-grained personal access token or a GitHub App installation token instead of a long-lived secret, and to scope that token as tightly as you can.

Permissions hardening does not fix a compromised third-party action. If a step runs actions/checkout@v4 and the action is compromised, the workflow inherits the compromise. The hardening above reduces the blast radius of the compromise; it does not remove the dependency. Pin actions by full-length commit SHA rather than by tag, review action updates before bumping, and treat any new action added to the workflow as a code change that needs review.

The tradeoff for tighter permissions is operational. A workflow that used to silently work because the default was permissive now has to declare every scope explicitly. That is the right kind of friction, but it is still friction, and a team that adopts the pattern needs to budget a half-day per workflow for the audit. The first few workflows take longer than the twentieth, because the team learns to read the YAML the same way they read application code: every line is a permission grant, every grant should match a step that needs it.

Leave a comment