Cloudflare Worker Proxy for ghcr.io Container Images: Pitfall Record Regarding /token Path Order

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 OK
  • curl 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

  1. Path evaluation order matters critically in registry proxies: The /token endpoint lies outside the /v2 namespace. If /v2 is used as a routing guard, /token must be handled before that check.
  2. curl testing ≠ real client testing: Observing a 401 on /v2/ does not guarantee correctness — actual clients follow the complete auth flow.
  3. Docker Hub and ghcr.io use different token endpoints: Docker Hub uses auth.docker.io/token (dedicated domain), while ghcr.io uses ghcr.io/token (same domain). Same-domain token endpoints are more easily misrouted by overly broad path guards.
  4. Always sketch the full request flow first, then implement routing logic — avoid incrementally adding conditions without verifying end-to-end behavior.