# GitHub Actions checkout persisted credentials push 403 in CI

> A GitHub Actions checkout persisted credentials push 403 reuses the credential checkout wrote into git config. The push command is not the fix.

Source: https://latchkey.dev/learn/github-actions/github-actions-checkout-persisted-credentials-403  
Updated: 2026-09-20

A GitHub Actions checkout persisted credentials push 403 happens because `actions/checkout` wrote a credential into the repository's local git config and every later git command reuses it. The push is refused for what that credential is allowed to do, which was decided in the checkout step rather than in the push step.

## What this error means

A `git push` several steps after checkout fails with a refusal naming `github-actions[bot]`, and git exits 128. The push command itself is ordinary and works from a laptop. Adding a token to the remote URL changes nothing, and neither does re-running. The confusing part is that nothing in the failing step mentions a credential at all, because the credential was configured earlier and silently.

```Actions log, quoted from SharpAstro/tianwen#302
remote: Permission to SharpAstro/tianwen.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/SharpAstro/tianwen.git/': The requested URL returned error: 403
```

## Common causes

### The job has a read-only token and the persisted credential inherited that

The common case. `token` defaults to `${{ github.token }}`, so the header carries the job's own token with the job's own permissions. If the job never declared `contents: write`, the credential written at checkout cannot push, and nothing later in the job can upgrade it.

### The run is a fork pull request, so write was downgraded before checkout

The token handed to the job was already read-only when checkout persisted it. This looks identical in the log and is not fixed by a permissions block, because the downgrade happens after that block is applied. The tell is that the same workflow pushes successfully from a branch in the base repository.

### A stronger token was supplied to the push but not to the checkout

The workflow has a personal access token or an app token in a secret and uses it on the push while letting checkout persist the default. The origin-scoped header still applies, so the stronger credential never gets a chance. In our experience this is the most frustrating version, because the right token is present in the file.

### The push targets a second repository on the same host

The persisted header is keyed to the origin rather than to the repository, so it is sent to every github.com URL. A push to another repository therefore authenticates as the first repository's token, which has no rights there, and the refusal names the bot rather than the repository mismatch.

## How to fix it

### Decide which identity should own the later git commands

1. If the job's own token is the right identity, give the job `contents: write` and leave the checkout alone.
2. If a different identity is required, pass that credential to the checkout step's `token` input so it is the one persisted.
3. Only if you want no ambient credential at all, set `persist-credentials: false` and configure the remote explicitly in the step that pushes.

### Grant the permission on the job

The smallest repair for the common case. Declare it on the job that pushes so other jobs keep the read-only default.

```.github/workflows/update.yml (illustrative)
jobs:
  update:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v5
      - run: |
          git config user.name 'github-actions[bot]'
          git config user.email 'github-actions[bot]@users.noreply.github.com'
          git commit -am 'chore: refresh generated files'
          git push
```

### Persist the credential you actually want

When the push must be authored by an app or a service account, hand that token to checkout rather than to the push. The header then carries it from the start and there is only ever one credential in play.

```.github/workflows/release.yml (illustrative)
- uses: actions/checkout@v5
        with:
          token: ${{ secrets.RELEASE_APP_TOKEN }}
      - run: git push origin HEAD:main
```

### Turn persistence off when the repository is only being read

For jobs that build and never push, `persist-credentials: false` removes an ambient credential from every later step, which is the reason the change keeps being proposed. Expect a different error if something does try to push afterwards, naming a missing user name rather than a refused permission.

## How to prevent it

- Treat the checkout step as the place where a job's git identity is chosen, and write the intended token there.
- Never combine a token in a remote URL with a persisted header for the same host.
- Declare `contents: write` on the job that pushes rather than at the workflow level.
- When a workflow pushes to a second repository, check out that repository separately with its own credential.

## What checkout writes, and where

The mechanism is small and entirely local. `git-auth-helper.ts` computes a config key of `http.${serverUrl.origin}/.extraheader` and a value of `AUTHORIZATION: basic ` followed by a base64 encoding of `x-access-token:` and the step's `token` input. It writes that pair into the checked-out repository's own git config and registers the value as a secret so it is masked in the log.

