◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-06-01Intermediate

Fixing 403 Write Access to Repository Not Granted When Pushing from Claude Code

When Claude Code automation pushes to GitHub and hits a 403 'Write access to repository not granted', the cause usually lies in how fine-grained PATs differ from classic ones. Here's how to diagnose repository access, Contents permissions, and org approval.

troubleshooting92claude-code132github3git18fine-grained-pat403

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 main

Quick triage table

When you run into a 403, working through these in order gets you to the cause fastest.

  1. Does api.github.com/user return 200? → If no, it's a 401 (token string or expiry).
  2. Is permissions.push true on repos/.../? → If false, Contents permission is insufficient.
  3. For fine-grained tokens, is the repository in the selection? → If missing, add it.
  4. 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Claude Code2026-05-24
When Claude Code's Sequential Edits Cascade into Failure — Fixing 'old_string not found' and 'not unique' on the Same File
Run several Edits against the same file in one turn and the second or third one suddenly fails with 'not found' or 'not unique'. The cause is order-dependence inside a single context snapshot, and a Read + scoped patches usually rescue it.
⟐ Claude Code2026-05-10
When Claude Code's Read Doesn't Reach the End of a File — Catching the PARTIAL view Notice and Sizing offset/limit
When Read returns only part of a large file, Claude Code says so with a PARTIAL view notice. Here is how to catch it, and how to size offset and limit by byte budget rather than line count, with measurements from my own sandbox.
⟐ Claude Code2026-05-10
Why Claude Code's Glob and Grep Return Zero Results
When Claude Code's Glob or Grep tool returns no matches even though the files clearly exist, the cause is almost always one of four things. Here's how to triage.