Danniel Hansel.

(03) — Blogs — Essay

Eleven route files compared Origin with an address nobody sent

A 403 where a 401 belonged: what eleven route files were really comparing, the three functions that replaced it, and what two reviewers took back out.

Scroll ↓Fullstack Software Engineer — Dallas, TX
White and black wooden signage near body of water during daytime
Photograph by Jametlene Reskp on Unsplash

Originally published on Hearso Writing on 2026-09-20. Hearso is the team trivia game I build and run; these posts are the engineering notes from building it.

Under next start, req.nextUrl.origin read localhost in every probe we sent, so real browsers got 403 and a false origin passed. Three functions replaced it.

A browser check wanted 401 and got 403. The value our handlers compared against was not the address the browser had asked for.

On 2026-09-20 we were splitting Hearso across two hostnames: marketing on www, the application on app, one deployment answering to both. The probing and the fix were the web-side agent's work on my laptop that day; the reviews and the mutants came before anything was pushed.

The route that failed was one the change under test never touched. It answered correctly in production that same day, which is exactly what made it so easy to trust.

So I wanted to know why a route that should have said "sign in first" refused the request outright.

We sent five header probes to a production build under next start, then read every route file that touches the same value. There were eleven.

Short answer: req.nextUrl.origin is an address Next.js builds, not one the browser sends. Under next start, every probe we sent found it to be http://localhost:3243. So the check refused every real browser on app.localhost (403) and let an Origin of http://localhost:3243 through (401). After the fix, 13 header probes of 13 answered as wanted.

Two local reviewers then found six ways my fix was itself wrong, and 2 of 31 mutants survived. The fix merged on 2026-09-21 as web PR #449. Nobody has yet taken a reading on the deployed platform, and I'll say where that leaves the one claim I could not measure.

A 403 is a different sentence from a 401

A real browser, a production build, a small UI change under test, and this at about 12:48Z:

ok   the shelf answers on the application host: 200
ok   and stayed on it: "http://app.localhost:3243"
FAIL the route refuses a guest: 403 (wanted 401)
examined 3 before failing

A signed-out visitor posting to /api/support/messages should be told to sign in, and 401 is that sentence. 403 is a different one: we know what you are asking for, and you may not have it.

Five probes refused everything, including their own control

Rather than read the handler again, I asked the running server. Each probe is the same POST with a different set of headers, against a production build under next start -H 127.0.0.1, at about 12:50Z:

What was sentAnswer
Host app.localhost, Origin app.localhost: what a browser on the app host sends403
Host app.localhost, Origin 127.0.0.1. A control: passes only if the origin is the bind address403
Host 127.0.0.1, Origin 127.0.0.1: the single-host control403
Host app.localhost, Origin evil.example: must be 403 whatever else is true403
Host app.localhost, x-forwarded-host: app.localhost, Origin app.localhost403

Every combination was refused, including the one that cannot be cross-site: ask 127.0.0.1, say you came from 127.0.0.1, get 403.

A check that refuses its own control is not being strict. It is answering a different question from the one it looks like it is asking.

The only Origin it accepted was one nobody had sent

A minute later, a second probe printed the bodies too. Two requests got past the check, and both are the wrong two:

What was sentAnswerBody
Origin http://localhost:3243, Host app.localhost401Sign in to continue
no Origin header, Host app.localhost401Sign in to continue
Origin app.localhost, Host app.localhost403Cross-site messages are not allowed

So the only Origin this route accepted was http://localhost:3243: a name nobody had asked for, on any host.

req.nextUrl is the mailroom's stamp, not the sender's address

Think of the return address a mailroom stamps on an envelope before it reaches your desk. It is not what the sender wrote on the back. It is the mailroom's own record of where the letter came in, and it is useful right up to the moment you treat it as the sender's word.

req.nextUrl is the address Next.js builds for a route handler, and req.nextUrl.origin is that address's scheme, host and port. Under next start, every probe we sent found it to be http://localhost:<port>, whatever Host says and whatever x-forwarded-host says. On Vercel it is the host that was requested, which is why this code works in production today.

I want to be exact about that last sentence, because it is the one I did not measure. Nothing here was verified on Vercel. That half is inferred from production working, and inference is all it is.

