Skip to content

Commit 3c0f3ea

Browse files
os-litantclaude
andauthored
disclose what the session.cookieCache door costs, measured against better-auth 1.7.1 (#12735)
* disclose what the cookieCache door costs, measured against better-auth 1.7.1 Extends the #4785 opt-in-with-disclosed-cost posture to `session.cookieCache`, the sibling key of `secondaryStorage`. No boot refusal, no config rejection, no narrowing of what `AuthManagerOptions` accepts — the ruled posture is disclosure, and the disclosure is now written where a future author would plumb the key. Adds an end-of-chain observation pin: a host-supplied `session.cookieCache` is dropped rather than honoured today, so revocation still de-authenticates on the next request. Co-authored-by: Claude <noreply@anthropic.com> * add the changeset: the CHANGELOG is where the sibling door's posture lives An operator weighing `cookieCache` reads the changelog, not our TSDoc. One door's cost being on the public record while its sibling's is not is the asymmetry this closes. Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e3e667 commit 3c0f3ea

4 files changed

Lines changed: 169 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
docs(plugin-auth): state what the `session.cookieCache` door costs, measured against better-auth 1.7.1 (#12547)
6+
7+
No behaviour change, no API change, and ⛔ **no boot refusal** — the #4785
8+
posture stands on both doors: deliberate opt-in with the cost disclosed
9+
(maintainer ruling 2026-08-27). `cacheSecondaryStorage()` stays exported and
10+
`AuthManagerOptions` accepts exactly what it accepted before.
11+
12+
This is shipped as a changeset rather than left in source comments because the
13+
CHANGELOG is where the sibling door's posture already lives — *"remains
14+
exported for anyone who wants better-auth's cached session store deliberately.
15+
It now says plainly what it costs."* An operator weighing `cookieCache` reads
16+
the changelog, not our TSDoc, and one door's cost being on the public record
17+
while its sibling's is not is the asymmetry this closes.
18+
19+
**What was measured**, against the installed better-auth `1.7.1` (read out of
20+
`node_modules`, never off the `^1.7.1` range):
21+
22+
- **The failure direction is the sibling's.** With `cookieCache` enabled,
23+
`/get-session` answers from a signed payload in the client's own
24+
`session_data` cookie and returns before any adapter read. ObjectStack
25+
revokes by writing the `sys_session` row (ADR-0069 D4) and hides tombstoned
26+
rows from better-auth's reads — all read-path enforcement, so while the
27+
cookie answers, a revoked session keeps authenticating and nobody gets an
28+
error.
29+
-**The reach is materially smaller, and the disclosure says so rather than
30+
inheriting the sibling's wording.** The session of record does not move —
31+
`createSession` still writes the row, so admin session lists, the
32+
concurrent-cap count and D4's audit trail stay correct. The staleness window
33+
is bounded and per-client (`cookieCache.maxAge`, default 300s) and cannot be
34+
extended without a database read, because better-auth force-disables its
35+
stateless `refreshCache` whenever a `database` is configured — which
36+
ObjectStack always does. Sensitive operations already bypass it via
37+
better-auth's own authoritative re-read. `secondaryStorage` has none of these
38+
three bounds.
39+
- **It is not reachable from ObjectStack config today, by construction rather
40+
than by refusal.** The spec's `AuthConfigSchema.session` declares
41+
`expiresIn` / `updateAge` only and `createAuthInstance` reads only those two,
42+
so a `cookieCache` key is dropped rather than honoured. The one way in is the
43+
`authInstance` escape hatch, where the host has replaced the whole config.
44+
45+
The disclosure lands at `auth-manager.ts`'s `session:` block — the place a
46+
future author would plumb the key — with a pointer from `secondary-storage.ts`
47+
so the two doors are described together. An **observation** pin in
48+
`session-of-record.test.ts` records the drop end-of-chain: a revoked session
49+
still de-authenticates on the very next request. ⛔ It pins no refusal; it is
50+
the tripwire a paragraph alone could not give, so the day someone plumbs
51+
`cookieCache` through, a red test points at the cost note instead of D4
52+
quietly acquiring a revocation window nobody chose.

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,62 @@ export class AuthManager {
13791379
} : {}),
13801380

13811381
// Session configuration
1382+
//
1383+
// ⚠️ `session.cookieCache` is the OTHER door into the architecture #4785
1384+
// rejected, and it is deliberately not opened here. What that costs, and
1385+
// what is actually known about it, measured 2026-08-27 against the
1386+
// installed better-auth `1.7.1` (read out of `node_modules`, never off
1387+
// the `^1.7.1` range) — each claim names the file it came from so a
1388+
// version bump is re-checkable one grep at a time:
1389+
//
1390+
// - **The failure DIRECTION is the sibling's.** With `cookieCache`
1391+
// enabled, `/get-session` answers from a signed payload in the
1392+
// client's own `session_data` cookie and returns before any adapter
1393+
// read (`dist/api/routes/session.mjs:48-130`). ObjectStack revokes by
1394+
// writing the `sys_session` row — `enforceSessionControls` /
1395+
// `enforceConcurrentCap` stamp `revoked_at` + a past `expires_at`
1396+
// (ADR-0069 D4), and `hideRevokedSessionRow` hides a tombstoned row
1397+
// from better-auth's reads (`session-tombstone.ts`). All of it is
1398+
// read-path enforcement, so for as long as the cookie answers, a
1399+
// revoked session keeps authenticating and nobody gets an error. Same
1400+
// silent direction as `secondaryStorage` (see `secondary-storage.ts`).
1401+
//
1402+
// - ⭐ **The REACH is materially smaller, and saying so is the point.**
1403+
// Three differences, all measured, not inferred:
1404+
// 1. The session of RECORD does not move. `cookieCache` is read-side
1405+
// only — `createSession` still writes the `sys_session` row, so
1406+
// admin session lists, the concurrent-cap count and D4's audit
1407+
// trail all stay correct. `secondaryStorage` skips the row.
1408+
// 2. The staleness window is BOUNDED and per-client:
1409+
// `cookieCache.maxAge`, default 300s (`dist/cookies/index.mjs:50`,
1410+
// `:99`). It cannot be extended without a database read either —
1411+
// better-auth force-disables the stateless `refreshCache` whenever
1412+
// a `database` is configured, which ObjectStack always does
1413+
// (`dist/context/create-context.mjs:149-165`). Under
1414+
// `secondaryStorage` the cache IS the record and the window has no
1415+
// bound at all.
1416+
// 3. Sensitive operations already bypass it: better-auth's own
1417+
// `getAuthoritativeSessionFromCtx` / `sensitiveSessionMiddleware`
1418+
// re-read with `disableCookieCache: true` when a `database` is
1419+
// configured (`dist/api/routes/session.mjs:270-280`). There is no
1420+
// equivalent escape from `secondaryStorage`.
1421+
//
1422+
// - **It is not reachable from ObjectStack config today, by
1423+
// construction rather than by refusal.** The spec's
1424+
// `AuthConfigSchema.session` declares `expiresIn` / `updateAge` only,
1425+
// and this block reads only those two, so a `cookieCache` key on
1426+
// `AuthManagerOptions.session` is DROPPED, not honoured — pinned
1427+
// end-of-chain in `session-of-record.test.ts`. The one way in is
1428+
// `authInstance`, where the host has replaced this whole config.
1429+
//
1430+
// ⛔ So this is a disclosed cost on a door a host must build to reach —
1431+
// NOT a guard, and deliberately not one. The #4785 posture is opt-in with
1432+
// the cost stated (maintainer ruling 2026-08-27), the same posture
1433+
// `cacheSecondaryStorage()` is exported under. Plumbing `cookieCache`
1434+
// through is therefore a decision that re-opens #4785, not a feature: it
1435+
// would trade D4's revocation latency for request latency, and the trade
1436+
// has to be made deliberately, by a maintainer, with the window written
1437+
// down. If you are here to add it, that ruling is what you need first.
13821438
session: {
13831439
...AUTH_SESSION_CONFIG,
13841440
expiresIn: this.config.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days default

packages/plugins/plugin-auth/src/secondary-storage.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ type SecondaryStorage = NonNullable<BetterAuthOptions['secondaryStorage']>;
3636
* cache at all (and rewrite D4's revocation to match) is #4785 — a decision,
3737
* not a bug fix.
3838
*
39+
* ⚠️ **`session.cookieCache` is the sibling key, and its cost is NOT the same
40+
* size.** It reaches the same read-path failure direction — a revoked session
41+
* keeps authenticating, silently — but the session of record stays in
42+
* `sys_session`, the window is bounded by `cookieCache.maxAge` (default 300s)
43+
* rather than unbounded, and better-auth's own sensitive-operation path
44+
* re-reads with the cookie cache disabled. It is also not reachable through
45+
* `AuthManagerOptions`. The measurement, with the better-auth files each claim
46+
* was read out of, is at `auth-manager.ts`'s `session:` block — the place a
47+
* future author would plumb it. ⛔ Neither door is boot-refused by
48+
* ObjectStack, and that is the ruled posture, not a gap: opt-in with the cost
49+
* stated. Adding a refusal to either needs a new maintainer ruling.
50+
*
3951
* better-auth's `secondaryStorage` contract is string-valued: `get` returns the
4052
* stored string (or null), `set` takes a string value + optional TTL (seconds),
4153
* `delete` removes it. We map straight onto `ICacheService`, translating

packages/plugins/plugin-auth/src/session-of-record.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,55 @@ describe('#4785 — why cache is NOT the session store (the rejected architectur
476476
expect(sessionRows(engine)).toHaveLength(0);
477477
});
478478

479+
it('session.cookieCache is not reachable through config either — asking for it changes nothing', async () => {
480+
// The OTHER door into the same read-path failure, pinned the same way as
481+
// option C above and for the same reason: `AuthManager` builds better-auth's
482+
// `session` block from `AUTH_SESSION_CONFIG` plus `expiresIn`/`updateAge`
483+
// only, so a host that asks for `cookieCache` is silently NOT getting it.
484+
//
485+
// ⛔ This is an OBSERVATION pin, not a refusal — nothing here rejects the
486+
// key, and nothing should: the ruled posture (maintainer, 2026-08-27) is
487+
// opt-in with the cost disclosed on BOTH doors, exactly as
488+
// `cacheSecondaryStorage()` stays exported. What this pin buys is the
489+
// tripwire the disclosure alone could not give: the day someone plumbs
490+
// `cookieCache` through, this test goes red and points them at the cost
491+
// note in `auth-manager.ts` instead of letting D4 quietly acquire a
492+
// revocation window nobody chose.
493+
//
494+
// End-of-chain, per this file's header — asserting "the emitted options
495+
// carry no cookieCache" would be the middle of the chain and would pass
496+
// against a build that reached the cache some other way. If the key were
497+
// honoured, sign-up would mint a `session_data` cookie (which `cookieFrom`
498+
// collects, so it really would be replayed below), `/get-session` would
499+
// answer from that payload without an adapter read for up to
500+
// `maxAge` (default 300s), and the revoked cookie would still
501+
// authenticate. It must not.
502+
const engine = createMemoryEngine();
503+
const manager = makeManager(engine, {
504+
sessionIdleTimeoutMinutes: 30,
505+
session: { cookieCache: { enabled: true, maxAge: 300 } },
506+
});
507+
508+
const cookie = cookieFrom(await signUp(manager, 'cookiecache@example.com'));
509+
const id = sessionRows(engine)[0]!.id;
510+
511+
// The row is still the session of record: unlike `secondaryStorage`,
512+
// `cookieCache` never relocates it even when it IS honoured.
513+
expect(sessionRows(engine)).toHaveLength(1);
514+
expect(await isAuthenticated(manager, cookie)).toBe(true);
515+
516+
ageSession(engine, id, { last_activity_at: new Date(Date.now() - 90 * MINUTE) });
517+
518+
// Same one-request lag as every other D4 control: the request that detects
519+
// the timeout is still authenticated, the next one is not.
520+
expect(await isAuthenticated(manager, cookie)).toBe(true);
521+
expect(sessionRows(engine).find((r) => r.id === id)!.revoke_reason).toBe('idle_timeout');
522+
523+
// The assertion that matters, and the one a honoured `cookieCache` breaks:
524+
// the cookie is dead on the very next request, not 300 seconds later.
525+
expect(await isAuthenticated(manager, cookie)).toBe(false);
526+
});
527+
479528
it('the DEFAULT composition refuses to boot with a secondaryStorage rather than degrading quietly', async () => {
480529
// The safety net under all of the above, and the reason this hole could
481530
// never have opened silently in a standard `serve`: the OIDC provider

0 commit comments

Comments
 (0)