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
cookieandset-cookiein 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
HttpOnlyandSameSite=Lax, andSecureon any https origin. - The Domain column shows the exact host, not a leading dot.
document.cookiein 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.