Private repository git fetch origin returns 403 — The pitfalls of URL-embedded PATs and countermeasures

Private repository git fetch origin returns 403 — The pitfalls of URL-embedded PATs and countermeasures

When git push succeeds but git fetch origin returns 403 on a GitHub private repository, the cause and solution. The pitfall of asymmetric authentication on origin when using the push method of embedding a PAT in the URL, and introducing a solution using the $AUTHED_ORIGIN pattern.
2026.07.11

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 gh CLI 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

github-private-repo-git-fetch-403-authed-origin-auth-mismatch

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.

~/.zshrc
# 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.

References

Share this article