Background
Pulling images from ghcr.io (GitHub Container Registry) on domestic servers frequently times out. Using Cloudflare Workers as a reverse proxy is a common solution. Following existing Docker Hub proxy implementations (e.g., docker-proxy), we built ghcr-proxy. After deployment, curl tests succeeded, but podman pull failed with HTTP 403 Forbidden.
Observed Issue
curl https://ghcr.wpcy.net/health→ 200 OKcurl https://ghcr.wpcy.net/v2/→ 401 (appears normal)podman pull ghcr.wpcy.net/linuxserver/heimdall→ 403 Forbidden
Root Cause Analysis
The Docker Registry v2 authentication flow proceeds as follows:
Client → GET /v2/ → 401 + www-authenticate: Bearer realm="https://ghcr.wpcy.net/token"
Client → GET /token?scope=repository:xxx:pull → fetch Bearer token
Client → GET /v2/xxx/manifests/latest + Authorization: Bearer xxx → 200
The critical step is #2: after receiving the realm, the client requests /token (without the /v2 prefix).
Our Worker code was ordered as follows:
// 1. Health check
if (path === "/" || path === "/health") { ... }
// 2. Non-/v2 requests return usage page ← This is the problem
if (!path.startsWith("/v2")) {
return new Response("GHCR Proxy\nUsage: ...", { status: 200 });
}
// 3. Token proxy ← /token never reaches this point
if (path.startsWith("/v2/token") || path === "/token") {
return fetch("https://ghcr.io/token" + url.search);
}
Since /token does not start with /v2, it gets intercepted at step #2 and returns the plain-text usage page. The client receives HTML instead of the expected JSON token, causing all subsequent authenticated requests to fail with 403.
Manually testing /v2/ with curl and observing 401 gave a false sense of correctness — real clients (podman, docker) execute the full auth flow, including parsing the realm and fetching the token.
Solution
Move the token path check before the /v2 prefix guard:
// 1. Health check
if (path === "/" || path === "/health") { ... }
// 2. Token proxy (must come before /v2 check)
if (path === "/token" || path.startsWith("/v2/token")) {
const authUrl = new URL("https://ghcr.io/token");
authUrl.search = url.search;
return fetch(authUrl.toString(), { ... });
}
// 3. Non-/v2 requests
if (!path.startsWith("/v2")) {
return new Response("Usage: ...", { status: 200 });
}
Also, consistently rewrite the realm in 401 responses to use /token (without /v2 prefix), matching ghcr.io’s original behavior:
wwwAuth.replace(
/realm="https?:\/\/ghcr\.io\/token"/,
`realm="https://${hostname}/token"`
);
Verification
# /v2/ returns 401 with correct www-authenticate header
curl -sI https://ghcr.wpcy.net/v2/ | grep www-authenticate
# www-authenticate: Bearer realm="https://ghcr.wpcy.net/token",service="ghcr.io"
# /token correctly proxies
curl -s "https://ghcr.wpcy.net/token?scope=repository:linuxserver/heimdall:pull&service=ghcr.io" | jq keys
# ["token"]
# Full pull succeeds
podman pull ghcr.wpcy.net/linuxserver/heimdall:latest
Lessons Learned
- Path evaluation order matters critically in registry proxies: The
/tokenendpoint lies outside the/v2namespace. If/v2is used as a routing guard,/tokenmust be handled before that check. curltesting ≠ real client testing: Observing a 401 on/v2/does not guarantee correctness — actual clients follow the complete auth flow.- Docker Hub and
ghcr.iouse different token endpoints: Docker Hub usesauth.docker.io/token(dedicated domain), whileghcr.iousesghcr.io/token(same domain). Same-domain token endpoints are more easily misrouted by overly broad path guards. - Always sketch the full request flow first, then implement routing logic — avoid incrementally adding conditions without verifying end-to-end behavior.