From 95c2936575fb74b40a6c892549de4b467bfeb80d Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Mon, 12 Oct 2026 08:39:19 +0200 Subject: [PATCH 1/4] fix(auth): keep private route cache policies on authenticated responses --- .../void/src/runtime/better-auth-shared.ts | 24 +++++-- .../integration/auth-isr-cache-policy.test.ts | 72 +++++++++++++++++++ .../void/test/unit/auth-middleware.test.ts | 19 +++++ 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index 20007bb63..bdc8d3b8f 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -222,10 +222,19 @@ function appendAuthHeaders( return; } + // A route policy that already keeps the response out of shared caches is as safe + // as no-store and may allow browser revalidation (ETag + 304) of private data. + const routeKeepsPrivate = hasCacheDirective(c.res.headers.get('cache-control'), [ + 'private', + 'no-store', + ]); source.forEach((value, key) => { // getSession's response policy belongs to the session endpoint. An anonymous // lookup must not make the application's public page or asset uncacheable. - if (!includeCachePolicy && ['cache-control', 'pragma'].includes(key.toLowerCase())) { + if ( + (!includeCachePolicy || routeKeepsPrivate) && + ['cache-control', 'pragma'].includes(key.toLowerCase()) + ) { return; } c.header(key, value, { @@ -234,16 +243,19 @@ function appendAuthHeaders( }); if ( source.has('set-cookie') && - !(source.get('cache-control') ?? '') - .split(',') - .some((directive) => - ['private', 'no-store', 'no-cache'].includes(directive.trim().toLowerCase()), - ) + !routeKeepsPrivate && + !hasCacheDirective(source.get('cache-control'), ['private', 'no-store', 'no-cache']) ) { c.header('Cache-Control', 'private, no-store'); } } +function hasCacheDirective(policy: string | null, directives: Array): boolean { + return (policy ?? '') + .split(',') + .some((directive) => directives.includes(directive.trim().toLowerCase())); +} + function resolveUserConfig( userConfig: VoidAuthConfig | undefined, defaults: BetterAuthOptions, diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts index e80dcbc6f..226a140d8 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -99,3 +99,75 @@ describe('real anonymous Better Auth lookup cache policy', () => { await expect(response.json()).resolves.toBeNull(); }); }); + +describe('real authenticated Better Auth session cache policy', () => { + async function signedInApp(routeHeaders: Record) { + const database = memoryAdapter({ user: [], session: [], account: [], verification: [] }); + const runtime = createVoidAuthRuntime( + 'sqlite', + async () => ({ database, dispose: async () => {} }), + async () => {}, + ); + const app = new Hono(); + app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options)); + app.on(['GET', 'POST'], '/api/auth/*', (c) => runtime.handleVoidAuthRequest(c, options)); + app.get('/api/report', (c) => c.text('report', 200, routeHeaders)); + const signUp = await app.request( + 'https://app.test/api/auth/sign-up/email', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Cache user', + email: 'cache-user@example.test', + password: 'correct-horse-battery-staple', + }), + }, + env, + ); + expect(signUp.status).toBe(200); + const cookie = signUp.headers + .getSetCookie() + .map((value) => value.split(';')[0]) + .join('; '); + const session = await app.request( + 'https://app.test/api/auth/get-session', + { headers: { cookie } }, + env, + ); + await expect(session.json()).resolves.toMatchObject({ + user: { email: 'cache-user@example.test' }, + }); + return { app, cookie }; + } + + it.each(['private, no-cache', 'private, max-age=60', 'no-store'])( + 'keeps a route policy that already stays out of shared caches: %s', + async (policy) => { + const { app, cookie } = await signedInApp({ 'cache-control': policy, etag: 'W/"report-1"' }); + const response = await app.request( + 'https://app.test/api/report', + { headers: { cookie } }, + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe(policy); + expect(response.headers.has('pragma')).toBe(false); + expect(response.headers.get('etag')).toBe('W/"report-1"'); + }, + ); + + it.each([null, 'public, max-age=60', 'no-cache'])( + 'applies the session lookup no-store to a route policy of %s', + async (policy) => { + const { app, cookie } = await signedInApp(policy ? { 'cache-control': policy } : {}); + const response = await app.request( + 'https://app.test/api/report', + { headers: { cookie } }, + env, + ); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + }, + ); +}); diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 942cb973e..6755832ca 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -178,6 +178,25 @@ describe('runVoidAuthMiddleware response headers', () => { expect(response.headers.get('cache-control')).toBe('private, no-store'); expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); }); + + it.each([ + ['without its own cache policy', new Headers()], + ['with a no-store policy', new Headers({ 'cache-control': 'no-store', pragma: 'no-cache' })], + ])('keeps a private route policy through a cookie rotation %s', async (_label, authHeaders) => { + authHeaders.append('set-cookie', 'session=rotated; Path=/; HttpOnly'); + const app = createApp({ response: null, headers: authHeaders }); + app.get('/refresh', (c) => + c.text('content', 200, { 'cache-control': 'private, no-cache', etag: 'W/"v1"' }), + ); + const response = await app.request( + 'http://localhost/refresh', + { headers: { cookie: 'session=current' } }, + { BETTER_AUTH_SECRET: 'test-secret' }, + ); + expect(response.headers.get('cache-control')).toBe('private, no-cache'); + expect(response.headers.has('pragma')).toBe(false); + expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); + }); }); describe('cross-origin cookie sessions', () => { From 76ad922b4724be4373adda646939757a7e140670 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Mon, 12 Oct 2026 10:46:30 +0200 Subject: [PATCH 2/4] test(auth): pin field-scoped private to no-store and document route cache policies --- docs/guide/auth.md | 2 ++ packages/void/test/integration/auth-isr-cache-policy.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 40617b404..3126ede13 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -127,6 +127,8 @@ Auth sessions use the same database as your app. Void creates `BETTER_AUTH_SECRE Local development needs no secret setup. To preview a production build locally, provide `BETTER_AUTH_SECRET`. +Responses to signed-in requests get `Cache-Control: no-store`, so no cache keeps a user's data. A route that sets its own `Cache-Control` with `private` or `no-store` keeps it, for example `private, no-cache` with an `ETag` to answer repeat requests with `304`. + `POST`, `PUT`, `PATCH` and `DELETE` requests and WebSocket connections from another origin don't use the signed-in session unless that origin is in `trustedOrigins`. If a separate frontend calls your app, add its origin there (see [Customization](#customization)). If your routes use their own cookies, check the request `Origin` yourself, for example with Hono's [`csrf()`](https://hono.dev/docs/middleware/builtin/csrf) middleware. Deployed apps on Cloudflare limit auth requests per client IP with Better Auth's defaults, for example 3 sign-in attempts per 10 seconds. Each Worker instance keeps its own count. Change or turn off the limits with `rateLimit` in `auth.ts`. To share one count across instances, set `rateLimit: { storage: 'database' }`, then run `void db generate` for a direct Cloudflare deploy. diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts index 226a140d8..7fecd72ca 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -157,7 +157,7 @@ describe('real authenticated Better Auth session cache policy', () => { }, ); - it.each([null, 'public, max-age=60', 'no-cache'])( + it.each([null, 'public, max-age=60', 'no-cache', 'private="set-cookie", max-age=60'])( 'applies the session lookup no-store to a route policy of %s', async (policy) => { const { app, cookie } = await signedInApp(policy ? { 'cache-control': policy } : {}); From 8af5b3f692f1ccb553946ad066a0c559e1b79c62 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Mon, 12 Oct 2026 13:09:31 +0200 Subject: [PATCH 3/4] test(auth): align authenticated cache fixture with runtime API --- .../test/integration/auth-isr-cache-policy.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts index 7fecd72ca..6855468f5 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -103,14 +103,12 @@ describe('real anonymous Better Auth lookup cache policy', () => { describe('real authenticated Better Auth session cache policy', () => { async function signedInApp(routeHeaders: Record) { const database = memoryAdapter({ user: [], session: [], account: [], verification: [] }); - const runtime = createVoidAuthRuntime( - 'sqlite', - async () => ({ database, dispose: async () => {} }), - async () => {}, - ); + const runtime = createVoidAuthRuntime('sqlite', async () => {}); const app = new Hono(); - app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options)); - app.on(['GET', 'POST'], '/api/auth/*', (c) => runtime.handleVoidAuthRequest(c, options)); + app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options, database)); + app.on(['GET', 'POST'], '/api/auth/*', (c) => + runtime.handleVoidAuthRequest(c, options, database), + ); app.get('/api/report', (c) => c.text('report', 200, routeHeaders)); const signUp = await app.request( 'https://app.test/api/auth/sign-up/email', From 8b0c2246df0b2a205e1fec95d415471269091403 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 13 Oct 2026 12:41:38 +0800 Subject: [PATCH 4/4] fix(auth): keep only route policies that revalidate private data A bare `private` still lets the browser reuse a signed-in response without asking the Worker, so the next account on the same browser can see it. Keep the route's Cache-Control only when it has `no-store`, or `private` together with `no-cache`. Every other policy gets the session lookup's `no-store`, as before. Parse directives with quoted values intact, so a comma inside a quoted value can't produce a false bare directive. Document `etag()` for 304s, per-user ETags, and the Back button limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/auth.md | 2 +- .../void/src/runtime/better-auth-shared.ts | 34 +++++++---- .../integration/auth-isr-cache-policy.test.ts | 60 ++++++++++++++----- .../void/test/unit/auth-middleware.test.ts | 34 ++++++++--- 4 files changed, 92 insertions(+), 38 deletions(-) diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 3126ede13..2e16fa3af 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -127,7 +127,7 @@ Auth sessions use the same database as your app. Void creates `BETTER_AUTH_SECRE Local development needs no secret setup. To preview a production build locally, provide `BETTER_AUTH_SECRET`. -Responses to signed-in requests get `Cache-Control: no-store`, so no cache keeps a user's data. A route that sets its own `Cache-Control` with `private` or `no-store` keeps it, for example `private, no-cache` with an `ETag` to answer repeat requests with `304`. +Responses to signed-in requests get `Cache-Control: no-store`, so neither the browser cache nor a shared cache keeps a user's data. A route keeps its own policy when it sets `no-store`, or `private` together with `no-cache`. To answer repeat requests with `304`, use `private, no-cache` with Hono's [`etag()`](https://hono.dev/docs/middleware/builtin/etag) middleware. If you set the `ETag` yourself, make it differ per user, so the `304` doesn't reuse another account's page. The browser cache keeps a copy of a `private, no-cache` page, so the Back button can show it again after the user signs out. Keep the default `no-store` for pages that must not stay in the browser cache. `POST`, `PUT`, `PATCH` and `DELETE` requests and WebSocket connections from another origin don't use the signed-in session unless that origin is in `trustedOrigins`. If a separate frontend calls your app, add its origin there (see [Customization](#customization)). If your routes use their own cookies, check the request `Origin` yourself, for example with Hono's [`csrf()`](https://hono.dev/docs/middleware/builtin/csrf) middleware. diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index cff771c86..9ad55adc7 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -215,17 +215,19 @@ function appendAuthHeaders( return; } - // A route policy that already keeps the response out of shared caches is as safe - // as no-store and may allow browser revalidation (ETag + 304) of private data. - const routeKeepsPrivate = hasCacheDirective(c.res.headers.get('cache-control'), [ - 'private', - 'no-store', - ]); + // `no-store`, or `private` with `no-cache`, keeps the response out of shared + // caches and makes the browser ask before reusing it, which allows ETag + 304 + // (the ETag must differ per user). `private` alone lets the browser show the + // response to the next account that signs in. + const routeDirectives = bareCacheDirectives(c.res.headers.get('cache-control')); + const keepRoutePolicy = + routeDirectives.has('no-store') || + (routeDirectives.has('private') && routeDirectives.has('no-cache')); source.forEach((value, key) => { // getSession's response policy belongs to the session endpoint. An anonymous // lookup must not make the application's public page or asset uncacheable. if ( - (!includeCachePolicy || routeKeepsPrivate) && + (!includeCachePolicy || keepRoutePolicy) && ['cache-control', 'pragma'].includes(key.toLowerCase()) ) { return; @@ -234,19 +236,25 @@ function appendAuthHeaders( append: key.toLowerCase() === 'set-cookie', }); }); + const sessionDirectives = bareCacheDirectives(source.get('cache-control')); if ( source.has('set-cookie') && - !routeKeepsPrivate && - !hasCacheDirective(source.get('cache-control'), ['private', 'no-store', 'no-cache']) + !keepRoutePolicy && + !['private', 'no-store', 'no-cache'].some((directive) => sessionDirectives.has(directive)) ) { c.header('Cache-Control', 'private, no-store'); } } -function hasCacheDirective(policy: string | null, directives: Array): boolean { - return (policy ?? '') - .split(',') - .some((directive) => directives.includes(directive.trim().toLowerCase())); +// Lowercased Cache-Control directive names that carry no value. Commas inside a +// quoted value do not split directives, so `no-cache="a,private"` has no `private`. +function bareCacheDirectives(policy: string | null): Set { + const directives = (policy ?? '').match(/(?:[^",]|"(?:[^"\\]|\\.)*"?)+/g) ?? []; + return new Set( + directives + .map((directive) => directive.trim().toLowerCase()) + .filter((directive) => !directive.includes('=')), + ); } function resolveUserConfig( diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts index 37807fedb..db4de051b 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -1,6 +1,7 @@ import { handleIsr, type IsrCacheEnv } from '@void/isr/runtime'; import { memoryAdapter } from 'better-auth/adapters/memory'; import { Hono } from 'hono'; +import { etag } from 'hono/etag'; import { describe, expect, it } from 'vite-plus/test'; import { createVoidAuthRuntime } from '../../src/runtime/better-auth-shared.ts'; @@ -93,7 +94,7 @@ describe('real anonymous Better Auth lookup cache policy', () => { }); describe('real authenticated Better Auth session cache policy', () => { - async function signedInApp(routeHeaders: Record) { + async function signedInApp(routeHeaders: Record, { withEtag = false } = {}) { const database = memoryAdapter({ user: [], session: [], account: [], verification: [] }); const runtime = createVoidAuthRuntime('sqlite', async () => {}); const app = new Hono(); @@ -101,6 +102,9 @@ describe('real authenticated Better Auth session cache policy', () => { app.on(['GET', 'POST'], '/api/auth/*', (c) => runtime.handleVoidAuthRequest(c, options, database), ); + if (withEtag) { + app.use('/api/report', etag()); + } app.get('/api/report', (c) => c.text('report', 200, routeHeaders)); const signUp = await app.request( 'https://app.test/api/auth/sign-up/email', @@ -131,8 +135,8 @@ describe('real authenticated Better Auth session cache policy', () => { return { app, cookie }; } - it.each(['private, no-cache', 'private, max-age=60', 'no-store'])( - 'keeps a route policy that already stays out of shared caches: %s', + it.each(['private, no-cache', 'no-store', 'Private, No-Cache, max-age=60', 'private,\tno-cache'])( + 'keeps a no-store or private, no-cache route policy: %s', async (policy) => { const { app, cookie } = await signedInApp({ 'cache-control': policy, etag: 'W/"report-1"' }); const response = await app.request( @@ -147,17 +151,41 @@ describe('real authenticated Better Auth session cache policy', () => { }, ); - it.each([null, 'public, max-age=60', 'no-cache', 'private="set-cookie", max-age=60'])( - 'applies the session lookup no-store to a route policy of %s', - async (policy) => { - const { app, cookie } = await signedInApp(policy ? { 'cache-control': policy } : {}); - const response = await app.request( - 'https://app.test/api/report', - { headers: { cookie } }, - env, - ); - expect(response.headers.get('cache-control')).toBe('no-store'); - expect(response.headers.get('pragma')).toBe('no-cache'); - }, - ); + it.each([ + null, + 'public, max-age=60', + 'no-cache', + 'private="set-cookie", max-age=60', + 'private', + 'private, max-age=60', + 'private, no-cache="set-cookie"', + 'no-cache="x,private,y", public, max-age=600', + 'no-cache="x,no-store,y", public, max-age=600', + 'no-cache="a\\",no-store,b", public, max-age=600', + ])('applies the session lookup no-store to a route policy of %s', async (policy) => { + const { app, cookie } = await signedInApp(policy ? { 'cache-control': policy } : {}); + const response = await app.request('https://app.test/api/report', { headers: { cookie } }, env); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + }); + + it('answers a signed-in repeat request with 304 through etag()', async () => { + const { app, cookie } = await signedInApp( + { 'cache-control': 'private, no-cache' }, + { withEtag: true }, + ); + const first = await app.request('https://app.test/api/report', { headers: { cookie } }, env); + expect(first.status).toBe(200); + const tag = first.headers.get('etag'); + expect(tag).toBeTruthy(); + const repeat = await app.request( + 'https://app.test/api/report', + { headers: { cookie, 'if-none-match': tag! } }, + env, + ); + expect(repeat.status).toBe(304); + expect(repeat.headers.get('cache-control')).toBe('private, no-cache'); + expect(repeat.headers.has('pragma')).toBe(false); + expect(repeat.headers.get('etag')).toBe(tag); + }); }); diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 3255a3949..50ed51642 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -177,19 +177,37 @@ describe('runVoidAuthMiddleware response headers', () => { it.each([ ['without its own cache policy', new Headers()], ['with a no-store policy', new Headers({ 'cache-control': 'no-store', pragma: 'no-cache' })], - ])('keeps a private route policy through a cookie rotation %s', async (_label, authHeaders) => { - authHeaders.append('set-cookie', 'session=rotated; Path=/; HttpOnly'); - const app = createApp({ response: null, headers: authHeaders }); - app.get('/refresh', (c) => - c.text('content', 200, { 'cache-control': 'private, no-cache', etag: 'W/"v1"' }), - ); + ])( + 'keeps a private, no-cache route policy through a cookie rotation %s', + async (_label, authHeaders) => { + authHeaders.append('set-cookie', 'session=rotated; Path=/; HttpOnly'); + const app = createApp({ response: null, headers: authHeaders }); + app.get('/refresh', (c) => + c.text('content', 200, { 'cache-control': 'private, no-cache', etag: 'W/"v1"' }), + ); + const response = await app.request( + 'http://localhost/refresh', + { headers: { cookie: 'session=current' } }, + { BETTER_AUTH_SECRET: 'test-secret' }, + ); + expect(response.headers.get('cache-control')).toBe('private, no-cache'); + expect(response.headers.has('pragma')).toBe(false); + expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); + }, + ); + + it('does not let a cookie rotation keep a private route policy the browser may reuse', async () => { + const app = createApp({ + response: null, + headers: new Headers({ 'set-cookie': 'session=rotated; Path=/; HttpOnly' }), + }); + app.get('/refresh', (c) => c.text('content', 200, { 'cache-control': 'private, max-age=60' })); const response = await app.request( 'http://localhost/refresh', { headers: { cookie: 'session=current' } }, { BETTER_AUTH_SECRET: 'test-secret' }, ); - expect(response.headers.get('cache-control')).toBe('private, no-cache'); - expect(response.headers.has('pragma')).toBe(false); + expect(response.headers.get('cache-control')).toBe('private, no-store'); expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); }); });