A browser check wanted 401 and got 403. The value the handler compared against was not the address the browser had asked for.
What I was chasing
I wanted to know why a route that should have said "sign in first" refused the request outright. The method was five header probes against a production build under next start, then reading every route file that touches the same value, because the change under test had nothing to do with that route. This post covers what the probes showed, the fix in three shapes, what two local reviewers took back out of it, and what is still unproved.
Hearso is a small real-time multiplayer trivia platform: a Next.js web app, a Rust WebSocket game service, and local Postgres and Valkey. The work on 2026-09-20 was a hostname split: marketing on www, the application on app, one deployment answering to both names. 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 symptom: a 403 where a 401 belonged
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. Nothing in the change under test went near that route, and the same code answers correctly in production today.
Five probes, and the two that passed
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 sent | Answer |
|---|---|
Host app.localhost, Origin app.localhost: what a browser on the app host sends | 403 |
Host app.localhost, Origin 127.0.0.1. A control: passes only if the origin is the bind address | 403 |
Host 127.0.0.1, Origin 127.0.0.1: the single-host control | 403 |
Host app.localhost, Origin evil.example: must be 403 whatever else is true | 403 |
Host app.localhost, x-forwarded-host: app.localhost, Origin app.localhost | 403 |
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.
A minute later, a second probe printed the bodies too. Two requests got past the check, and both are the wrong two:
| What was sent | Answer | Body |
|---|---|---|
Origin http://localhost:3243, Host app.localhost | 401 | Sign in to continue |
no Origin header, Host app.localhost | 401 | Sign in to continue |
Origin app.localhost, Host app.localhost | 403 | Cross-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.
The address the platform built
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 the probes say it is always 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. Anywhere else 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. 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, three shapes
Eleven route files read that value. I opened each one and wrote down what it did with it, instead of guessing at the shape of the problem from the one route that had failed.
What the file does with req.nextUrl.origin | Files |
|---|---|
compares it with the request's Origin, as a same-origin check | 7 |
| builds a same-host redirect from it | 3 |
builds the redirectTo a Google sign-in hands our identity provider | 1 |
| total | 11 |
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. 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.
On any platform but the one this was written for, that is every browser write refused and a sign-in sent to localhost. Nothing in the repository could have caught it, because every unit test builds its request from a URL, and there the header and the built address are the same string.
Three shapes, three functions
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
Originis compared to theHost(orX-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, reading 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 where most 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.
What two local reviewers found
Two reviewers read the first two commits before anyone outside did, one on security and one on correctness and test quality: 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, which is the habit I would keep if I could keep only one.
- 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.comwithHost: app.hearso.comandOrigin: https://evil.exampleread as "same". The comparison I was replacing had refused it. A list now names no host andHostis asked instead. - Comparing hosts and not schemes loosened the check on the platform we ship on, where it replaced a comparison of whole origins.
httpandhttpson one hostname are the same site, soSameSite=Laxdoes not stop that POST, and our HSTS header readsmax-age=31536000; includeSubDomainswith nopreload. Wherex-forwarded-protonames one scheme, theOriginhas to carry it now. - A custom scheme parsed and compared equal.
new URL("my-app://app.hearso.com").hostis our hostname. Anything that is nothttporhttpsis refused before the host is looked at. - 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:1337was trusted and then rebuilt ashttps://www.hearso.com:1337, as an OAuthredirectTo, and as the origin of a mailed link. A configured host now answers with its configured origin. - 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 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.
- 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.hrefand a rawheaders.get("host")) and its comment stripper cut a line at the//inside a"https://…"literal. It reads the TypeScript syntax tree now, and it coverslib/as well as the route files.
Then the mutants: 31 applied by hand in three rounds, each one checked to have actually changed its file, each 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
Originis refused there: the one route where a missingOriginis 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 number that cannot be phrased two ways.
The proof
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. 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 sent | Before | After |
|---|---|---|
a browser on app.localhost | 403 | 401 |
a browser on 127.0.0.1 (single-host control) | 403 | 401 |
x-forwarded-host names the app host | 403 | 401 |
Origin http://localhost:3243 | 401 | 403 |
| no Origin at all | 401 | 401 |
evil.example posting to app | 403 | 403 |
The three 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, 302 /signin?returnTo=/team) with a control in the same run 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 6e1746ed | Result |
|---|---|
| hostname tests | 7 passed (1.7 s) |
| accessibility tests | 2 passed (5.0 s) |
| platform gate and shell canary | 4 passed (1.2 s) |
The whole block on that commit: tsc 0, eslint 0, 452 test files, 8,537 tests.
What is not proved
This is a local branch waiting for review. It is not merged and it is not deployed, and none of the numbers above says anything about how it behaves on the platform we ship on: only that the handler now reads the header the browser sent, on the one platform where I could see both readings at once.
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.
Key takeaways
- An address a framework hands you is somebody's reconstruction of the request. Ask which platform built it before you compare anything to it.
- A check can be wrong in both directions at once on a platform it was not written for: 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 was protecting by accident. An absolute
${origin}//evil.exampleis a path; a relative//evil.exampleis another host. - The leftmost entry of a forwarded chain is the client's word, not the proxy's. A list of hosts names no host.
- A guard with nothing to find needs a positive control. Ours now has to name the modules it is allowed to skip, or it has not looked.
- A reviewer's finding and your own sentence are both claims. Trace each one before acting on it; the first finding above came with the request that reproduces it.
What we would do next
Get it reviewed and merged, in that order, and then measure the one thing this post only infers. A single probe on a deployed preview (a POST with Host and Origin naming the deployment's own hostname, expecting 401) would turn "works in production today" into a reading. It costs one request.
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, which is the check that would have caught this in the first place, and it exists because it failed.
Evidence
All times UTC, all 2026-09-20. 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, builde3e8dc19): 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.1on port 3243. - The two that got past the check (Origin
http://localhost:3243and no Origin, both 401Sign in to continue) and the browser's own case (403Cross-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 relativeLocation, 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
6e1746edin 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,dae5ed2fat 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 12with exit 0: file 05, on build6e1746edat 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:tsc0,eslint0, 452 test files, 8,537 tests: file 07 and file 10. The two earlier commits on the branch,d289d57eat 13:17:00Z anddae5ed2fat 13:21:57Z, record 8,488 and 8,490 tests on the same 452 files. - The branch as it stands, unmerged and undeployed, with
preloadleft to the site's owner: file 07, the closing lines of6e1746ed; cutover log, web-side entries 12:55:24Z (the diagnosis) and 13:53:00Z (the branch as it stands).
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.