---
title: "CSRF in Next.js route handlers, without tokens"
slug: csrf-in-next-js-route-handlers-without-tokens
section: tech
date: 2026-10-27T09:15:00.000Z
canonical: https://roland.leth.ro/blog/tech/csrf-in-next-js-route-handlers-without-tokens
description: "CSRF protection for Next.js route handlers without tokens: check Sec-Fetch-Site and Origin on every write, since SameSite=Lax lets sibling subdomains in."
---

In [the post about this site’s auth][auth], the admin cookie goes out as `sameSite: "lax"`, and until late September that was the whole defense against CSRF (cross-site request forgery: a page somewhere else getting my browser to send a write to the admin, cookie attached).

A full review of the codebase I ran then flagged exactly that: `Lax` stops _most_ of it, but not a request from a sibling subdomain, and this site is a subdomain itself, `roland.leth.ro`.

Next.js has a check of its own, but only for [Server Actions][next-actions], where it compares the `Origin` header with the host. There are no Server Actions here; every admin write is a route handler under `/api/admin`, and for those, as [the Next.js team’s security post][next-security] puts it, CSRF protection “has to be done manually”.

The check went into the handlers instead, with no tokens: the browser already sends 2 headers that say where a request came from, and a test holds every write handler to them. It’s a single-user site with one admin, so the exact rules fit _it_ — they might be a bit too strict, or not strict enough, for yours.

## SameSite=Lax and sibling subdomains

If we set a cookie as `Lax`, the browser [leaves it off][samesite] cross-site requests, form `POST`s included, and still sends it on top-level `GET` navigations, like following a link.

The catch is really in “cross-site”.

A _site_ isn’t an _origin_: the origin is the scheme, host and port, while the site is, more or less, the scheme plus the registrable domain, the part we actually register (web.dev has [a pretty good explainer][same-site]).

That makes `https://roland.leth.ro` and `https://other.leth.ro` 2 origins, but the same site, and the browser attaches a `Lax` cookie to a `POST` from one to the other without a second thought.

Does that matter? Well, only if someone gets a page on a `*.leth.ro` subdomain first, through a forgotten DNS record pointing at a service anyone can claim, or an XSS hole in some other app on the domain, which is probably a tall order.

It’s not just theory, though. The first one even has a name, a [subdomain takeover][takeover]: a subdomain’s DNS record still points at a hosting service after whatever was on it is gone, so whoever claims that name on the service gets a page on the domain. Owning the main domain doesn’t rule it out, either, since the forgotten record would be one I added myself.

For this site, it’s theory for now, since nothing else lives on `*.leth.ro`. But it only takes one subdomain I set up later and forget about, and the check is a few lines.

The review’s version of the risk was concrete: back then `request.json()` didn’t look at `Content-Type`, so a plain `text/plain` form on such a page could have created a post here, and new posts are published by default.

## Checking Sec-Fetch-Site and Origin

The browser already tells us where a request came from, it just doesn’t do it through the cookie. Here’s the check, minus the logging:

```typescript
// src/lib/api/sameOrigin.ts (excerpt)
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"])

export function refuseCrossSiteWrite(
  request: Request,
  tag: string
): NextResponse | null {
  if (SAFE_METHODS.has(request.method)) {                     // ❶
    return null
  }

  const fetchSite = request.headers.get("sec-fetch-site")
  const origin = request.headers.get("origin")
  const isFetchSiteForeign =
    fetchSite != null && fetchSite !== "same-origin"          // ❷
  const isOriginForeign =
    origin != null && origin !== new URL(request.url).origin  // ❸

  if (!isFetchSiteForeign && !isOriginForeign) {              // ❹
    return null
  }

  // A warn-level log line with both headers, then:
  return NextResponse.json(
    { error: "Cross-site request refused" },
    { status: 403 }
  )
}
```

Reads pass right away ❶, whatever their headers say.

Writes go through 2 checks, and _either_ one is enough to refuse.

`Sec-Fetch-Site` ❷ is one of the [Fetch Metadata][spec] headers. The browser sets it, and since its name starts with `Sec-`, [a script can’t touch it][fetch-metadata].

It has [4 values][sec-fetch-site]: `same-origin`, `same-site`, `cross-site` and `none` (the last one for requests the user started directly, like typing a URL). We only let `same-origin` through: `same-site` is exactly the sibling-subdomain case, and the admin’s own writes all come from its own pages, so `none` goes too.

`Origin` ❸ covers browsers that don’t send `Sec-Fetch-Site`. It has to match the request’s own origin exactly, so `http://roland.leth.ro`, `https://roland.leth.ro:8443` and the literal string `null` (what a sandboxed iframe sends, for example) are all refused.

When the 2 headers disagree, say a `same-origin` fetch site with a foreign `Origin`, we trust neither.

What about a request with neither header? We let it through ❹.

Current browsers [send `Origin`][origin] on every request that isn’t a `GET` or a `HEAD`, so a write without it probably didn’t come from another site’s page. More likely it came from `curl` or a script, which needs the cookie’s value before it can do anything, and at that point CSRF is the least of my problems. It’s the one rule here that leans on browsers behaving, so I left myself a note to revisit it if one turns up that sends neither header, or if the admin API ever gets a client that isn’t a browser.

