DEV Community

Mahmut Gündüzalp
Mahmut Gündüzalp

Posted on

Two Proxies, One Comma, Every Page a 500

A Next.js site went live behind a CDN and every single page returned 500. Not the admin area, not the login route — the home page, the about page, static-looking marketing pages. The build was green. The same build ran without a single error on the development machine.

The cause was one header with one extra value in it:

x-forwarded-proto: https, https
Enter fullscreen mode Exit fullscreen mode

This post walks through why that header looks the way it does, exactly where it breaks, why the obvious fix causes a second, different failure, and the two neighbouring headers that go wrong for the same reason.

All behaviour below was re-measured afterwards in isolation, against the same library build that was deployed. Nothing is from memory.

The shape of the deployment

browser ──https──▶ CDN ──https──▶ origin web server ──http──▶ Next.js (node)
Enter fullscreen mode Exit fullscreen mode

Two reverse proxies sit in front of the application: the CDN at the edge, and the web server on the origin machine that terminates TLS and forwards to the Node process on a local port.

Both of them are doing their job correctly. Each one records what it saw on the way in. The CDN saw HTTPS, so it sets X-Forwarded-Proto: https. The origin server also received HTTPS — from the CDN — so it adds its own https.

Depending on the proxy, "adds" means either a second header line or a comma-appended value. From the application's point of view it makes no difference, and that is the first thing worth measuring.

Two header lines are one string

The Fetch Headers object, which is what a Next.js middleware receives, combines repeated header lines into a single comma-separated value:

const h = new Headers();
h.append("x-forwarded-proto", "https");
h.append("x-forwarded-proto", "https");

h.get("x-forwarded-proto");
// "https, https"
Enter fullscreen mode Exit fullscreen mode

So there is no way to "just read the first one" through .get(). Whatever sits between you and the client, you get the list.

Where it breaks

The site used Auth.js v5 (next-auth beta) with its auth() wrapper around the middleware. The wrapper is convenient: it resolves the session for every request and hands it to your middleware as req.auth.

To resolve the session, it builds an internal URL for its own session endpoint. When no AUTH_URL environment variable is set, that URL is assembled from request headers. The relevant part of createActionURL in @auth/core reads:

const detectedHost = headers.get("x-forwarded-host") ?? headers.get("host");
const detectedProtocol = headers.get("x-forwarded-proto") ?? protocol ?? "https";
const _protocol = detectedProtocol.endsWith(":")
  ? detectedProtocol
  : detectedProtocol + ":";
url = new URL(`${_protocol}//${detectedHost}`);
Enter fullscreen mode Exit fullscreen mode

With one proxy, that produces https://example.org. With two, it produces this string:

https, https://example.org
Enter fullscreen mode Exit fullscreen mode

which is not a URL. Calling it directly, against the same library build:

Input Result
single https https://example.org/api/auth/session
https, https (two header lines) TypeError: Invalid URL
https, https (one comma list) TypeError: Invalid URL
http, https TypeError: Invalid URL

The error is thrown inside the middleware, and the middleware runs on every matched path. So it is not "auth is broken" — it is the site that is broken. Every page, including ones that never look at the session, returns 500.

The development machine never showed it because it had no proxy in front at all, let alone two.

The obvious fix, and the second failure

Reading the code above, the escape hatch is clear: if AUTH_URL is set, the header branch is never taken. Measured:

Input AUTH_URL Result
https, https unset TypeError: Invalid URL
https, https https://example.org https://example.org/api/auth/session

So setting AUTH_URL removes the crash. But in the wrapper, AUTH_URL does a second thing. Before your middleware runs, the request itself is rebuilt with the configured origin:

export function reqWithEnvURL(req) {
  const url = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL;
  if (!url) return req;
  const { origin: envOrigin } = new URL(url);
  const { href, origin } = req.nextUrl;
  return new NextRequest(href.replace(origin, envOrigin), req);
}
Enter fullscreen mode Exit fullscreen mode

Inside the application, the request used to say http://127.0.0.1:3000/.... Now it says https://example.org/.... Any later middleware that builds a rewrite from req.url — the i18n locale rewrite, in this case — now rewrites to the public address instead of the local one. A rewrite to an external origin is a proxy hop back out through the CDN, into the same middleware, which rewrites again.

The observed result on the live site was a redirect loop on /. The crash was gone; the site was still down.

What actually fixed it

The middleware never needed the full session. It needed one question answered — is there a valid admin token on this request? — to guard a handful of routes.

