Originally published on Hearso Writing on 2026-09-27. Hearso is the team trivia game I build and run; these posts are the engineering notes from building it.
42 write routes trusted a SameSite=Lax cookie. Lax trusts every host on the site, and a text/plain form needs no preflight. The probe, the fix and the scan.
Forty-two write routes relied on one cookie attribute to keep other pages out. The attribute was doing a different job from the one we thought.
Hearso is a small real-time multiplayer trivia platform: a Next.js 16 web app with route handlers, Postgres on Supabase, and a Rust WebSocket service. Our session cookies are host-only, httpOnly and SameSite=Lax. For 42 of our write handlers, that was the whole defence.
The reasoning, as far as anyone had written it down, was two sentences: the cookie is SameSite=Lax, and the routes only take JSON. Both sentences are true. (Which is the awkward part: nothing we believed was false, it just didn't add up to a defence.)
So the question was: can a page we don't intend to trust make a write with a signed-in player's session?
On the night of 2026-09-26 we made an edge-case pass over every route in app/api on my laptop, then sent one signed-in request to a local production build to find out.
Short answer: yes. A text/plain form post from a sibling host, news.localhost, created a team: 201. With the fix, the same request gets 403 cross_site and our own page still gets 201. The guard is now first in 56 handlers, and a test scan fails if the next mutating handler forgets it.
It follows req.nextUrl.origin vs the Host header (2026-09-20). That post fixed what our same-origin checks compared against. This one is about the write routes that had no check at all. If your app has cookie sessions, JSON route handlers and more than one host under one domain, treat the rest as a checklist.
The cookies looked like enough
The session cookies are set with these options:
const COOKIE_BASE = {
httpOnly: true,
sameSite: "lax" as const,
secure: isProduction, // simplified here: true in production
path: "/",
};
There is no Domain attribute, so the cookies are host-only: the browser sends them only to app.hearso.com. The room cookie that says which seat a player holds is set the same way.
Fourteen handlers already compared the request's Origin with the host, using the helper from the earlier post. The other 42 trusted the cookie alone.
So the first thing to settle was what that cookie actually promises.
Same site is not same origin
Think of a site as an apartment building and an origin as one flat. Lax checks you're in the building, not that you live in this flat.
Precisely: an origin is a scheme, a host and a port, https://app.hearso.com. A site is the registrable domain, the part you buy from a registrar: hearso.com. Every host under it is the same site.
SameSite=Lax answers one question: is the page that caused this request on the same site as the cookie? If it is, the browser attaches the cookie to any request, POST included. If it is not, the cookie goes only with a top-level GET navigation.
So Lax keeps other.example out. It does not keep out www.hearso.com, news.hearso.com, status.hearso.com or dash.hearso.com, which are all ours and all the same site as the app. Host-only does not help either: it controls where the cookie is sent, not which page may cause the request.
One more case is easy to miss: an http page on our own name. The classic definition of a site ignores the scheme; the newer "schemeful same-site" compares it, and browsers differ. We could not rely on either, so we treated the http page as same-site, which is the case that needs defending. Our HSTS header reads max-age=31536000; includeSubDomains with no preload, so a browser that has never reached us over https is not protected by it.
Figure 1. Seven origins, and what each of the two rules says about a POST from a page there. Read from the cookie options in lib/auth-server.ts and the guard in lib/refuse-cross-site.ts and lib/same-origin.ts, 2026-09-27.
Every one of those hosts is ours, so why does it matter? For the reason same-site risks always matter. A page on any of them that runs someone else's script, or a subdomain whose DNS record points at something you no longer control, is inside the line that Lax draws.
"The routes only take JSON" was not a second wall
It sounds like one. It isn't, for two reasons.
A text/plain form needs no preflight. Browsers sort cross-origin requests into two kinds. A "simple" request goes out at once. Anything else is preceded by an OPTIONS preflight that asks the server for permission. A POST whose Content-Type is text/plain, multipart/form-data or application/x-www-form-urlencoded is simple. An HTML <form enctype="text/plain"> sends exactly that, and its body can be a string that happens to be JSON.
Request.json() does not look at Content-Type. It reads the body as text and parses it. So a handler that starts with await req.json() accepts that form's body as if it had come from our own fetch.
Figure 2. Why POST was the method that mattered. Browser rules for simple requests and preflights; the 201 is the base build's answer to the probe in Figure 3. None of our routes answers a preflight (no OPTIONS handler and no Access-Control-Allow header in app/, lib/ or the Next config, checked 2026-09-27).
Figure 2 is the whole mechanism. A page on a sibling host, a signed-in player, and a form that posts to one of our routes. The browser sends the cookie because the page is on the same site. It sends the request without asking because the request is simple. The handler parses the body because req.json() does not care how it was labelled.
Each of the three did its own job correctly. None of them was asked whether that page had any business sending the request.
PATCH, PUT and DELETE were not the hole
A form can only send GET and POST. A PATCH, PUT or DELETE from another page needs fetch, and a non-simple method means a preflight. None of our routes answers one: there is no OPTIONS handler and no Access-Control-Allow-* header in the app, which I checked on 2026-09-27. So the browser never sends those writes.
POST was the method a sibling page could use. The guard is on the other three anyway, as defence in depth. It costs two lines per handler, and it is already there on the day somebody adds a CORS header for a good reason.
One request settled it
Reading the code said the gap was there. I wanted the running server to say so. The probe was one request against a local production build under next start, signed in as a development test account:
| Build | What was sent | Answer |
|---|---|---|
base 461fd329 | text/plain POST /api/teams, Origin: http://news.localhost | 201, a team was created |
| the fix branch | the same request | 403 cross_site, nothing created |
| the fix branch | the same request, Origin: http://app.localhost (our own page) | 201 |
Figure 3. The live probe from PR #490, 2026-09-26 (UTC), n = 3 requests. Both teams it created were deleted; they were the only changes it made to the development database.
The third row matters as much as the second. A guard that refused everything would also turn the second row into a 403. Only the control shows that the route still does its job for our own pages.
Run the same check on your app
Use a test account, and a route whose effect you can undo:
POST /api/<a write route> HTTP/1.1
Host: app.localhost:<port>
Origin: http://news.localhost:<port>
Content-Type: text/plain
Cookie: <a signed-in test session>
<a JSON body the route accepts>
A 2xx is the gap. Then send it again with an Origin naming the app itself, and expect the 2xx back.
The fix: one guard, first in every write
The fix is one function, built on the three-way originOfRequest from the earlier post:
export function refuseCrossSite(
req: OriginBearingRequest,
): NextResponse | null {
return originOfRequest(req) === "cross"
? problem(403, CROSS_SITE_SENTENCE, CROSS_SITE_CODE)
: null;
}
Every mutating handler now starts with the same two lines:
const refused = refuseCrossSite(req);
if (refused) return refused;
It was added to 42 handlers, and 14 hand-written checks were moved onto it: 56 handlers in all. It comes before the params, the body, the session or the database are read.
Note what it compares. originOfRequest asks whether Origin names the host the request was sent to, and the scheme too where the platform states one. That is a same-origin test, stricter than the same-site rule the cookie follows, so every sibling host in Figure 1 gets a 403.
An absent Origin passes. This check has one job: stop a write forged through a player's browser from another site. Browsers send Origin on every POST, PUT, PATCH and DELETE, so a forged browser write always carries one. A missing Origin proves nothing about who sent a request, though: Origin is not an authentication signal, and clients other than browsers can leave it out. Allowing it is a statement about this one attack, not a way to identify callers. Before choosing this, we checked the callers in our own repository. No page posts to /api on another host. No server caller in the repository sets an Origin on an HTTP request (the bots set one only on the WebSocket to the realtime service), so they all arrive "absent" and pass.
Two kinds of route are stricter. The sign-in confirmation (auth/confirm), where an emailed token becomes a session, refuses a missing Origin too. The /ops controls refuse any Origin but their own, absent included, and they check it after requireOps() on purpose: a caller without access must get the 404 that hides /ops, not a 403 that names it.
Two routes are exempt: the Typeform webhook and the game service's "game finished" callback. Both are server-to-server calls signed with a shared secret, and neither reads a session. The exemptions live in the test, with their reasons.
Why not in the proxy? Next's request interceptor (proxy.ts) looks like the natural place for one rule over every route. Ours deliberately does not match /api, and proxy.test.ts pins that; it is what lets /api answer on both hostnames without a redirect. Moving the guard there would mean changing that or writing an exception list, and a matcher edit would then remove the guard from every route at once. In the handler, the rule sits next to the code it protects, and a test can read it there.
Make the scan prove the guard is used, not just present
A guard copied into dozens of handlers is only as good as the check that the next handler has it too. So app/api/writes-refuse-cross-site.test.ts opens every exported POST, PUT, PATCH and DELETE under app/api and holds one rule: the refusal is written out, at the top level of the handler, before its first await.
"Before the first await" is the useful part. The body, the params, the session and the database are all awaited, so nothing is read before the refusal.
The scan is written to fail rather than pass on nothing, the lesson from our checks whose null case reads as success:
- It asserts floors on what it read: at least 40 files and 50 handlers.
- It counts exports in any form. An
export const POST = wrap(handler)that it cannot open fails, rather than passing unread. - An exemption that names no handler fails. So does an exemption for a handler that no longer needs it.
Our first version accepted a guard that refused nothing. It passed a handler if the text refuseCrossSite( appeared before the first await. That is also true of this:
refuseCrossSite(req);
const body = await req.json();
The answer is computed and thrown away. It is also true of a check inside a callback nobody calls, and of an originOfRequest test that only logs. A local review caught it before the pull request opened. The scan now requires the pair, const r = refuseCrossSite(req); if (r) return r;, at brace depth zero, and those three shapes are samples it must refuse.
All 62 handlers already met the stricter rule, so no route changed. The hole was in the check, not yet in the code. It would have let the next route through.
Before it was trusted, four changes each turned it red: removing the call from one route, dropping its if (refused) return refused;, moving it after await params, and adding a bogus exemption. Then the automated review on the pull request found that an unmatched { inside a template string could cut a handler's body short. The scan now classifies each character as code, text or comment before it counts braces.
A second test, app/api/cross-site-writes-are-refused.test.ts, drives one real handler per family (rooms, teams, profile, auth, invites, daily, inbox) with a text/plain body. "Refused" is proved by what did not happen: no session resolved, no sign-in attempted, no query made. "Goes through" is proved by the first thing each route reaches for being reached, both with no Origin and with our own. A guard that refused everything fails that test as surely as one that refused nothing.
The GETs that still write
The rule covers POST, PUT, PATCH and DELETE. Some GET handlers write too, and they are known and left unguarded:
- the room state poll, which records presence, hands over the host and moves the room's own phase on;
- the team page and invite-link reads, which create the team's invite link the first time it is read;
- the profile read, which creates the player's own profile row;
- the sign-in
GETs that set cookies.
These need care, because Lax sends cookies on a top-level GET navigation even from another site. Any page on the web, not only a sibling, can cause them. We accept them because each does only what the player's own next visit would do, and another site cannot read the answer.
That is the test I would apply to any GET that writes: if a stranger could make it happen for your user, would your user notice anything but a slightly earlier write?
The same survey found a smaller leak
Twenty-eight places in the room routes answered a database failure with the database's own words, problem(500, error.message). That text names tables, columns and constraints, and the lobby, join and host screens printed it. The state route is polled several times a second, so a failing database repeated it on every poll.
| Room route | Sites fixed |
|---|---|
| answer, join | 4 each |
| advance, settings | 3 each |
| leave, rematch, start, state, topic, ws-ticket, room creation | 2 each |
Each site now logs a fixed label with the narrowed cause and answers a fixed sentence, such as "The room could not be joined. Try again in a moment." A 22P02 error quotes the input it could not read, which can be a player's own text, so that part is cut off before it is logged. A scan of every route file (84 files and 665 calls on 2026-09-26) fails a 5xx that mentions .message. Both fixes shipped in the same pull request, #490.
What the numbers say
I recounted the scan's inputs on the merged branch:
| What the scan read | Count |
|---|---|
route files under app/api | 84 |
mutating handlers (46 POST, 9 DELETE, 4 PUT, 3 PATCH) | 62 |
that open with refuseCrossSite | 56 |
that open with a stricter same-origin check (auth/confirm) | 1 |
exempt, with a reason (Typeform, the game callback, three /ops routes) | 5 |
PR #490 was opened at 21:36Z on 2026-09-26 and merged into the beta.9 release branch at 21:55Z. Beta.9 reached production at 04:38Z on 2026-09-27, through release PR #498.
Update, 2026-09-28: right after that release, a probe against production with a foreign Origin got 403 cross_site. That checks the guard on the real domain. It does not check what a browser does with our cookies there.
Predictions vs outcome
| Prediction | What happened | Right? |
|---|---|---|
SameSite=Lax keeps other pages out | it keeps other sites out; every host under hearso.com is in | No |
| "JSON only" blocks a form post | a text/plain form needs no preflight, and req.json() parses it | No |
| The gap is real on a running server | 201 on base, 403 on the fix, 201 for our own page | Yes |
| A scan for the call proves the guard | the first scan passed a call whose answer was dropped | No |
PATCH, PUT and DELETE are exposed too | they need a preflight nobody answers | No |
An absent Origin only comes from servers | every caller checked; none of ours sets one | Yes, for our callers |
Key takeaways
- Treat
SameSite=Laxas a site rule, because every host under your registrable domain, and possibly anhttppage on your own name, is inside the line. - Don't count a host-only cookie as origin protection: it limits where the cookie goes, not which page may send the request.
- Don't count "the route takes JSON" as a defence, since a
text/plainform is a simple request andRequest.json()parses a body whatever its label. - Compare
Originwith the requested host in the handler, before the firstawait, and decide on purpose, route by route, what a missingOriginmeans. - Make a scan prove the guard is used, not just named, because a call whose answer is dropped reads the same as one that works unless the check looks for the
return. - Keep the list of
GETs that write short, and write down why each is acceptable, because any site can cause them.
What I'd do next
- Look for use. Nothing was checked about whether the gap was ever used: no logs were searched and no data was compared. Only a page on one of our own hosts, or an
httppage on our own name, could have used it. - Run the browser half on the real domain. A preview deployment, a sibling host's page, a signed-in test account: expect the 403. It turns the local cookie reasoning into a measurement on the platform we ship on.
- Shorten the list of
GETs that write. Moving the invite-link creation and the profile row creation behind an explicitPOSTwould make it shorter; a read that writes is the one shape this guard cannot see. - Keep the HSTS
preloaddecision in view. It would close thehttpcase for good, and it commits every subdomain tohttps. That belongs to whoever owns the domain.
How we measured
What is proved. The probe was local, on next start, against the development database, with two teams created and deleted. It shows what the route did with such a request. That a browser attaches the cookie in this case comes from the cookie rules described above; I did not measure it against the production domain.
Test totals. On the pull request's final head 41450321: 546 test files, 10,938 unit tests, tsc clean. The full browser tests ran on e3c34d6b, before the review fixes: 176 passed and 2 skipped in one shell, 163 passed and 15 skipped in the other, and the hostname tests 7 of 7. After the review fixes, the room and leave journeys passed again. CI could not run that night: GitHub Actions failed to start org-wide from 10:31Z.
The count. The pull request's description says 41 handlers gained the guard, and so does its changelog fragment. Its own list sums to 42, and a per-file recount between base 461fd329 and merge commit 4d4d36ae agrees: 42 added and 14 migrated, 56 in all.
Evidence
All times UTC.
- The gap, the probe table (201 on base
461fd329, 403cross_siteon the branch, 201 for the same-origin control), the two probe teams created and deleted, and "Not looked at: whether the cross-site gap was ever used": PR #490 body and its notes for reviewers. - 42 handlers added, 14 migrated, the absent-
Originrule, the stricter routes, the two exemptions, and the callers checked: PR #490 body; commits22dd1458andd95e6989; the per-file recount described in "How we measured". - Cookie options (
httpOnly,SameSite=Lax, noDomain):lib/auth-server.ts(COOKIE_BASE) andlib/room-actor.ts. - The guard and what it compares:
lib/refuse-cross-site.tsandlib/same-origin.ts. proxy.tsleaves/apialone: its first matcher entry,"/((?!api/|_next/|docs/|.*\\..*).*)"(the second is/feed.xml), andproxy.test.ts.- HSTS value:
next.config.ts,max-age=31536000; includeSubDomains. - The scan, its floors (40 files, 50 handlers), its exemptions and its samples:
app/api/writes-refuse-cross-site.test.ts; commit29413c04(first version) andb592496b(requires the refusal to be used, from the local review in PR #490's notes). The four red controls are listed in the changelog fragment. - The template-string finding: the automated review on PR #490, fixed in
85503005. - The behaviour test:
app/api/cross-site-writes-are-refused.test.ts; commitbb4e7b55. - No preflight is answered: no
OPTIONSexport and noAccess-Control-Allowheader inapp/,lib/,proxy.tsornext.config.ts, searched 2026-09-27. - The GETs that still write, and why they are accepted:
changelog/unreleased/room-routes-keep-database-error-text-out-of-responses.md. - The 28 room-route sites (per route), the 22P02 cut, and the response scan (84 files, 665 calls): PR #490 body and the same changelog fragment.
- The recount (84 files, 62 handlers by method, 56 with the guard, 1 stricter, 5 exempt): a read-only count on merged branch
4ddb1cc5, 2026-09-27, repeated 2026-09-28. - Test totals, browser tests and the CI outage: PR #490 body (546 files and 10,938 tests on
41450321; browser tests one3c34d6b). - Opened and merged: PR #490, 2026-09-26 21:36:05Z and 21:55:40Z, into the beta.9 release branch.
- In production: merge commit
4d4d36aeis an ancestor ofmainat77323ff2(release PR #498, merged 2026-09-27T04:38:07Z); the overnight handoff's 04:45Z entry (Vercel production READY; the production guard answered 403cross_siteto a foreignOrigin).
No credential, token, player identity or environment value appears in this post. The probe used a development test account and .localhost hostnames.