# GITHUB_TOKEN read-only on fork pull requests in GitHub Actions

> GITHUB_TOKEN read-only on fork pull requests is a documented default, not a broken permissions block. Read the rule, then pick a trigger that writes.

Source: https://latchkey.dev/learn/github-actions/github-actions-token-push-403-fork  
Updated: 2026-09-20

GITHUB_TOKEN read-only on fork pull requests is a rule GitHub applies after it reads your permissions block, not a mistake inside it. The same job passes on a branch in the base repository and fails on a pull request opened from a fork, because the token it is handed is a different token.

## What this error means

One step fails and everything after it is skipped. The step is almost always a write: a `git push`, a comment posted through the API, a label added, a release created. The job is green on every pull request opened from a branch in the base repository and red on every pull request opened from a fork, with the same file and the same `permissions` block in both. A `git push` ends in the log below. Through the REST API the same refusal reads `Resource not accessible by integration`. Neither message names the fork, which is why the workflow file gets edited first and edited in vain.

```Actions log, quoted from hachyderm/ansible#27
##[group]Push the commit or tag
[command]/usr/bin/git push origin gh-pages
remote: Permission to hachyderm/ansible.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/hachyderm/ansible.git/': The requested URL returned error: 403
##[error]Action failed with "The process '/usr/bin/git' failed with exit code 128"
```

## Common causes

### The event is a fork pull request, so write was downgraded to read

The common one by a wide margin, and the only one that explains a job green from a branch and red from a fork with no change in between. It fires on `pull_request`, `pull_request_review` and `pull_request_review_comment` when the head repository is not the base repository. The job setup summary may still list a write permission, because the block was applied before the adjustment.

### A write step is sitting in a job that runs on the untrusted event

The build half of the job is worth running on a fork pull request. The publish half is not, and nothing in the file says so. With no `continue-on-error`, the first write to run takes the whole job red and skips everything after it, including the steps that would have told the contributor what went wrong.

### The repository setting that would allow write is not selected

The documented exception is a repository setting, **Send write tokens to workflows from pull requests**, and in our experience it is off almost everywhere, because turning it on hands a fork's pull request a write token. A guide claiming the `permissions` block should work was written for a repository with this selected.

### The token is being asked to reach outside its own repository

Worth ruling out before you blame the fork, because the message is identical. GitHub documents that the token's "permissions are limited to the repository that contains your workflow". A push to a second repository fails the same way from a branch as from a fork, and the fork is then a coincidence.

## How to fix it

### Confirm the downgrade before you touch the workflow file

1. Open the failing run and compare it against the last green run of the same workflow.
2. If the green one was on a branch in this repository and the red one came from a fork, the trigger is the cause and the `permissions` block is not.
3. If both came from forks, or both from branches, read the resource the step was reaching for instead: a token cannot write to a repository other than its own.

### Guard the write steps so the build still reports

The cheapest change and usually the right one. Keep the build running on fork pull requests, since that is the signal a contributor came for, and run the write steps only when the head repository is this repository.

```.github/workflows/docs.yml, guarded (illustrative)
- run: npm run docs:build
      - name: Publish
        if: github.event.pull_request.head.repo.full_name == github.repository
        run: |
          git config user.name github-actions[bot]
          git commit -am 'docs: rebuild'
          git push origin gh-pages
```

### Move the write into a second workflow on workflow_run

When the write is the point, split it. The first workflow builds on `pull_request` with a read-only token and uploads an artifact. The second runs on `workflow_run` and does the write. GitHub documents why: the workflow started by `workflow_run` "is able to access secrets and write tokens, even if the previous workflow was not. This is useful in cases where the previous workflow is intentionally not privileged, but you need to take a privileged action in a later workflow." Treat the artifact as untrusted data, because its contents came from the fork.

```.github/workflows/publish.yml (illustrative)
name: publish
on:
  workflow_run:
    workflows: [docs]
    types: [completed]

permissions:
  contents: write

jobs:
  publish:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: ./scripts/publish-artifact.sh
```

> Two limits worth knowing before you build on it. `workflow_run` is one of the low-trust triggers for caching, so by default the job gets read-only access to the default branch's cache scope and a save there fails with a warning; use `actions/cache/restore` in it, or declare a write-capable `cache-mode` on the job that has to save. And GitHub documents that "You can't use `workflow_run` to chain together more than three levels of workflows."

### Use an app installation token when the write must cross a boundary

A GitHub App installed on the repository issues its own installation token with its own permissions, and the fork rule does not apply to it because it is not `GITHUB_TOKEN`. It is also the answer when the write is to a different repository. It costs an app, a private key held as a secret, and the discipline to keep that secret out of any job that runs fork code.

## How to prevent it

- Split build from publish at the workflow level, not with a condition you have to remember to add to each new step.
- Put the fork condition on the job rather than on five steps, so a new write step inherits it.
- Prefer `workflow_run` over `pull_request_target` on public repositories, given the default policy and its enforcement date.
- When you do use `pull_request_target`, never check out the pull request head into the working directory.

## A workflow that produces it

This file is written for this page and has never been run. It is the ordinary shape: a build step, a publish step, and a `permissions` block that asks for write. On a branch in the base repository it works. On a pull request opened from a fork the publish step fails, and the block above it changes nothing.

```.github/workflows/docs.yml (illustrative)
name: docs
on:
  pull_request:

permissions:
  contents: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: npm run docs:build
      - run: |
          git config user.name github-actions[bot]
          git commit -am 'docs: rebuild'
          git push origin gh-pages
```

