Article

The redirect loop that only happens behind HTTPS

A locale rewrite forced onto http: is cross-origin behind an HTTPS proxy, so Next re-issues it as an external request that re-enters the proxy and 301-loops the homepage. It passes every local test because the loop only appears when X-Forwarded-Proto: https is set. The fix: mark the internal rewrite with a per-process secret so canonicalization skips it.
July 14, 2026
Topics:Deployment
Tags:Next.jsCaddy

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-Proto changes 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.