Lesson 03 of 04
Cookies, SameSite, and CSRF
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
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.
// 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.