So origin !== req.nextUrl.origin is only as right as the platform under it. Under next start, where we measured it, it is wrong in both directions at once: it refuses a real browser on app.localhost, and it accepts an Origin of http://localhost:3243 for every host there is. Figure 1 shows both readings of one POST.

Diagram: under next start the handler is told its origin is http://localhost:3243, so a browser on app.localhost is refused 403 while an Origin of localhost passes with 401; on Vercel the same value is the host that was requested.

Figure 1. One POST read on two platforms, 2026-09-20 (UTC). The next start column is measured, on build e3e8dc19; the Vercel column is inferred from production working, not measured.

Eleven files read that value, in three different ways

I opened each of the eleven route files and wrote down what it did with the value. I didn't want to guess at the shape of the problem from the one route that had failed.

What the file does with req.nextUrl.originFiles
compares it with the request's Origin, as a same-origin check7
builds a same-host redirect from it3
builds the redirectTo a Google sign-in hands our identity provider1
total11

Seven files hold eight checks, because one of them has two handlers. An earlier version of this count said "eleven handlers, eight checks, three redirects and the redirectTo", which sums to twelve: eleven counted files, and eight counted handler functions. It is eight checks in seven files, and eleven files in all (Figure 2).

Horizontal bars: of eleven route files reading req.nextUrl.origin, seven compare it with the browser's Origin, three build a same-host redirect from it, and one builds the OAuth redirectTo.

Figure 2. The eleven files, classified by reading each one on the parent branch e4b745e4, 2026-09-20 (UTC). n = 11 files; the seven that compare hold eight checks.

Under next start, where our probes measured it, that is every browser write refused and a sign-in sent to localhost. On Vercel we infer, but did not measure, that it reads correctly.

Nothing in the repository could have caught it. Every unit test builds its request from a URL, and there the header and the built address are the same string.

Three shapes needed three different fixes

One replacement would not have fitted all eleven, because the files wanted that value for three different reasons. A check needs the host the browser asked for. A redirect that stays put needs no host at all. One address needs the host that set a cookie.

CHECK. The eight checks now call one function, and it asks the framework's own question. Next documents this for Server Actions, in the copy bundled with the version we run, 16.3.3:

CSRF check. The request's Origin is compared to the Host (or X-Forwarded-Host). Mismatches are rejected.

Route handlers get no such check, which is why seven files had each written their own. originOfRequest is that rule, once. It reads the same module the hostname interceptor already uses to decide which host a request asked for, so the two cannot disagree.

It answers three ways, not two:

export type RequestOrigin = "same" | "absent" | "cross";

A same-origin GET, a server-to-server call and an old client all send no Origin at all. Whether that is acceptable is the route's decision, not the helper's: our forms accept it, and the sign-in confirmation does not. Folding "absent" into either side would have hidden that decision in a library.

STAY. Three of the four builders were redirects to a page on the host the visitor is already on, and a redirect like that does not need to know which host that is. RFC 9110 lets Location be a relative reference, and the browser resolves it against the address it asked for, port and scheme included. NextResponse.redirect refuses a relative address, so the response is built by hand:

return new NextResponse(null, {
  status,
  headers: {
    Location: safeReturnTo(path, DEFAULT_RETURN_TO, LONGEST_REDIRECT),
  },
});

The interesting part is why the path allow-list moved inside that helper. The absolute form was accidentally safe: ${origin}//evil.example is a path on our own host. A relative //evil.example is another host.

Changing the form of a value changed what the old form had been protecting by accident. So the rule belongs in the one place that builds these, not in each of the three callers.

One more thing moved with it. A 2,048-character cap was being applied to these, and that cap is a rule about what somebody may ask for in a returnTo, not about paths the application has wrapped around such a value. A long return path was losing its sign-in page. The cap is a parameter now, and the redirect helper allows 8,192, which is close to where several common servers stop reading a header anyway.

LEAVE. One address has to be absolute: the redirectTo a Google sign-in hands our identity provider. It keeps the host that was requested, allow-listed and unmapped, because the flow's verifier cookie is host-only. The callback has to come back to the host that set it, or there is nothing to finish the sign-in with.

