A session cookie carries either the whole session, sealed and signed, or an opaque id the server looks up (express-session weighs those designs). Five attributes matter, each preventing one failure: HttpOnly keeps an injected script from reading the session, Secure keeps it off plain HTTP, SameSite=lax keeps it off cross-site POSTs (Cross-Site Request Forgery), Path=/ keeps it from going missing on sibling routes, and Max-Age stops a stolen cookie being valid forever. cookies() is asynchronous, and set and delete work only where a Set-Cookie header can still be written: in a Server Action or a route handler, never in a render.
(await cookies()).set("bookshelf_session", sealed, {
httpOnly: true, secure: process.env.NODE_ENV === "production",
sameSite: "lax", path: "/", maxAge: 60 * 60 * 8,
});Signing in to the demo application and watching the response in Chrome 152 1 gives the real header:
set-cookie: bookshelf_session=Fe26.2*1*37b4b8ae972ef63b1ff0b8b5b ... (304 chars)
; Path=/; Expires=Tue, 22 Sep 2026 14:22:02 GMT; Max-Age=28800; Secure;
HttpOnly; SameSite=laxChrome accepted that cookie over plain http://localhost despite Secure, because browsers treat localhost as a secure context — which is why a Secure flag that looks fine in development can still be missing in production if you gate it on NODE_ENV. Keep the payload small and boring: a user id, a role, an expiry. Cookies are capped near 4 KB, travel on every request, and are readable by whoever holds them.