engineering

SameSite=Lax is a same-site promise, not a same-origin one

Forty-two write routes trusted a SameSite=Lax cookie. Lax trusts the whole 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.

This is a sequel of learnings to 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.

What I was looking at

On the night of 2026-09-26 we made an edge-case pass over every route in app/api on my laptop, looking for gaps the per-route tests could not see. It found two. This post is about the bigger one: about 40 cookie-authenticated POST, PUT, PATCH and DELETE handlers had no check of where the request came from.

A local read-only review went over the branch before it was opened, and the automated review on the pull request read it after. It was opened as PR #490 at 21:36Z and merged into the beta.9 release branch at 21:55Z.

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. The class of gap here is not specific to us. If your app has cookie sessions, route handlers that take JSON, and more than one host under one domain, the rest of this post is 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. 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. Neither is a defence against a request from a page on our own domain.

Same site is not same origin

An origin is a scheme, a host and a port: https://app.hearso.com. A site is coarser. It 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 compares the registrable domain and not the scheme. A newer definition ("schemeful same-site") compares the scheme too, and browsers differ. We could not rely on either, so we treated the http page as the 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.

Two more assumptions that did not hold

"The routes only take JSON" sounds like a second wall. It is not, 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. The browser's rules for simple requests and preflights; the 201 is the base build's answer to the probe in Figure 3.

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.

One request to find out

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.

If you want to run the same check on your app, the shape is this. 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. Browsers send Origin on every POST, PUT, PATCH and DELETE. A request without one comes from a server: our bots, the cutover scripts, a test. None of those carries the cookie of a player someone else is riding. Before choosing this, the branch checked the callers. 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, which is the next section.

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 either 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.

A scan that says what it read

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, which is 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.

I recounted its inputs on the merged branch on 2026-09-27:

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

The review found that the 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. 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. The automated review on the pull request then 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.

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.

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.

This one needs 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 these. 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 sibling fix in the same PR

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.

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, 665 calls on 2026-09-26) fails a 5xx that mentions .message.

What is not proved

Nothing was checked about whether the gap was ever used. No logs were searched and no data was compared. The PR says so in its notes, and so does the changelog. Only a page on one of our own hosts, or an http page on our own name, could have used it.

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.

When I wrote this, the fix was merged into the beta.9 release branch and not yet in production. The whole local block on the final head 41450321 passed: 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. CI could not run that night: GitHub Actions failed to start org-wide from 10:31Z.

Key takeaways

  • SameSite=Lax is about the site, the registrable domain. Every host under it, and possibly an http page on your own name, is inside the line.
  • A host-only cookie limits where the cookie goes, not which page may send the request.
  • "The route takes JSON" is not a defence. A text/plain form is a simple request, and Request.json() parses a body whatever its label.
  • Compare Origin with the host the request asked for, in the handler, before the first await. Decide on purpose what a missing Origin means, route by route.
  • A scan that proves a guard is present has to prove it is used. A call whose answer is dropped reads the same as a call that works, unless the check looks for the return.
  • A GET that writes can be caused by any site. Keep that list short, and write down why each one is acceptable.

What we would do next

Run the same probe once against a preview deployment on the real domain, with a sibling host's Origin, and expect the 403. It costs one request and turns the local result into one on the platform we ship on.

Then look at the list of GETs that write. Each is accepted today for a written reason, but a read that writes is the one shape this guard cannot see. Moving the invite-link creation and the profile row creation behind an explicit POST would make the list shorter.

Finally, keep the preload decision from the earlier post in view. It would close the http case for good, and it commits every subdomain to https. That belongs to whoever owns the domain.

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 (2026-09-26, 21:00Z). The PR's description says 41 added, but its own list of added handlers sums to 42. Recounted per file between its base 461fd329 and its merge commit 4d4d36ae: 42 handlers gained the guard and 14 hand-written checks moved onto it, 56 in all, which matches the 56 in the recount below.
  • 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 matcher, "/((?!api/|_next/|docs/|.*\\..*).*)", and proxy.test.ts.
  • HSTS value: next.config.ts, max-age=31536000; includeSubDomains.
  • The scan, its floors, 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). The four red controls are listed in the changelog fragment.
  • The recount (84 files, 62 handlers by method, 56 with the guard, 1 stricter, 5 exempt): a read-only run of the scan's own helpers on merged branch 4ddb1cc5, 2026-09-27.
  • The template-string finding: the automated review on PR #490, fixed in 85503005 (21:48Z).
  • 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, the 22P02 cut, and the response scan (84 files, 665 calls): PR #490 body and the same changelog fragment.
  • 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.

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 basic game usage, safe button/link interactions, and IP-based traffic data, and we make masked recordings and click heatmaps of how pages are used (every word hidden). We do not send your email, name, country, answers, or sign-in tokens to analytics. 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.