There’s also a smaller layer for the handlers that take JSON. An HTML form can only send `text/plain`, `multipart/form-data` or `application/x-www-form-urlencoded`, and a cross-origin `fetch` with anything else [needs a CORS preflight][cors], which this site never says yes to. That’s why the [`parseJsonBody` helper][validation] now answers 415 to any other `Content-Type`.

That doesn’t cover the upload route, which takes `multipart/form-data`, or the IndexNow and keepalive routes, which take no body at all. For those 3, the origin check is really the only thing in the way.

## Where the check runs

Every admin handler already opens with `requireAdmin`, which I added in July as a second lock behind the proxy, after a bug in the proxy’s path matching let some admin paths skip it entirely. The cross-site check went in there:

```typescript
// src/lib/api/requireAdmin.ts (excerpt)
export async function requireAdmin(
  request: Request,
  tag: string
): Promise<NextResponse | null> {
  if (!(await verifySession())) {                 // ❶
    logMiddlewareBypass(tag, "the handler")

    return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
  }

  return refuseCrossSiteWrite(request, tag)       // ❷
}
```

The session check comes first ❶, so a request that slipped past the proxy without a session still answers 401 and gets logged as a bypass, instead of a quieter 403.

The cross-site check runs after it ❷.

Why not in the proxy? Because then it would depend on the proxy’s path matching being right, which is the one thing `requireAdmin` was added to stop relying on. A new handler also gets the check just by making the call it has to make anyway.

`adminAuthContract.test.ts` keeps “every handler” true. It lists the 14 admin route files by hand (a glob that matched nothing would make a test that always passes), fails if a route file isn’t on the list, and makes every exported write handler, 18 of them today, answer 403 to a cross-site request:

```typescript
// src/app/api/admin/adminAuthContract.test.ts (excerpt)
const CROSS_SITE_HEADER_SETS = [
  { label: "Sec-Fetch-Site only", headers: { "sec-fetch-site": "same-site" } }, // ❶
  { label: "Origin only", headers: { origin: "http://other.localhost" } },
]

// For every route module and every header set:
vi.mocked(verifySession).mockResolvedValue(true)                               // ❷

// `writes` is every exported handler that isn't GET.
for (const method of writes) {
  const handler = routeModule[method] as Handler
  const request = new Request("http://localhost/api/admin/probe", {
    method,
    headers,
  })
  const response = await handler(request, {
    params: Promise.resolve({ id: "1" }),
  })

  expect(
    response.status,
    `${method} answered ${response.status} to a cross-site write`
  ).toBe(403)
}
```

Each header goes in on its own ❶, so a handler that only looked at one of them fails.

And the session is valid ❷, because without one every handler would stop at the 401, and the test would pass without the cross-site check ever running.

Login and logout live outside `/api/admin`, so they call `refuseCrossSiteWrite` directly. Login is the one my September change missed; the next day’s review caught it.

On login, the check runs _before_ the rate limiter, on purpose. The limiter from the auth post buckets attempts by IP, so a page I visit could fire cross-site form `POST`s from my own browser, and my own IP, until I’m locked out of my own admin. A test sends one and checks both the 403 and that the limiter was never called.

Logout can only end a session, which from another `*.leth.ro` page is just a nuisance; it had been on a review’s list since May, and it got the same line in September.

None of this covers everything. An XSS hole on `roland.leth.ro` itself is same-origin by definition, and a handler that writes on a `GET` would be wide open again, since the check lets reads through and `Lax` sends the cookie on top-level `GET` navigations.

I also didn’t take the `__Host-` cookie prefix, which stops a sibling from planting a cookie in place of mine. With one user and a signed JWT, a planted cookie gets nobody anything, and the rename would complicate development over `http://localhost`.

If you’ve done this differently, or you can see a hole in the neither-header rule, I’m at [@roland.leth.ro][] and [@rolandleth][].

[auth]: /blog/tech/auth-for-a-single-user-site-without-nextauth "Auth for a single-user site, without NextAuth"
[validation]: /blog/tech/input-validation-and-logs-that-dont-leak "Input validation and logs that don't leak"
[next-actions]: https://nextjs.org/docs/app/guides/server-actions "Next.js: Server Actions"
[next-security]: https://nextjs.org/blog/security-nextjs-server-components-actions "How to Think About Security in Next.js"
[takeover]: https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/Subdomain_takeover "MDN: Subdomain takeover"
[samesite]: https://web.dev/articles/samesite-cookies-explained "SameSite cookies explained"
[same-site]: https://web.dev/articles/same-site-same-origin "Same-site and same-origin"
[spec]: https://www.w3.org/TR/fetch-metadata/ "Fetch Metadata Request Headers"
[fetch-metadata]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Fetch_metadata "MDN: Fetch metadata"
[sec-fetch-site]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site "MDN: Sec-Fetch-Site"
[origin]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin "MDN: Origin"
[cors]: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS "MDN: Cross-Origin Resource Sharing (CORS)"
[@roland.leth.ro]: https://bsky.app/profile/roland.leth.ro "@roland.leth.ro on Bluesky"
[@rolandleth]: https://x.com/rolandleth "@rolandleth on X"
