GitHub Actions checkout persisted credentials push 403 in CI
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.
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: 403What 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.
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
- If the job's own token is the right identity, give the job
contents: writeand leave the checkout alone. - If a different identity is required, pass that credential to the checkout step's
tokeninput so it is the one persisted. - Only if you want no ambient credential at all, set
persist-credentials: falseand 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.
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 pushPersist 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.
- uses: actions/checkout@v5
with:
token: ${{ secrets.RELEASE_APP_TOKEN }}
- run: git push origin HEAD:mainTurn 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.
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 |
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.
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: writeon 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.
Frequently asked questions
Where exactly does checkout put the token?
.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?
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?
Why does the message name github-actions[bot] rather than my workflow?
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.Related guides
References
- actions/checkout: src/git-auth-helper.ts, where the extraheader is built and written
- actions/checkout: action.yml, the token and persist-credentials inputs
- actions/checkout#1687: the open proposal to default persist-credentials to false
- SharpAstro/tianwen#302: a push refused after checkout persisted the job token
- GitHub Actions documentation