Self-hosted: deduplicated OAuth callback redirects to container-internal origin (https://<container-id>:3000)
Describe the bug
On a self-hosted Docker deployment running behind a reverse proxy, signing in with Google sometimes ends with the browser being redirected to the container-internal origin:
https://ae4297c33ec1:3000/welcome-redirect(ae4297c33ec1 is the web container's hostname), which fails in the browser with ERR_NAME_NOT_RESOLVED.
This happens whenever the OAuth callback URL is requested more than once for the same authorization code (Google/Chrome commonly do this, e.g. due to prefetching or a retried navigation), which activates the Redis-backed callback deduplication path. The logs show:
[auth/oauth-callback]: Reusing completed OAuth callbackRoot cause
apps/web/utils/oauth/auth-callback-deduplication.ts resolves redirect targets against request.url:
createCachedRedirect:const redirectUrl = new URL(redirect, request.url);- plus three fallbacks:
Response.redirect(new URL(WELCOME_PATH, request.url), 302)(claim error / timeout / missing cached redirect).
better-auth itself returns a relative Location (e.g. /welcome-redirect) on the first callback response, which is what gets cached in Redis. The first response is therefore fine — the browser resolves it against the public origin. But the second (deduplicated) response absolutizes the cached relative path against request.url, and in a self-hosted standalone Next.js server request.url carries the server's internal listen origin (http(s)://<container-hostname>:3000), not the public origin — regardless of the incoming Host / X-Forwarded-Host headers.
On Vercel request.url reflects the public URL, so the bug is not visible on the hosted deployment.
To Reproduce
No Google account needed — the same code path can be triggered with a fake code:
- Self-host with
docker compose --profile all up -d(imageghcr.io/elie222/inbox-zero:latest),NEXT_PUBLIC_BASE_URL=https://inbox.example.com, behind any HTTPS reverse proxy (reproduced with Tailscale Serve; any proxy applies). Redis is the compose-providedserverless-redis-http, soisOAuthCodeStoreConfigured()is true. - Initiate a social sign-in and keep the cookies (the
oauth_statecookie is needed for the dedup fingerprint):curl -s -c jar.txt -X POST https://inbox.example.com/api/auth/sign-in/social \ -H 'Content-Type: application/json' -H 'Origin: https://inbox.example.com' \ -d '{"provider":"google","callbackURL":"/welcome-redirect"}' # extract the `state` query param from the returned Google URL - Hit the callback twice with the same (fake) code:
curl -s -b jar.txt -D - -o /dev/null "https://inbox.example.com/api/auth/callback/google?state=<state>&code=fake123" | grep -i '^location' curl -s -b jar.txt -D - -o /dev/null "https://inbox.example.com/api/auth/callback/google?state=<state>&code=fake123" | grep -i '^location'
Result:
location: /login/error?error=invalid_code # 1st response — relative, correct
location: https://ae4297c33ec1:3000/login/error?error=invalid_code # 2nd response — container-internal originIn a real Google login the same happens on the success path with /welcome-redirect, so the user ends up on https://<container-id>:3000/welcome-redirect.
Expected behavior
The deduplicated callback response should redirect to the public origin, same as the first response.
Suggested fix
Resolve redirects against the configured base URL instead of request.url in auth-callback-deduplication.ts, e.g.:
const redirectUrl = new URL(redirect, env.NEXT_PUBLIC_BASE_URL);and the same for the three new URL(WELCOME_PATH, request.url) fallbacks. I verified this fix locally (patched the deployed bundle): the second callback response then correctly returns location: https://inbox.example.com/....
Happy to open a PR if that helps.
Environment
- Image:
ghcr.io/elie222/inbox-zero:latest(Next.js 16.3.0 standalone), officialdocker compose --profile all - Reverse proxy: Tailscale Serve (forwards
Host,X-Forwarded-Host,X-Forwarded-Protocorrectly — verified) NEXT_PUBLIC_BASE_URLset to the public HTTPS URL;QUEUE_BACKEND=internal; no OAuth proxy (OAUTH_PROXY_URLunset)
Source: elie222/inbox-zero