Two consequences follow. The first is that every later git command in that working directory authenticates as whatever `token` was at checkout time, with no further configuration and no mention of it. The second is that the key is scoped to the origin, so it applies to any https URL on that host, which is why pushing to a different remote on github.com picks up the same header.

## What the persist-credentials value changes

The input decides whether the header is written at all, and that changes the failure you get rather than removing failure. Both columns below are ordinary outcomes, not bugs.

| `persist-credentials` | Written into the local git config | How a later push fails |
| --- | --- | --- |
| `true`, the default | An `.extraheader` for the server origin, carrying the step's token | A 403 naming the identity, because the credential exists and is not allowed |
| `false` | Nothing; the header is removed after fetching | A prompt failure such as `could not read Username`, because there is no credential at all |
| `true` with a `token` you supplied | An `.extraheader` carrying your token rather than the job's | Whatever that identity is refused, which is the point of supplying it |

> The default has not changed. Reading `action.yml` on the v4, v5 and v6 refs and on `main`, `persist-credentials` is declared with `default: true` in every one of them. There is an open proposal to flip it to `false`, which is often quoted as though it had landed; at the time of writing it is still an open pull request rather than released behavior, so do not assume a new major turned it off for you.

## Why adding a token to the push does not help

The usual next attempt is to rewrite the remote URL with a personal access token embedded in it, or to pass one on the command line. That frequently leaves the 403 exactly as it was, and the reason is the config key. An `extraheader` for the origin is sent as an HTTP header on every request to that host, and it is applied regardless of what the URL contains. You have added a second credential without removing the first.

The repair is therefore to change what checkout persists, not what the push carries. Give the checkout step the token you actually want later commands to use, or turn persistence off and configure the remote deliberately. Both work; mixing them is what does not.

## Why there is no recorded run on this page

The refusal is issued by GitHub's git server, about one repository, for one identity, and it is the same two lines whatever produced it. Recording a push that our own runner was not allowed to make would show you our repository name inside a message you already have with yours in it. The part that is worth establishing is what is in the git config and how it got there, and that is fixed in the action's source, which we read instead. The excerpt above is quoted from a public issue.

## FAQ

### Where exactly does checkout put the token?

In the local git config of the repository it checked out, under a key built from the server origin and ending in `.extraheader`. The value is an HTTP Authorization header using basic auth, whose user name is the literal string `x-access-token` and whose password is the step's `token` input. The post-job step removes it.

### Is persist-credentials still true by default?

Yes. It is declared `default: true` in `action.yml` on the v4, v5 and v6 refs and on `main`. A pull request proposing `false` has been open for some time and is frequently cited as though it had shipped. Check the ref you actually pin rather than relying on a major number.

### Why does putting a PAT in the remote URL not fix it?

Because the persisted header is attached per host, not per URL, and it is sent regardless of what the URL contains. You end up with two credentials offered for the same request and the configured header is the one that decides. Supply the PAT to the checkout step instead, so only one credential exists.

### Why does the message name github-actions[bot] rather than my workflow?

Because that is the identity the automatic token belongs to. `GITHUB_TOKEN` is an installation token for the Actions app on your repository, so refusals name the app installation. Seeing the bot in the message tells you the persisted credential was the job default rather than a token you supplied.

## References

- [actions/checkout: src/git-auth-helper.ts, where the extraheader is built and written](https://github.com/actions/checkout/blob/main/src/git-auth-helper.ts)
- [actions/checkout: action.yml, the token and persist-credentials inputs](https://github.com/actions/checkout/blob/main/action.yml)
- [actions/checkout#1687: the open proposal to default persist-credentials to false](https://github.com/actions/checkout/pull/1687)
- [SharpAstro/tianwen#302: a push refused after checkout persisted the job token](https://github.com/SharpAstro/tianwen/issues/302)

---

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
