Every GitHub Actions job gets a token. GitHub creates a fresh GITHUB_TOKEN at the start of each job, scoped to the repository the workflow lives in, and any step in the job can use it. What it can do depends on settings many teams have never looked at, and on older repositories the answer is still "write to almost everything".
That matters when something in the job turns malicious. In the August 2025 Nx "s1ngularity" compromise, the repository's token still had read and write permissions. An injection in a pull_request_target workflow used it to trigger the publish workflow, which leaked the npm token used to publish malicious versions of Nx. A read-only token would have stopped that step.
This post covers how the token's permissions are set, what each common task needs, and how to find the minimum for your workflows.
What GITHUB_TOKEN is
GITHUB_TOKEN is an installation access token of the GitHub Actions app. A few properties are easy to miss:
- It exists whether you use it or not. Workflows reference it as
secrets.GITHUB_TOKEN, but any action can read it through thegithub.tokencontext without being passed it. - It is written to disk by default.
actions/checkoutpersists the token for later git commands unless you setpersist-credentials: false. Before v6 it went into.git/config; v6 moved it to a separate file under$RUNNER_TEMP. In 2024 Unit 42's ArtiPACKED research found tokens leaking through artifacts that included.git/config. - It expires with the job. On GitHub-hosted runners that means at most 6 hours, the job time limit. On self-hosted runners it can be refreshed for up to 24 hours.
Because the token is always there, the only thing you control is how much it can do.
The default: permissive or restricted
Each enterprise, organization and repository has a default for the token:
- Permissive: read and write on every permission.
- Restricted: read-only on
contentsandpackages, nothing else.
Since February 2, 2023, new enterprises, organizations and repositories default to restricted. The change did not touch existing ones, so any repository or organization created before then keeps the permissive default unless someone changed it. If a higher level chooses restricted, the levels below it cannot choose permissive.
Check it under Settings → Actions → General → Workflow permissions, at the organization level first. The same section has Allow GitHub Actions to create and approve pull requests, which should stay off unless a workflow needs it.
The permissions key
The permissions key in a workflow adjusts the token from the default. It works at two levels:
permissions:
contents: read
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-commit-sha>
The rules:
- Job level overrides workflow level. Set the workflow default to read-only and raise permissions only in the jobs that need them.
- Anything you don't list becomes
none. As soon as you set one permission, every other one is removed.permissions: {}removes them all. - Values are
read,writeornone.writeincludesread.read-allandwrite-allset everything at once; avoidwrite-all.
The available permissions, as of September 2026:
| Permission | Controls |
|---|---|
actions | Workflow runs, artifacts and caches |
artifact-metadata | Artifact metadata records (replaced contents for these APIs in 2026) |
attestations | Artifact attestations |
checks | Check runs and check suites |
code-quality | Code quality results, such as coverage reports |
contents | Repository contents, commits, branches, tags and releases |
deployments | Deployments |
discussions | Discussions |
id-token | Requesting an OIDC token (write or none only) |
issues | Issues and comments |
packages | GitHub Packages, including the container registry |
pages | GitHub Pages builds |
pull-requests | Pull requests, labels and reviews |
security-events | Code scanning alerts and SARIF uploads |
statuses | Commit statuses |
vulnerability-alerts | Dependabot alerts (read or none only) |
When a job lacks a permission it needs, the API call fails with 403 Resource not accessible by integration. That error is the signal to add the one permission the step needs, not to switch to write-all.
What common tasks need
| Task | Permissions |
|---|---|
| Check out code | contents: read |
| Create a release or push a tag | contents: write |
| Comment on an issue or pull request | issues: write (pull request comments go through the issues API) |
| Add labels or reviews to a pull request | pull-requests: write |
| Authenticate to a cloud provider with OIDC | id-token: write |
| Create artifact attestations | id-token: write, attestations: write, contents: read, plus packages: write for container images |
| Push an image to GitHub Container Registry | packages: write |
| Upload SARIF to code scanning | security-events: write |
| Deploy GitHub Pages from a workflow | pages: write, id-token: write |
Most test and build jobs need nothing beyond contents: read.
Forks, pull_request_target and Dependabot
- Pull requests from forks get a read-only token on the
pull_requestevent, whatever the workflow asks for, unless an admin turns on "Send write tokens to workflows from pull requests". Leave it off. pull_request_targetgets a read and write token even for pull requests from public forks. That is why it is behind many of the best-known token thefts: the Nx compromise, the Ultralytics compromise in December 2024 and the Trivy action compromise in March 2026 all started with apull_request_targetworkflow. GitHub is narrowing it: since July 2026actions/checkoutrefuses to check out fork code in these workflows by default, and from November 2, 2026, public repositories without their own policy will havepull_request_targetdisabled by default.- Dependabot pull requests are treated like forks: a read-only token and no Actions secrets. If you're weighing other update tools, see Dependabot alternatives.
How to find the minimum permissions
You don't have to work this out by reading every action's source:
- GitHub Security Lab's actions-permissions monitors how a workflow uses its token and recommends the minimum permissions. It is in public beta.
- OpenSSF Scorecard's Token-Permissions check flags workflows that don't set read-only permissions at the top level. It rates the check high risk.
- zizmor, a static analyzer for workflows, flags excessive or default permissions and checkouts that persist credentials.
- StepSecurity's Secure-Repo and Harden-Runner recommend job-level permissions from a knowledge base of actions and from the API calls a job makes.
A practical rollout: set the organization default to restricted, add permissions: contents: read at the top of every workflow, and run the workflows. The ones that fail with Resource not accessible by integration tell you exactly which job needs which extra permission.
Where CRACI fits
Least privilege limits what a stolen token can do. It doesn't stop it being stolen, and the other secrets in the job, such as npm tokens and cloud credentials, are just as exposed.
CRACI runs your GitHub Actions jobs on its own runners. You change runs-on to craci; GITHUB_TOKEN, the permissions key and your repository settings work as before, because GitHub still issues the token. What CRACI adds is on the runner: an egress policy that decides which hosts a job may reach, is validated before the job starts and fails closed, and a record of every package the job fetched, with a trace of its network connections. If malicious code in a job does get hold of a token, the policy limits where it can send it, and the record shows what happened.
For the rest of the job's secrets, see GitHub Actions secrets: how they leak and how to lock them down. Book a demo to see the record of one of your own builds.
