To limit GitHub Actions token permissions, set the default GITHUB_TOKEN to read access for repository contents, then raise permissions only on the specific job that needs them using the permissions key. Replace long-lived cloud secrets with OpenID Connect and pin third-party actions to full commit SHAs.
Why does the token matter so much?
Every workflow run gets a GITHUB_TOKEN. GitHub's documentation states that an action can reach it through the github.token context even when your workflow never passes it in. So a compromised or careless third-party step in a job inherits whatever that token can do.
The same security guide notes that anyone with write access to a repository can read all secrets configured in it. Together, those two facts set the rule: assume any step in a job can use everything the job holds, and give the job less.
What does a least privilege workflow look like?
Start at the top of the file with a restrictive default, then loosen one job at a time.
name: release
on:
push:
tags: ["v*"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-length-commit-sha>
- run: make test
publish-notes:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@<full-length-commit-sha>
- run: ./scripts/create-release.sh
Here the test job, which runs the most third-party code, only ever holds a read token. Only the small notes job can write. If the test job is compromised, it cannot push a tag or edit a release.
Note that when you set permissions at all, any scope you do not list is set to none. That is why listing exactly what a job needs works as an allowlist.
What about the repository and organization defaults?
GitHub's guide recommends making read access to repository contents the default for the GITHUB_TOKEN, and increasing it per job. You can set that default in repository or organization settings, which protects workflows nobody remembered to annotate. Treat the workflow file as the source of truth anyway, because settings can be changed by an admin without a code review.
Should you pin actions to a SHA?
GitHub's guide says to pin an action to a full-length commit SHA, because an attacker would need to produce a SHA-1 collision for a valid Git object to slip a backdoor in. Check that the SHA belongs to the action's own repository and not a fork. A tag is only acceptable if you trust the author, since tags can be moved or deleted.
Add a Dependabot version update entry for the github-actions ecosystem so the pinned SHAs keep moving forward instead of rotting.
How do you stop storing cloud keys?
If a job deploys to a cloud provider that supports OpenID Connect, let the workflow authenticate directly with a short-lived token instead of a stored access key. GitHub's guide points to this as the way to stop keeping credentials as long-lived secrets. The cloud side then limits which repository, branch or environment may assume the role.
Which triggers need extra care?
pull_request_target and workflow_run, when combined with checking out an untrusted pull request, are called out in GitHub's guide as exposing the repository to compromise, because those workflows are privileged and can reach secrets. If you use them, never run code from the pull request in the privileged context.
Also handle untrusted input safely. Do not paste values like a pull request title straight into a shell script. Pass them through an intermediate environment variable, or hand them to an action as an argument.
What to do
- Add a top-level
permissions: contents: readblock to every workflow. - Grant
writescopes only on the one job that needs each of them. - Pin every third-party action to a full-length commit SHA and let Dependabot update them.
- Replace stored cloud keys with OIDC and restrict the trust policy to specific repositories and environments.
- Audit
pull_request_targetandworkflow_runworkflows for untrusted checkouts. - Put
.github/workflowsunder CODEOWNERS so changes need a named reviewer.
Safeguard can help with step 3 and 5 at scale: its policies and gates can flag unpinned actions and risky workflow patterns in pull requests, and its dependency scanning covers what the workflow installs.
Quick check
If I set permissions on one job, do the others keep the default?
A job-level permissions block applies to that job. Jobs without one use the workflow-level block, or the repository default if the workflow sets none. Setting a top-level block avoids guessing.
Is a tag like v4 safe enough for an action?
Only if you trust the action's author, per GitHub's guidance. Tags can be moved or deleted. A full-length commit SHA cannot be silently repointed.
Sources
- Use GITHUB_TOKEN for authentication in workflows, GitHub Docs, fetched 18 October 2026
- Secure use reference, GitHub Docs, fetched 18 October 2026
Last reviewed: 18 October 2026.