Skip to content

Commit acf8d77

Browse files
pi0claude
andcommitted
feat(session): default session cookie to SameSite=Lax
No SameSite attribute was set on the sealed session cookie. Chrome defaults to Lax, but Firefox/Safari are inconsistent, leaving the cookie attached to cross-site requests (CSRF surface). Overridable via config.cookie (including sameSite: false to omit it). Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent a173e99 commit acf8d77

4 files changed

Lines changed: 32 additions & 1 deletion

File tree

docs/4.examples/handle-session.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ app.use(async (event) => {
142142
143143
Every option is optional except `password`. The `name` option is worth calling out: it sets the cookie used to store the session and defaults to `h3`. H3 also reads the session from a request header derived from `name`, which it normalizes to lowercase as `x-${name.toLowerCase()}-session`, so the default name `h3` produces the `x-h3-session` header seen earlier. A mixed-case `name` like `MyApp` still resolves to a lowercase `x-myapp-session` header, while the cookie keeps the original casing. That default is why the earlier examples set a cookie named `h3`.
144144
145+
> [!NOTE]
146+
> The session cookie defaults to `secure: true`, `httpOnly: true`, `sameSite: "lax"`, and `path: "/"`. Any of these can be overridden via `cookie`.
147+
145148
> [!NOTE]
146149
> The `secure: true` option tells the browser to only store and send the cookie over HTTPS. When developing locally over plain HTTP, compliant browsers (notably Safari and iOS, and Chrome on some local domains) silently drop the cookie, so the session will not persist. Set `cookie: { secure: false }` during local development to work around this.
147150

src/utils/internal/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export const DEFAULT_SESSION_COOKIE: SessionConfig["cookie"] = {
1212
path: "/",
1313
secure: true,
1414
httpOnly: true,
15+
sameSite: "lax",
1516
};

src/utils/session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export interface SessionConfig {
4242
maxAge?: number;
4343
/** default is h3 */
4444
name?: string;
45-
/** Default is secure, httpOnly, / */
45+
/** Default is secure, httpOnly, sameSite lax, / */
4646
cookie?: false | (CookieSerializeOptions & { chunkMaxLength?: number });
4747
/** Default is x-h3-session / x-{name}-session */
4848
sessionHeader?: false | string;

test/session.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,33 @@ describeMatrix("session", (t, { it, expect }) => {
3737
});
3838
});
3939

40+
it("sets SameSite=Lax by default", async () => {
41+
const result = await t.fetch("/");
42+
expect(result.headers.getSetCookie()[0]).toContain("SameSite=Lax");
43+
});
44+
45+
it("allows overriding SameSite via config.cookie", async () => {
46+
t.app.get("/strict", async (event) => {
47+
const session = await useSession(event, {
48+
...sessionConfig,
49+
cookie: { sameSite: "strict" },
50+
});
51+
return { session };
52+
});
53+
const strict = await t.fetch("/strict");
54+
expect(strict.headers.getSetCookie()[0]).toContain("SameSite=Strict");
55+
56+
t.app.get("/none", async (event) => {
57+
const session = await useSession(event, {
58+
...sessionConfig,
59+
cookie: { sameSite: false },
60+
});
61+
return { session };
62+
});
63+
const none = await t.fetch("/none");
64+
expect(none.headers.getSetCookie()[0]).not.toContain("SameSite");
65+
});
66+
4067
it("gets same session back", async () => {
4168
const result = await t.fetch("/", { headers: { Cookie: cookie } });
4269
expect(await result.json()).toMatchObject({

0 commit comments

Comments
 (0)