The redirect loop that only happens behind HTTPS
We shipped a locale change to a headless Next.js front end and the homepage started returning HTTP 301 — but only in production, behind the reverse proxy. Locally it was fine. Here is the trap, and the one-line reason it hid from every local test.
The setup
English is served unprefixed. A Next proxy (middleware) rewrites / into an internal /en tree so the URL bar stays clean, and separately 301-redirects any direct hit on /en/... to its canonical unprefixed path so a page never has two addresses.
// / -> internal rewrite to /en (renders, 200)
// /en/foo -> 301 -> /foo (canonicalize)Two rules that look independent. They are not.
The trap
An earlier fix forced the rewrite target onto http:. Behind the TLS-terminating proxy, the request arrives as https (via X-Forwarded-Proto) while the app listens on plain HTTP. Rewriting to a same-origin https URL made Next proxy over TLS to a plaintext port — EPROTO, a 500 on every route — so forcing http: was the correct fix for that bug.
But http: while the incoming request is https: is a different origin. Next treats a cross-origin rewrite as an external request and re-issues it to the server. The re-issued request hits the proxy again on /en, matches the canonicalization rule, and 301s back to /. The homepage is now a redirect loop.
Why every local test passed
Without X-Forwarded-Proto: https, the request is plain http, the rewrite is same-origin, and it stays internal — no re-entry, no loop. A bare curl localhost:3000/ returns 200. The loop appears only when something sets the forwarded-proto header — that is, only behind the real proxy. The staging health check had been hardened to send that header and require a 200; that is the check that caught it before production.
The fix
The re-issued internal request needs to skip canonicalization. A fixed marker header would work, but any external client could spoof it to bypass the redirect, and keying on the protocol is unreliable because the re-issue carries the upstream https. The answer is a per-process secret: mark the internal rewrite with a value only the running server knows.
// generated once per process
const token = randomHex(24)
// canonicalization: skip only our own re-issue
if (isEnglishPrefixed && req.headers.get(MARKER) !== token) {
return redirect(canonical, 301)
}
// on the internal rewrite, attach the token
requestHeaders.set(MARKER, token)The internal re-issue is recognized regardless of forwarded protocol, and an external client cannot forge the token to escape canonicalization. The homepage returns 200; a genuine /en/... hit still 301s to its canonical URL.
Takeaways
- A middleware rewrite whose target origin differs from the request origin is an external request, not an internal one — even when the host is identical. Protocol is part of the origin.
- Behind a TLS-terminating proxy,
X-Forwarded-Protochanges what "same origin" means. Test the proxied path, not just the app port. - When two routing rules share a path space, prove they cannot compose into a loop — ideally with a health check that exercises the real forwarded-proto condition.