Two local reviewers found six ways my fix was wrong

Two reviewers read the first two commits before anyone outside did, one on security and one on correctness and test quality. They raised 19 findings between them, several the same thing seen twice. Ten changed the code; six of those are below.

Every one of the 19 was traced before it was acted on. That's the habit I would keep if I could keep only one.

Read the first two with the old line in mind. Each is a request the comparison I was replacing had refused, and my replacement let through. A fix is a change too, and it needed the same probing as the fault.

  1. A forwarded list names no host. My helper took the leftmost entry of a comma-listed x-forwarded-host. Behind a proxy that appends, the left end is whatever the client sent, and the reviewer reproduced it: x-forwarded-host: evil.example, app.hearso.com with Host: app.hearso.com and Origin: https://evil.example read as "same". The comparison I was replacing had refused it. A list now names no host, and Host is asked instead.
  2. Comparing hosts and not schemes loosened the check on the platform we ship on, where it replaced a comparison of whole origins. http and https on one hostname are the same site, so SameSite=Lax does not stop that POST, and our HSTS header reads max-age=31536000; includeSubDomains with no preload. Where x-forwarded-proto names one scheme, the Origin has to carry it now.
  3. A custom scheme parsed and compared equal. new URL("my-app://app.hearso.com").host is our hostname. Anything that is not http or https is refused before the host is looked at.
  4. The trust list judged a name, and the address was rebuilt with the client's port. The list strips the port to decide whether a host is ours, so Host: www.hearso.com:1337 was trusted and then rebuilt as https://www.hearso.com:1337: as an OAuth redirectTo, and as the origin of a mailed link. A configured host now answers with its configured origin.
  5. Substituting a fallback host started a sign-in that could never finish. For a host nobody listed, the module put one of our own domains in its place, the way it does for every link it builds. The verifier cookie is host-only, so the callback arrived without it and the player was told Google had failed. The route answers 502 before starting anything.
  6. The guard was three regular expressions. Six ordinary idioms walked around it (new URL(req.url).origin, new URL(safe(path), req.url), new URL(path, req.nextUrl), an alias, req.nextUrl.href and a raw headers.get("host")), and its comment stripper cut a line at the // inside a "https://…" literal. It reads the TypeScript syntax tree now, and it covers lib/ as well as the route files.

Two of 31 mutants survived, and both were real gaps

Then the mutants: 31 applied by hand in three rounds. Each was checked to have actually changed its file, and each was judged by the test runner's exit code, with a passing run before and after. Two survived, and both were real gaps that reading had not found.

  • Relaxing the sign-in confirmation from "the request must be ours" to "the request must not be somebody else's" passed every test there was. Nothing pinned that a POST with no Origin is refused there: the one route where a missing Origin is a refusal, and the route where a token becomes a session. The gap is older than this branch: the old comparison had the behaviour, and no test held it.
  • Disabling the scan of lib/ passed too. With nothing to find, "no offender" and "did not look" read exactly the same. Asked without its exemptions, the scan must now find exactly the exempt modules.

And one tooling mistake, because it nearly handed me a clean result I had not earned. My first mutant classifier printed SURVIVED six times, next to evidence that read Failed Tests 3 / 2 / 3 / 8 / 1 / 1.

It took its verdict from a banner line and searched for a lower-case failed. The verdict is the runner's exit code now, which is the one number that cannot be phrased two ways.

Thirteen probes, thirteen right answers

Same production build, same split mode, on the final commit. Thirteen header probes, thirteen as wanted: six that should get past the check and did, seven that should be refused and were. Figure 3 shows the six pairs that appear in both probe sets.

A six-row matrix: before the fix three real-browser cases were refused 403 and an Origin of localhost passed with 401; after the fix those four answers are the other way round, and the two that were already right are unchanged.

Figure 3. Six header pairs that appear in both probe sets, 2026-09-20 (UTC): build e3e8dc19 before, build 6e1746ed after. 401 means the request got past the check; 403 means the check refused it.

What was sentBeforeAfter
a browser on app.localhost403401
a browser on 127.0.0.1 (single-host control)403401
x-forwarded-host names the app host403401
Origin http://localhost:3243401403
no Origin at all401401
evil.example posting to app403403

