The other day I was running a setup that updates several repositories automatically from Claude Code, and one repository stalled right at git clone. The message that came back was:
remote: Write access to repository not granted.
fatal: unable to access 'https://github.com/youruser/yourrepo.git/': The requested URL returned error: 403
The token had been regenerated just the day before, and every other repository was pushing fine through the exact same setup. Yet this one repository returned a 403. The cause turned out to be a misunderstanding of how GitHub's fine-grained personal access tokens handle permissions. Anyone automating repository operations from Claude Code can fall into the same trap, so let me walk through how to tell the errors apart and the order in which to check things.
First, separate 401 from 403 — they are not the same problem
The first thing to do is determine whether the error is an authentication failure or an authorization failure. Confuse the two and you'll regenerate tokens endlessly without fixing anything.
fatal: Authentication failed/ 401 — the token isn't being accepted by GitHub at all. Usually a mistyped string, an expired token, or a stray newline introduced when copying.Write access to repository not granted/Resource not accessible by personal access token/ 403 — the token is valid, but it has no write (or even read) permission tied to that specific repository.
What I hit was the latter, a 403. The token was alive — git ls-remote even succeeded in some cases — yet only the push was rejected. That pattern points squarely at a permission-scope problem.
As a first step, confirm only whether the token authenticates at all.
# Is authentication succeeding? (distinguishes 401 from 403)
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
https://api.github.com/user
# A 200 means authentication works — the token itself is valid.If this returns 200 but the push still returns 403, the problem isn't token validity. It's access to that particular repository.
The root cause — classic and fine-grained PATs use different permission models
This is the heart of what tripped me up. GitHub offers two kinds of PAT, and they think about permissions in completely different ways.
Classic PATs (starting with ghp_) grant read and write across every repository the account can reach once you tick the repo scope. There's no notion of choosing repositories individually, so a token you created once keeps working for newly added repositories too.
Fine-grained PATs (starting with github_pat_) require you to explicitly choose which repositories and which operations the token may perform, at creation time. If you forget to select a target repository — or a repository created later was never added to the selection — the token stays valid but returns 403 for that repository alone.
My case was exactly this: when I regenerated the token, I had missed selecting one repository in the list. The reason it behaved differently from another token where git ls-remote worked was simply that the other token was a classic PAT covering all repositories.
Checklist when using a fine-grained PAT
When a fine-grained PAT returns 403, check these three things, top to bottom.
1. Is the target repository included in Repository access?
In GitHub, go to Settings → Developer settings → Personal access tokens → Fine-grained tokens, open the token, and check Repository access. If you chose Only select repositories, the repository you want to operate on must be in the list. If it isn't, add it. For an organization's repositories, even All repositories requires that the organization itself appears as an option.
2. Is the Contents permission set to Read and write?
Fine-grained PATs split permissions per operation. git clone / git pull need Contents Read, while git push needs Contents Read and write. If Repository permissions → Contents is left at Read-only, you can clone but the push returns 403. That's the real explanation for the "I can read it but I can't write it" state.
# Check directly whether the token has rights to the target repository
curl -s -H "Authorization: Bearer ${GITHUB_TOKEN}" \
https://api.github.com/repos/youruser/yourrepo \
| grep -E '"(full_name|permissions)"' -A4
# Look at "permissions": { "push": true, ... }
# "push": false means Contents is Read-only.If you see "push": false, change Contents to Read and write in the token settings. Note that fine-grained PATs can take a few dozen seconds to reflect a permission edit.
3. Is it waiting on organization approval?
If the repository is owned by an organization, a fine-grained PAT won't work until the organization approves it. If the token shows as Pending in the token list, an org administrator needs to approve it. For repositories under a personal account, this step doesn't apply.
Practical ways to keep automation running
As an indie developer who has shipped apps since 2014 (Dolice, with tens of millions of downloads across them), I update several repositories through one shared setup — which leaves a standing risk of forgetting to update the fine-grained PAT's selection whenever I add a repository. So in practice I settled on a split: assign a classic PAT (with the repo scope) to automation that spans many repositories, and reserve fine-grained PATs for cases where I want to expose just one repository externally. Fine-grained errs on the safe side, which is exactly why a missed selection surfaces as a silent 403.
When you need a push to go through urgently, the fastest path is to temporarily swap in another classic PAT on the same account that already covers all repositories. Then, calmly fix the Repository access and Contents permission on the fine-grained PAT you actually intended to use.
# Push via an environment variable rather than embedding the token in the URL
# (keeps the token out of shell history and process listings)
git remote set-url origin "https://github.com/youruser/yourrepo.git"
git -c credential.helper='!f() { echo "username=x-access-token"; echo "password=${GITHUB_TOKEN}"; }; f' \
push origin mainQuick triage table
When you run into a 403, working through these in order gets you to the cause fastest.
- Does
api.github.com/userreturn 200? → If no, it's a 401 (token string or expiry). - Is
permissions.pushtrue onrepos/.../? → If false, Contents permission is insufficient. - For fine-grained tokens, is the repository in the selection? → If missing, add it.
- If org-owned, is approval
Pending? → If so, ask an admin to approve.
Before you regenerate a token, try these four checks first. More often than not the token itself is fine, and a single permission selection is simply missing. I hope this saves a little time for anyone stuck at the same spot.