Lesson 03 of 04

Cookies, SameSite, and CSRF

Intermediate9 min

Four cookie attributes decide whether your auth is sound. Most tutorials set two of them.

OutcomesAfter this you will be able to

  • Set a session cookie with correct security attributes
  • Explain what SameSite=Lax does and does not protect
  • Decide whether you still need a CSRF token

01The four that matter

ts
cookies().set('session', sessionId, {
  httpOnly: true,   // JavaScript cannot read it — blunts XSS token theft
  secure: true,     // HTTPS only — never sent in cleartext
  sameSite: 'lax',  // not sent on cross-site POSTs — blunts CSRF
  path: '/',
  maxAge: 60 * 60 * 24 * 7,
});
  • `httpOnly` — without it, any XSS on your site reads the session out of `document.cookie` immediately.
  • `secure` — without it, one plain-HTTP request on a shared network hands over the session.
  • `sameSite` — the CSRF control. `lax` is the sane default; `strict` breaks inbound links from email and search.
  • `maxAge` / `expires` — absent, it becomes a session cookie that dies with the browser. Sometimes right, rarely intentional.

02What SameSite=Lax actually covers

`Lax` withholds the cookie on cross-site sub-requests and cross-site POSTs, but sends it on top-level GET navigations — so following a link from another site keeps you logged in. That combination blocks the classic CSRF attack, which is a hidden form auto-submitting a POST from an attacker's page.

It does not cover you if a state-changing action is reachable by GET. A `GET /account/delete` link is exploitable with SameSite=Lax set correctly, because a top-level navigation is exactly the case Lax permits. State changes go over POST. Always.

03Do you still need a token?

With `SameSite=Lax`, POST-only mutations, and an `Origin` header check on the server, the residual risk is small. Add explicit CSRF tokens when you need defence in depth, when you must support browsers you cannot vouch for, or when a request can be triggered from a subdomain you do not fully control — `SameSite` treats subdomains as same-site.

ts
// cheap, effective server-side check on every mutation
const origin = request.headers.get('origin');
const allowed = [process.env.NEXT_PUBLIC_SITE_URL];

if (request.method !== 'GET' && !allowed.includes(origin ?? '')) {
  return new Response('Forbidden', { status: 403 });
}

Exercise

Audit your own cookies

Open devtools → Application → Cookies on any app you have built. Check the HttpOnly, Secure and SameSite columns on your session cookie. If any are blank, you have found this lesson's point in your own code.