## The block is read, then overruled

GitHub's workflow syntax reference sets out the order in which a job's permissions are computed, and the fork rule is the last step in it. The permissions start at the enterprise, organization or repository default, are then "adjusted based on any configuration within the workflow file, first at the workflow level and then at the job level", and then: "Finally, if the workflow was triggered by a pull request event other than `pull_request_target` from a forked repository, and the **Send write tokens to workflows from pull requests** setting is not selected, the permissions are adjusted to change any write permissions to read only."

So the `permissions` block is not ignored. It is read, applied, and then walked back. The same reference says it plainly in its own section on forks: you "can use the `permissions` key to add and remove `read` permissions for forked repositories, but typically you can't grant `write` access." That is why editing the block is the one repair that never works, and why the job setup summary can print a write permission that the token no longer has.

## What each trigger actually hands the job

Three triggers can run in response to a fork pull request and they are not interchangeable. The table is the whole decision. Everything in it comes from GitHub's own reference pages, read on 2026-09-20.

|  | `pull_request` from a fork | `pull_request_target` | `workflow_run` |
| --- | --- | --- | --- |
| Workflow file that runs | The pull request merge commit | The base repository's default branch | The base repository's default branch |
| GITHUB_TOKEN | Write scopes downgraded to read | The base repository's token | Able to access write tokens |
| Repository and organization secrets | Withheld | Available | Available |
| Actions cache | Merge-ref scope, not affected by the low-trust rule | Default-branch scope, read only | Default-branch scope, read only by default |
| Blocked by default on public repos | No | Yes, in evaluate mode | No |

> The cache row is a different rule from the token row and it does not line up with it. GitHub restricts writes to the default branch's cache scope to a short list of triggers and names the low-trust ones it excludes, "such as `pull_request_target`, `issue_comment`, and `workflow_run`", then exempts the one most people expect to be caught: "The `pull_request` event is not affected. Caches created by a `pull_request` run are already scoped to the merge ref (`refs/pull/.../merge`) and cannot be written to the default branch's scope." When a read-only run does try to save, "the save fails but the step and the job do not", and the failure is "reported as a warning in the workflow log".

## pull_request_target is no longer the easy swap

The advice that circulated for years was to change the trigger to `pull_request_target` and carry on. GitHub now ships a policy against it. Its reference page says that "For public repositories that do not already have an applicable Actions event policy, GitHub adds a default policy that blocks workflows triggered by `pull_request_target`." The policy "Does not apply to private or internal repositories", "Does not replace an applicable event policy that you have already configured", and "Currently runs in **evaluate** mode. In this mode, workflow runs continue, but you can use policy insights to identify runs that would be blocked after enforcement."

The page also names the date: "On November 2, 2026, GitHub will enforce the default policy for affected repositories that were using the default `pull_request_target` policy before general availability." If you reach for this trigger on a public repository, you are choosing something that is scheduled to stop working unless you write an event policy that allows it. The reason is the older one: the workflow "is taken from the **base repository's default branch**, not from the pull request", so pointing `actions/checkout` at the pull request head and then running the result hands the fork your secrets. `actions/checkout` now gates that behind an input named `allow-unsafe-pr-checkout`, described in its own `action.yml` as "Required to check out fork pull request code from a workflow triggered by `pull_request_target` or `workflow_run`".

## Why there is no recorded run on this page

Every page here that carries a log from a job we ran says so. This one does not, and cannot. The decision is made by the Actions service when it mints the token for the job, using the event, the repository settings and the fork relationship. A script on a runner cannot cause it, cannot observe it beyond the 403 it gets back, and cannot undo it: a runner that could hand itself a write token would be the security hole this rule exists to close. The log above is quoted from a public repository's issue, and the workflows on this page are illustrative.

## FAQ

### Why does the permissions block not give the fork PR write access?

Because it is applied and then overruled. GitHub computes the job's permissions from the default, then the workflow file, then the job, and then adjusts: for a pull request event other than `pull_request_target` from a forked repository, with the **Send write tokens to workflows from pull requests** setting not selected, "the permissions are adjusted to change any write permissions to read only". The block never had a chance.

### What does "denied to github-actions[bot]" mean on a git push?

It means the token in the local git config is the job's `GITHUB_TOKEN` and it has no write to this repository. On a fork pull request that is the documented state. The push exits 128 and the action wrapping it reports `The process '/usr/bin/git' failed with exit code 128`, which is git's exit code, not a runner fault.

### Is pull_request_target still safe to use for fork pull requests?

It is still supported, but on public repositories GitHub now adds a default policy that blocks it, currently in evaluate mode, with enforcement announced for November 2, 2026. The policy does not apply to private or internal repositories and does not replace a policy you already configured. If you keep the trigger, allow it explicitly with an event policy and never execute the fork's code.

### Can a contributor from a fork see my repository secrets?

Not on a `pull_request` run. GitHub documents that "With the exception of `GITHUB_TOKEN`, secrets are not passed to the runner when a workflow is triggered from a forked repository." That is the same protection as the read-only token, and it is why a job that needs a secret has to move to a trusted trigger rather than gain one.

## References

- [GitHub Actions: workflow syntax, permissions and how they are calculated for a job](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions)
- [GitHub Actions: securely using pull_request_target, and the default blocking policy](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target)
- [GitHub Actions: events that trigger workflows, workflow_run](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run)
- [hachyderm/ansible#27: gh-pages push denied to github-actions[bot] on a fork pull request](https://github.com/hachyderm/ansible/issues/27)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
