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

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:

```ts
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:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
