
Private repository git fetch origin returns 403 — The pitfalls of URL-embedded PATs and countermeasures
This page has been translated by machine translation. View original
Introduction
When setting up CI/CD scripts or release automation in GitHub Organization private repositories, you may encounter an error like this:
remote: Write access to repository not granted.
fatal: unable to access 'https://github.com/YOUR-ORG/your-repo.git/': The requested URL returned error: 403
git push succeeds, but the immediately following git fetch origin returns 403. This seemingly contradictory behavior is caused by the combination of a push method that embeds a PAT (Personal Access Token) in the URL and an origin remote that holds no authentication credentials.
This article explains why this problem occurs and introduces the $AUTHED_ORIGIN pattern for safely performing git operations on private repositories.
Prerequisites
- GitHub Organization private repository
- PAT managed in 1Password (operation without saving tokens in git config)
- Custom wrapper function for
ghCLI for Organization use (gh-org) to retrieve tokens (described later) - macOS / zsh
What Is Happening
Typical push script
When pushing to an Organization repository, since git push origin main fails with an authentication error, a method of embedding the PAT in the URL for pushing was adopted.
source ~/.zshrc
GH_TOKEN=$(gh-org auth token)
AUTHED_ORIGIN=$(git remote get-url origin | sed "s|https://|https://YOUR_USERNAME:${GH_TOKEN}@|")
git push "$AUTHED_ORIGIN" main
Please replace YOUR_USERNAME with your own GitHub username. This method itself works fine. However, URL-based push has a side effect: local tracking refs are not updated. If you run git status after pushing, it will display "Your branch is ahead of 'origin/main' by N commits" even though the push succeeded.
403 when trying to update refs with fetch
To resolve this side effect, git fetch was being run immediately after push.
git push "$AUTHED_ORIGIN" main && git fetch origin # ← 403 here
git push succeeds because it uses $AUTHED_ORIGIN (URL with token). However, git fetch origin uses the remote URL registered in git config as-is.
# origin remote URL (without token)
https://github.com/YOUR-ORG/your-repo.git
For public repositories, fetch is possible without authentication, but private repositories require authentication even for reading. As a result, 403 is returned.
Diagram of the problem

Solution: Use $AUTHED_ORIGIN for all git operations
The fix is simple. Unify all places that used origin to use $AUTHED_ORIGIN instead.
source ~/.zshrc
GH_TOKEN=$(gh-org auth token)
AUTHED_ORIGIN=$(git remote get-url origin | sed "s|https://|https://YOUR_USERNAME:${GH_TOKEN}@|")
# Use the same authenticated URL for both push and fetch
git push "$AUTHED_ORIGIN" main && git fetch "$AUTHED_ORIGIN"
Same applies to pull / fetch --prune
The same applies when running pull or prune in release scripts or similar.
# NG: origin has no token → 403
git pull origin main && git fetch --prune origin
# OK: consistently use authenticated URL
git pull "$AUTHED_ORIGIN" main && git fetch --prune "$AUTHED_ORIGIN"
Why not set the token in origin
You might think, "Why not just set the token-embedded URL with git remote set-url origin?" However, this approach is avoided for the following reasons.
| Method | Advantages | Disadvantages |
|---|---|---|
Embed token with git remote set-url |
origin can be used as-is for subsequent operations |
Token remains in .git/config in plain text |
Environment variable $AUTHED_ORIGIN |
Token does not remain in files | Need to explicitly specify $AUTHED_ORIGIN for all operations |
| git credential helper | origin can be used as-is, token also managed in keychain |
Switching between Organization PAT and personal PAT is cumbersome |
The $AUTHED_ORIGIN method, which retrieves the Organization PAT managed in 1Password each time and uses it as a one-time value, is the most secure in terms of security. The trade-off is the effort required to unify origin to $AUTHED_ORIGIN throughout scripts.
The && chain trap
There is another easy-to-miss problem. If git fetch origin fails with 403, subsequent commands in the && chain will not be executed.
# fetch fails with 403 → tags push is not executed
git push "$AUTHED_ORIGIN" main && git fetch origin && git push "$AUTHED_ORIGIN" --tags
When actually encountering this problem, the situation was "push succeeded but tags were not pushed." The error message reported the 403 from git fetch origin, but it was easy to miss that tags had not been pushed.
As a countermeasure, either separate the tags push from fetch, or fix fetch to use $AUTHED_ORIGIN.
# Fix fetch (recommended)
git push "$AUTHED_ORIGIN" main && git fetch "$AUTHED_ORIGIN"
git push "$AUTHED_ORIGIN" --tags
# Or, allow fetch failure
git push "$AUTHED_ORIGIN" main
git fetch "$AUTHED_ORIGIN" || true
git push "$AUTHED_ORIGIN" --tags
Token retrieval pattern with gh CLI wrapper
For reference, here is the pattern of the gh-org wrapper that retrieves the Organization PAT from 1Password.
# Wrapper to run gh with Organization PAT managed in 1Password
gh-org() {
GH_TOKEN=$(op read "op://Private/GitHub Org PAT/token") gh "$@"
}
# Retrieve token
GH_TOKEN=$(gh-org auth token)
# Build authenticated URL
AUTHED_ORIGIN=$(git remote get-url origin | sed "s|https://|https://YOUR_USERNAME:${GH_TOKEN}@|")
gh-org auth token runs gh auth token with the GH_TOKEN environment variable set, so the PAT retrieved from 1Password is returned as-is.
Summary
| Situation | Cause | Solution |
|---|---|---|
git fetch origin returns 403 |
Fetch without authentication on private repository | Use $AUTHED_ORIGIN |
git status shows "ahead" after push |
URL-based push does not update tracking refs | Run git fetch "$AUTHED_ORIGIN" after push |
Subsequent commands in && chain are not executed |
Chain interrupted by fetch failure in the middle | Fix fetch or separate it |
The key point is the obvious fact that "private repositories require authentication even for reading." The asymmetric configuration where only git push uses the authenticated URL while git fetch uses the plain origin was the root cause of this problem.
When adopting the method of embedding a PAT in the URL, make sure to consistently use $AUTHED_ORIGIN for all operations — push, fetch, and pull.