engineering

42 write routes trusted SameSite=Lax, which trusts the whole site

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.

A bank of numbered metal mailboxes in a hallway, with columns labelled 14 to 17
Photograph by Aravind Balabhaskar on Unsplash

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.

A matrix: SameSite=Lax sends the app's cookies with a POST from all six origins under hearso.com, including an http page on the app's own name, and withholds them only from another site; the new guard passes only the app's own origin and answers 403 cross_site to the other six.

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.

Two paths from a sibling page: a text/plain form POST needs no preflight, carries the app's cookies and reaches the handler, which answered 201 before the fix; a PATCH, PUT, DELETE or JSON POST sent with fetch needs a preflight our routes never answer, so the write is never sent.

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:

BuildWhat was sentAnswer
base 461fd329text/plain POST /api/teams, Origin: http://news.localhost201, a team was created
the fix branchthe same request403 cross_site, nothing created
the fix branchthe same request, Origin: http://app.localhost (our own page)201

Three probes: on the base build a POST carrying a sibling host's Origin created a team with 201; on the fix branch the same request got 403 cross_site and created nothing; the same-origin control still got 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 routeSites fixed
answer, join4 each
advance, settings3 each
leave, rematch, start, state, topic, ws-ticket, room creation2 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 readCount
route files under app/api84
mutating handlers (46 POST, 9 DELETE, 4 PUT, 3 PATCH)62
that open with refuseCrossSite56
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

PredictionWhat happenedRight?
SameSite=Lax keeps other pages outit keeps other sites out; every host under hearso.com is inNo
"JSON only" blocks a form posta text/plain form needs no preflight, and req.json() parses itNo
The gap is real on a running server201 on base, 403 on the fix, 201 for our own pageYes
A scan for the call proves the guardthe first scan passed a call whose answer was droppedNo
PATCH, PUT and DELETE are exposed toothey need a preflight nobody answersNo
An absent Origin only comes from serversevery caller checked; none of ours sets oneYes, for our callers

Key takeaways

  1. Treat SameSite=Lax as a site rule, because every host under your registrable domain, and possibly an http page on your own name, is inside the line.
  2. Don't count a host-only cookie as origin protection: it limits where the cookie goes, not which page may send the request.
  3. Don't count "the route takes JSON" as a defence, since a text/plain form is a simple request and Request.json() parses a body whatever its label.
  4. Compare Origin with the requested host in the handler, before the first await, and decide on purpose, route by route, what a missing Origin means.
  5. 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.
  6. 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 http page 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 explicit POST would make it shorter; a read that writes is the one shape this guard cannot see.
  • Keep the HSTS preload decision in view. It would close the http case for good, and it commits every subdomain to https. 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, 403 cross_site on 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-Origin rule, the stricter routes, the two exemptions, and the callers checked: PR #490 body; commits 22dd1458 and d95e6989; the per-file recount described in "How we measured".
  • Cookie options (httpOnly, SameSite=Lax, no Domain): lib/auth-server.ts (COOKIE_BASE) and lib/room-actor.ts.
  • The guard and what it compares: lib/refuse-cross-site.ts and lib/same-origin.ts.
  • proxy.ts leaves /api alone: its first matcher entry, "/((?!api/|_next/|docs/|.*\\..*).*)" (the second is /feed.xml), and proxy.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; commit 29413c04 (first version) and b592496b (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; commit bb4e7b55.
  • No preflight is answered: no OPTIONS export and no Access-Control-Allow header in app/, lib/, proxy.ts or next.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 on e3c34d6b).
  • 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 4d4d36ae is an ancestor of main at 77323ff2 (release PR #498, merged 2026-09-27T04:38:07Z); the overnight handoff's 04:45Z entry (Vercel production READY; the production guard answered 403 cross_site to a foreign Origin).

No credential, token, player identity or environment value appears in this post. The probe used a development test account and .localhost hostnames.

Get the next one

We write these up when something is worth writing up: roughly once a month, never on a schedule. Every number in them comes from a run we can point at.

One email when there is something to read. Unsubscribe in a click.

HearsoHEARSO · LOADING

Loading, 0%

Never goes backwards. Never lies about being done. Under a second on a good day.

ASSETS · STATE · HANDSHAKE
Help improve Hearso

With your permission, we measure how you use Hearso (pages, buttons, your display name, your squad's name and your games) and record sessions and click heatmaps of how pages are used. We never send your first name, last name, email, phone number or passwords to analytics, and recordings hide them. You can change this in Settings after signing in. Hearso also keeps anonymous totals of rounds, players, and live rooms without this permission; see the player guide.