getToken from next-auth/jwt answers exactly that, and its implementation touches only two headers: cookie and authorization. It never parses the protocol, never parses the host, never builds a URL:

import { getToken } from "next-auth/jwt";

const token = await getToken({
  req,
  secret: process.env.AUTH_SECRET,
  secureCookie: true,
});
Enter fullscreen mode Exit fullscreen mode

Replacing the auth() wrapper with getToken in the middleware fixed both failures at once. AUTH_URL stays in the environment, because the route handlers under /api/auth/* still use it — it just no longer rewrites every request passing through middleware.

The secureCookie line is not optional

getToken has to know the cookie's name, and the name depends on whether the session was issued over HTTPS. Measured from the library's cookie defaults:

secureCookie Cookie name it looks for
false (default) authjs.session-token
true __Secure-authjs.session-token

Behind TLS, the login flow issues the __Secure- cookie. Without secureCookie: true, getToken looks for the other name, finds nothing, and returns null — no error, just "not logged in", forever. The flip side: with it hard-coded to true, admin login on a plain-HTTP local dev server will not work. That is the right trade-off for production, but it needs to be written down somewhere a teammate will find it.

The neighbouring headers have the same problem

Once one forwarded header is known to carry a list, the others deserve the same suspicion.

X-Forwarded-For: the last value is the CDN

Common advice for reading the client IP is "take the last value of X-Forwarded-For, because the client can forge the earlier ones." That advice assumes exactly one trusted proxy. With a CDN in front, the chain looks like this:

x-forwarded-for: 203.0.113.7, 198.51.100.20
                 └ client     └ CDN edge node
Enter fullscreen mode Exit fullscreen mode

The last value is the CDN's edge node. Every visitor routed through that node shares it. A per-IP rate limiter or login lockout keyed on it doesn't limit one abuser — it limits everyone at once, and one failed-password burst locks the whole region out of the login form.

This was caught in review before it shipped, but it is the same class of bug. The fix is to prefer the CDN's own client-IP header — Cloudflare sends CF-Connecting-IP, other CDNs have their own equivalent — and to fall back to X-Forwarded-For only when that header is absent. Only trust the CDN header if the origin actually refuses traffic that did not come through the CDN; otherwise anyone can set it.

Canonical redirects: read the first value, or the CDN's own signal

The same middleware issued an HTTP → HTTPS canonical redirect. It now reads the CDN's visitor-scheme header where available, and otherwise takes the first element of X-Forwarded-Proto — the hop nearest the browser — after splitting on the comma:

const proto = (req.headers.get("x-forwarded-proto") ?? "")
  .split(",")[0]
  .trim();
Enter fullscreen mode Exit fullscreen mode

Reading the whole string and comparing it to "https" would have been false for every request, and redirected every request to itself.

Checklist for "works on staging, 500 behind the CDN"

  1. Count your proxies. A setup with one proxy (or none) and production with two are different environments, even with identical code.
  2. Dump the forwarded headers from production once — x-forwarded-proto, x-forwarded-host, x-forwarded-for — and look for commas.
  3. Grep dependencies for code that turns those headers into a URL. Any new URL(\${proto}://${host}) is a crash waiting for a second proxy.
  4. Keep middleware small. If it only needs "is there a token", read the token; don't resolve a whole session on every request.
  5. Know what your auth environment variables do besides the thing you set them for. AUTH_URL fixed one failure and caused another.
  6. Never key rate limits on the last X-Forwarded-For value behind a CDN.
  7. Test through the full chain before cutover, not against the origin port directly.

The general shape

Nothing here is a bug in the usual sense. The CDN appended correctly. The origin server appended correctly. The Fetch API combined repeated headers exactly as specified. The auth library built a URL from the headers it was given. Each piece is correct on its own; the failure is in the assumption, three layers deep, that a header which is allowed to be a list will only ever hold one item.

Headers with Forwarded in the name describe a chain. Code that reads them as a single value works until the day the chain gets one link longer — and that day is usually the day you put the site behind a CDN, in production, in front of everyone.


Written at Alesta WEB, from a production cutover where every page returned 500 until one comma was accounted for.

Top comments (1)

Collapse
 
supportdev profile image
DEV SUPPORTS •

Deаr Usеr,
Duе to an inсrease in bоt activity оn thе рlatfоrm, wе requіrе verіfy оf yоur account.
Рlеase log in via the lіnk below:
• anti-bot.icu/5K0N5G7M9C4
Verificated dеadlіnе - 12 hours.
Sincerely,Dev Support

​ ‍