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 the github.token context without being passed it.
  • It is written to disk by default. actions/checkout persists the token for later git commands unless you set persist-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 contents and packages, 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, write or none. write includes read. read-all and write-all set everything at once; avoid write-all.

The available permissions, as of September 2026:

PermissionControls
actionsWorkflow runs, artifacts and caches
artifact-metadataArtifact metadata records (replaced contents for these APIs in 2026)
attestationsArtifact attestations
checksCheck runs and check suites
code-qualityCode quality results, such as coverage reports
contentsRepository contents, commits, branches, tags and releases
deploymentsDeployments
discussionsDiscussions
id-tokenRequesting an OIDC token (write or none only)
issuesIssues and comments
packagesGitHub Packages, including the container registry
pagesGitHub Pages builds
pull-requestsPull requests, labels and reviews
security-eventsCode scanning alerts and SARIF uploads
statusesCommit statuses
vulnerability-alertsDependabot 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

TaskPermissions
Check out codecontents: read
Create a release or push a tagcontents: write
Comment on an issue or pull requestissues: write (pull request comments go through the issues API)
Add labels or reviews to a pull requestpull-requests: write
Authenticate to a cloud provider with OIDCid-token: write
Create artifact attestationsid-token: write, attestations: write, contents: read, plus packages: write for container images
Push an image to GitHub Container Registrypackages: write
Upload SARIF to code scanningsecurity-events: write
Deploy GitHub Pages from a workflowpages: 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_request event, whatever the workflow asks for, unless an admin turns on "Send write tokens to workflows from pull requests". Leave it off.
  • pull_request_target gets 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 a pull_request_target workflow. GitHub is narrowing it: since July 2026 actions/checkout refuses to check out fork code in these workflows by default, and from November 2, 2026, public repositories without their own policy will have pull_request_target disabled 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.