Skip to content

CSRF, SameSite and the cookie flags that make a session safe

What each session cookie flag actually defends against, why trustedOrigins is your CSRF check, and the three configuration changes that quietly disable both.

Better Auth4 min readships at docs/solutions/better-auth/csrf-and-cookie-flags.md

Tags: better-auth · csrf · cookies · samesite · security · sessions

Session security in a modern app is mostly four cookie attributes and one origin check. They are easy to get right, easy to disable by accident, and almost impossible to debug from the symptom, because a weakened cookie behaves identically to a correct one until someone attacks it.

The four flags

HttpOnly: JavaScript cannot read the cookie. This is the difference between "an XSS bug shows a stranger some of your UI" and "an XSS bug hands them a session they can replay from their own machine for a month". If you ever find yourself reading the session from document.cookie, the cookie is misconfigured, not the code.

Secure: the cookie is only sent over HTTPS. Without it, a single plain-HTTP request on a shared network exposes the session in cleartext. Localhost is the one legitimate exception, which is why this repo derives the flag from whether BETTER_AUTH_URL is https.

SameSite=Lax: the browser does not attach the cookie to cross-site requests, except top-level navigations with a safe method. That single rule blocks the classic CSRF attack: an image tag or auto-submitting form on evil.example that fires a POST at your app carries no session.

SameSite=None re-enables cross-site sending and is only correct if your app is genuinely embedded in another origin: an iframe widget, for instance. It requires Secure, and it puts CSRF defence entirely back on the origin check. SameSite=Strict is stronger and has a well-known cost: someone following a link to your app from an email arrives signed out, then signed in after they navigate once, which reads as a bug to everyone who sees it.

Path=/ and no Domain: a host-only cookie. Setting Domain=.example.com shares the session with every subdomain, including the one running a customer-uploaded static site. Do not widen the scope unless several first-party subdomains genuinely need the same session.

The origin check

SameSite=Lax is not a complete CSRF defence on its own: it permits top-level GET navigations, and browsers have historically shipped bugs and exceptions. The second layer is a server-side origin check, and in Better Auth that is trustedOrigins, seeded from BETTER_AUTH_URL.

Every request to /api/auth/** is checked against that list. A POST arriving with Origin: https://evil.example is rejected before anything else happens. This is why the URL variable is not cosmetic and why a mismatch produces an "invalid origin" error rather than a subtle failure.

Three ways people break it:

trustedOrigins: ["*"],                       // no
trustedOrigins: [req.headers.get("origin")], // no, the attacker sets that
advanced: { disableCSRFCheck: true },        // no

The last one appears in a lot of forum answers as a fix for a local development problem. It is a fix in the same way removing a smoke alarm fixes burnt toast. If sign-in fails locally with an origin error, BETTER_AUTH_URL disagrees with the URL in your address bar: 127.0.0.1 versus localhost, or a missing port. Fix the variable.

Preview deployments

Vercel-style preview URLs change per deployment, so they cannot be listed ahead of time. Add them at runtime from the platform's own environment variable:

trustedOrigins: [
  baseUrl(),
  ...(process.env.VERCEL_URL ? [`https://${process.env.VERCEL_URL}`] : []),
],

The value comes from the platform, not from the request, which is the whole difference between this and the reflected-origin anti-pattern above.

What the flags do not cover

Server actions. A Next.js server action is a POST to your own origin, so SameSite=Lax protects it from cross-site invocation. It does not protect it from a signed-in user calling it directly with arguments you did not expect. Every action still authenticates and authorises itself.

Route handlers that mutate on GET. A GET /api/account/delete is reachable by a top-level navigation, which SameSite=Lax permits. Mutations use POST, PUT, PATCH or DELETE: always. This is why sign-out in this repo is a button that POSTs, not a link.

Subdomain takeover. If the cookie is scoped to .example.com and an old staging.example.com CNAME points at an unclaimed service, whoever claims it can read and set your session cookie. Host-only cookies make this a non-event.

Logging and leaks

A session cookie in a log line is a session anyone with log access can replay.

  • Never log request headers wholesale in a handler that receives cookies.
  • Scrub cookie and set-cookie in your error reporter's before-send hook.
  • Redact tokens in any debug output you add while chasing a bug, before you commit it.

Checking your work

Open devtools → Application → Cookies on a signed-in page:

  • The session cookie shows HttpOnly and SameSite=Lax, and Secure on any https origin.
  • The Domain column shows the exact host, not a leading dot.
  • document.cookie in the console does not contain it.

Then, from a scratch HTML file served on a different port, submit a form that POSTs to your app's sign-out endpoint. The request must fail. If the user is signed out, either the endpoint accepts GET, the cookie is SameSite=None, or the origin check is disabled, and all three are worth finding today rather than in a report.