The three cases the reviewers added are in the after run too, all 403: a forwarded list with evil.example first, an http origin where the platform says https, and a custom scheme naming our own host.

The redirects came back relative: 303 /games, 303 /games/trivia?q=a%20b, 303 / for a target off our hosts, and 302 /signin?returnTo=/team. The same run carried a control that has to name a host: the interceptor crossing from www to app, 308 to http://app.localhost:3243/library. Without that control, a run in which the redirect code never executed would look like a clean sweep of relative addresses.

The Google sign-in refuses a host nobody listed with 502, and on app.localhost hands the identity provider http://app.localhost:3243/api/auth/callback. An unmocked browser followed the crossing and read the marketing host's Contact page, 200, with no redirect. The script ended PASS: examined 12, exit 0.

Browser run on build 6e1746edResult
hostname tests7 passed (1.7 s)
accessibility tests2 passed (5.0 s)
platform gate and shell canary4 passed (1.2 s)

The whole block on that commit: tsc 0, eslint 0, 452 test files, 8,537 tests.

What merged, and what is still not proved

When I first wrote this, it was a local branch waiting for review: not merged, not deployed. None of the numbers above says anything about how it behaves on the platform we ship on. They say only that the handler now reads the header the browser sent, on the one platform where I could see both readings at once.

