CSRF in SvelteKit cookie auth: what SameSite=Lax does and does not stop

Most posts about CSRF in SvelteKit stop at sameSite: 'lax' and call it handled. It handles a real and large class of attack, and it leaves two holes that a production app should know about. This guide covers the layers, the holes, and the tests that hold the whole thing in place.

The four layers, and what each one is not

CSRF protection is not one mechanism. In a SvelteKit app using cookie sessions there are four independent things doing work, and it matters which one you are relying on for which attack.

httpOnly stops a cross-site script from reading the session token and replaying it. It does nothing against anything that can already run script on your origin — that is XSS, not CSRF.

sameSite: 'lax' stops the browser attaching the session cookie to a cross-site POST, PUT or DELETE, so the attacker gets the request without your credential. It does not stop a same-site subdomain attacker, and it does not stop a top-level GET navigation. Both of those are below, and both are the part most guides skip.

SvelteKit's csrf.checkOrigin stops cross-origin form POSTs, by comparing the request origin header against the server origin. It does not cover anything that is not a form-shaped POST. It is on by default — leave it that way.

The last one is not a setting. Not mutating state on GET removes the widest class of CSRF outright: a link or an image tag that changes data. Honour it and there is nothing left for it to fail to stop.

The two holes sameSite=lax leaves open

These are the parts that get skipped. Both are real, and neither is fixed by adding a token.

The same-site subdomain hole

SameSite judges the site, not the origin. If an attacker controls any subdomain of your domain — a forgotten staging deploy, a dangling CNAME, a third-party tool that accepts arbitrary subdomains — then evil.example.com can issue a request toapp.example.com and the browser will still attach the cookie, because they are the same site. The mitigation is to serve the app on a domain that shares nothing with anything else you own, or to add a server-side origin check on state-changing requests. SameSite alone cannot close this one.

Top-level GET still sends the cookie

sameSite: 'lax' deliberately allows cookies on top-level GET navigation, because that is what makes following a link into a logged-in site work. The consequence is that any state change you perform on a GET is reachable by a bare link. Never mutate on GET: confirmation links must render a form that POSTs, and the handler must verify the token server-side.

One definition, not four copies

The failure mode here is not exotic. When the cookie options are written out separately at each call site — once at login, once at invite-accept, once at token rotation — they drift. Someone hardens the login path, forgets the invite path, and the weaker path stays open with no test failing and nothing in a code review catching it.

So the options live in exactly one exported place per starter, and every call site uses them:

export const SESSION_COOKIE_OPTIONS = {
  path: '/',
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
  maxAge: 30 * 24 * 60 * 60,
} as const;

export function setSessionCookie(cookies: Cookies, token: string): void {
  cookies.set(SESSION_COOKIE, token, { ...SESSION_COOKIE_OPTIONS });
}

The Supabase variant differs here and the test records it. Its cookie holds a rotating Supabase refresh token, so the helper takes secure as an argument and sets no maxAge: a fixed 30-day lifetime would outlast the rotation. The SQLite options are not reused there, since copying them across would carry that lifetime into a token that is designed to be replaced.

What the tests actually assert

Each of these is asserted in the suite, so a change to the cookie options fails a test rather than passing silently:

  • The session cookie is set with `httpOnly: true`, so JavaScript cannot read the token.
  • It is set with `sameSite: 'lax'`, pinned in a test so a future edit cannot quietly change the value.
  • The exact canonical option object is asserted at the call site, so a call site cannot pass a weakened set.
  • The Supabase variant is asserted to carry **no** `maxAge`, because it holds a rotating Supabase refresh token — a 30-day maxAge copied from the SQLite variant would outlive the rotation. That is precisely the copy-paste bug the single-sourced helper exists to prevent.
  • The test suite pins `sameSite` to `lax`, so the threat model documented on this page cannot silently stop describing the code — change the cookie option and the test fails and forces a rewrite. The two limits themselves are browser behaviour and are not unit-tested; what is unit-tested is that we have not claimed coverage we do not have.
  • A separate test walks every module that SvelteKit also runs in the browser — `.svelte` files and `+page.ts` / `+layout.ts` — and fails if any of them imports server-only auth code or reads the session cookie from `document.cookie`. That is the check that keeps the token out of the client bundle.

The last of these is the one that keeps this page honest. A test asserting only that sameSite is 'lax' would still pass after the limits documented above changed, so the limits are asserted alongside it.

A short checklist

  • Session cookie is httpOnly, secure in production, and scoped to path: '/'.
  • Cookie options are defined once and imported, never retyped at a call site.
  • csrf.checkOrigin is left at its default of enabled — it is not a setting you need to add.
  • No route mutates state on a GET.
  • The app's domain shares no site with staging, previews, or any other subdomain you control.
  • Confirmation links render a form that POSTs, and the handler verifies the token server-side.

Related reading

Get in touch

Questions about the product, team licenses, or anything else? We'll aim to respond within 48 hours.

Max 2000 characters

Stored in our own database — no third party. Deleted on request.