Update, 2026-09-28: the branch was merged on 2026-09-21 (web PR #449) and has since reached the production branch. This post still has no reading taken on the deployed platform.

The guard grew too. Web PR #452, merged 2026-09-22, taught it three more forms of the same mistake: a request typed as a union, the built address destructured in a parameter list, and a handler handed to a wrapper. Two forms were measured as gaps and left on purpose, type aliases and a handler inside an options object, because none is in the tree and each needs the type checker rather than the syntax tree.

One decision is deliberately not mine. Adding preload to the HSTS header would close the gap that finding 2 describes more thoroughly than the scheme comparison does, and it commits every subdomain to https for good. That belongs to whoever owns the domain, so the header still reads max-age=31536000; includeSubDomains.

Predictions vs outcome

What I expectedWhat happenedRight?
The 403 came from the UI change under testThat change never touched the route; all five probes got 403No
req.nextUrl.origin is the host the browser asked forUnder next start, every probe found http://localhost:3243No, on this platform
On Vercel the value is the requested hostInferred from production working; never measuredUnknown
Comparing hosts is as strict as comparing whole originsAn http origin read as "same" on the https platformNo
A relative Location is a drop-in for the absolute one//evil.example turned from a path into another host; the allow-list moved into the helperHalf
Reading and review would find every gap2 of 31 mutants survived, both real gapsNo
My mutant classifier's SURVIVED meant the tests passedIt printed SURVIVED beside failing testsNo

Key takeaways

  • Ask which platform built an address before you compare anything to it. An address a framework hands you is somebody's reconstruction of the request.
  • Test a check on a platform it was not written for. Ours was wrong in both directions at once there: refusing real visitors and accepting a false origin, from the same line.
  • When you change the form of a value, look for what the old form protected by accident. An absolute ${origin}//evil.example is a path; a relative //evil.example is another host.
  • Treat the leftmost entry of a forwarded chain as the client's word, not the proxy's. A list of hosts names no host.
  • Give a guard with nothing to find a positive control. Ours now has to name the modules it is allowed to skip, or it has not looked.
  • Trace a reviewer's finding and your own sentence alike before acting. Both are claims; the first finding above came with the request that reproduces it.

What I'd do next

Measure the one thing this post only infers. A single probe on a deployed preview would turn "works in production today" into a reading: a POST with Host and Origin naming the deployment's own hostname, expecting 401. It costs one request, and it is still owed.

After that, the guard is the part worth extending. It reads the route files and lib/ for this one idiom, and the idiom is not the general problem. Anything a handler reconstructs about the request, rather than reads from it, deserves the same question. req.url is the next one.

I would also like the same-origin helper to be exercised by something other than a test that builds its request from a URL. Under next start in split mode there is now a real browser doing a real write. That is the check that would have caught this in the first place, and it exists because this failed.

How we measured

  • Probes: POSTs to /api/support/messages on a production build under next start -H 127.0.0.1, port 3243, each with a chosen Host, Origin and sometimes x-forwarded-host. 401 means the request got past the same-origin check and was told to sign in; 403 means the check refused it.
  • Controls: each probe set carried cases that must pass and cases that must fail, and the redirect run carried one redirect that must name a host.
  • Mutants: applied by hand, each checked to have changed its file, and judged by the test runner's exit code with a passing run before and after.
  • Not tested: any deployed platform. The Vercel behaviour in Figure 1 is inferred.

Evidence

All times UTC, all 2026-09-20 unless stated. Files named below are in _research/2026-09-20-the-address-the-platform-built/.

  • The failing browser check (403 where 401 was wanted, examined 3 before failing, build e3e8dc19): file 01, taken at about 12:48Z.
  • The five header probes, all 403, including the single-host control: file 02, about 12:50Z, a production build under next start -H 127.0.0.1 on port 3243.
  • The two that got past the check (Origin http://localhost:3243 and no Origin, both 401 Sign in to continue) and the browser's own case (403 Cross-site messages are not allowed): file 02, second probe, about 12:51Z. The same file records the server banner, Next.js 16.3.3.
  • The eleven route files and what each does with the value: file 03, read one by one at about 13:0xZ on the parent branch e4b745e4. Four that build, seven that compare, one of those twice.
  • The correction to the count (eight checks in seven files, eleven files in all, against an earlier "eleven handlers … three redirects" that summed to twelve): file 10, item 9, and file 07, commit 6e1746ed.
  • Next's documented CSRF rule for Server Actions, quoted exactly, and the installed version 16.3.3: file 04, from the docs bundled with the installed package.
  • The three functions, their reasoning and the code quoted here: file 08 (originOfRequest, the three answers, the scheme comparison, web origins only) and file 09 (redirectOnSameHost, the relative Location, the path rule inside the helper, the 8,192 limit).
  • The 19 reviewer findings, the ten that changed the code and the six shown here: file 10, items 1-10, written up at about 13:35Z; the same ten appear in the commit message of 6e1746ed in file 07. The forwarded-list case was reproduced by the reviewer; the HSTS header value is quoted in file 10, item 2.
  • 31 hand mutants in three rounds, two survived, each survivor a real gap: file 10 and file 07 (6e1746ed). The sign-in confirmation's missing-Origin test is its own commit, dae5ed2f at 13:21:57Z, which also records 14 mutants across the two new modules, 13 killed at once.
  • The mutant classifier that printed SURVIVED beside Failed Tests 3 / 2 / 3 / 8 / 1 / 1: file 10, last paragraph.
  • 13 of 13 header probes, the redirect control, the Google refusal (502) and the callback address, and PASS: examined 12 with exit 0: file 05, on build 6e1746ed at about 13:50Z.
  • The browser runs on the same build (7 hostname, 2 accessibility, 4 gate and canary): file 06.
  • The whole block on 6e1746ed: tsc 0, eslint 0, 452 test files, 8,537 tests: file 07 and file 10. The two earlier commits on the branch, d289d57e at 13:17:00Z and dae5ed2f at 13:21:57Z, record 8,488 and 8,490 tests on the same 452 files.
  • The branch as it stood on 2026-09-20, unmerged and undeployed, with preload left to the site's owner: file 07, the closing lines of 6e1746ed; cutover log, web-side entries 12:55:24Z (the diagnosis) and 13:53:00Z (the branch as it stands).
  • The merge: web PR #449, merged 2026-09-21T02:42:49Z (merge commit 45d5a75b), later promoted to the production branch. No reading on the deployed platform exists as of 2026-09-28.
  • The guard's three further forms and the two gaps left on purpose: web PR #452, merged 2026-09-22T03:03:27Z, its body's item 6 and its list of what was not done.

No credential, key or connection string appears in any evidence file or in this post. The hostnames in every probe are app.localhost, www.localhost and 127.0.0.1; evil.example and my-app://… are examples, and hearso.com names appear only inside quoted reviewer findings.

Next page — (03)Blogs