@lenne.tech/nest-server 11.36.2 → 11.36.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.claude/rules/better-auth.md +54 -0
  2. package/.claude/rules/configurable-features.md +1 -1
  3. package/.claude/rules/testing.md +23 -3
  4. package/FRAMEWORK-API.md +1 -1
  5. package/dist/core/modules/better-auth/better-auth.types.d.ts +4 -0
  6. package/dist/core/modules/better-auth/better-auth.types.js +9 -0
  7. package/dist/core/modules/better-auth/better-auth.types.js.map +1 -1
  8. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.d.ts +1 -1
  9. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js +16 -6
  10. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js.map +1 -1
  11. package/dist/core/modules/better-auth/core-better-auth-token.helper.d.ts +1 -0
  12. package/dist/core/modules/better-auth/core-better-auth-token.helper.js +4 -0
  13. package/dist/core/modules/better-auth/core-better-auth-token.helper.js.map +1 -1
  14. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +1 -0
  15. package/dist/core/modules/better-auth/core-better-auth.controller.js +9 -4
  16. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  17. package/dist/core/modules/better-auth/core-better-auth.resolver.js +1 -1
  18. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth.service.js +2 -1
  20. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/docs/REQUEST-LIFECYCLE.md +8 -0
  23. package/migration-guides/11.36.2-to-11.36.3.md +131 -0
  24. package/package.json +1 -1
  25. package/src/core/modules/better-auth/CUSTOMIZATION.md +25 -0
  26. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +6 -0
  27. package/src/core/modules/better-auth/README.md +4 -4
  28. package/src/core/modules/better-auth/better-auth.types.ts +30 -0
  29. package/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +49 -7
  30. package/src/core/modules/better-auth/core-better-auth-token.helper.ts +16 -0
  31. package/src/core/modules/better-auth/core-better-auth.controller.ts +53 -7
  32. package/src/core/modules/better-auth/core-better-auth.resolver.ts +6 -1
  33. package/src/core/modules/better-auth/core-better-auth.service.ts +5 -2
@@ -1460,6 +1460,14 @@ All security features are configured in `config.env.ts` under the `security` key
1460
1460
  | JWT-only | `cookies: false` | Yes | No | Yes |
1461
1461
  | Hybrid | `cookies: { exposeTokenInBody: true }` | Yes | Yes | Yes |
1462
1462
 
1463
+ > **In hybrid mode the body token and the cookie are DIFFERENT values.** With the JWT plugin active,
1464
+ > the body carries a JWT while the cookie keeps the opaque Better-Auth session token — Better-Auth
1465
+ > resolves a session by that opaque value, so a JWT in the cookie authenticates nothing. Treating
1466
+ > them as one token is what caused 11.36.3, where sign-in succeeded and every following request was
1467
+ > anonymous. `setSessionCookies()` now refuses a JWT-shaped cookie value. Hybrid mode is confined to
1468
+ > development and CI: `assertCookiesProductionSafe()` forbids `exposeTokenInBody` in `production`
1469
+ > and `staging`.
1470
+
1463
1471
  ### Guardian Gates
1464
1472
 
1465
1473
  | Config Path | Type | Default | Description |
@@ -0,0 +1,131 @@
1
+ # Migration Guide: 11.36.2 → 11.36.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | None |
9
+ | **Bugfixes** | **The session cookie carried the wrong token in hybrid mode** — sign-in succeeded and every following request was anonymous (§1). Development and CI only |
10
+ | **Migration Effort** | **None for most projects.** `pnpm update`. Read §2 only if you OVERRIDE `processAuthResult()` or `processCookies()` |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.36.3
18
+ ```
19
+
20
+ **Vendor-mode projects:** the fix spans four files that form an ATOMIC set — syncing a subset
21
+ produces a TypeScript build failure, because the cookie helper and the controller both now import
22
+ `isJwtShaped` from the token helper, and the controller imports `hasToken` from the types module:
23
+
24
+ - `src/core/modules/better-auth/better-auth.types.ts`
25
+ - `src/core/modules/better-auth/core-better-auth-token.helper.ts`
26
+ - `src/core/modules/better-auth/core-better-auth-cookie.helper.ts`
27
+ - `src/core/modules/better-auth/core-better-auth.controller.ts`
28
+
29
+ ---
30
+
31
+ ## 1. Session cookie no longer carries the body's JWT
32
+
33
+ **Were you affected?** Only if all three of these are true:
34
+
35
+ 1. `cookies.exposeTokenInBody: true`, **and**
36
+ 2. the Better-Auth JWT plugin is enabled, **and**
37
+ 3. the environment is `local`, `development`, `ci` or `e2e`.
38
+
39
+ **Production and staging were never affected.** `assertCookiesProductionSafe()` throws at boot when
40
+ `exposeTokenInBody` is set in either, so no live deployment could reach this state. If you are
41
+ upgrading because you read "session cookie bug", this is a development and CI fix — no emergency.
42
+
43
+ **The symptom, if you had it:** sign-in returns `200` and sets a cookie, and the very next request is
44
+ anonymous. `GET /iam/session` answers `success: false` for a session that plainly exists in the
45
+ database. In a browser the user is signed out again the moment they navigate. Cookie-based E2E
46
+ suites fail in CI while passing locally — because in this framework's own `config.env.ts` only `ci`
47
+ and `e2e` set `exposeTokenInBody`, while `local`, `development` and `production` leave it unset. In
48
+ cookies-only mode both carriers hold the same value, so the defect is invisible there. Check your own
49
+ config rather than assuming these defaults.
50
+
51
+ **The cause.** With `exposeTokenInBody`, `resolveJwtToken()` converts the opaque session token into a
52
+ JWT for the response body — correct, that is what a bearer client wants. The cookie was then written
53
+ with that same JWT. Better-Auth resolves a session by the **opaque** token it stored, so a JWT there
54
+ matches nothing.
55
+
56
+ **What changed.** The cookie now receives the opaque session token while the body keeps the JWT.
57
+ Beyond the fix itself, `setSessionCookies()` now **refuses** a JWT-shaped value outright and logs an
58
+ error, so the same mistake can no longer be made silently by any future call site or by your own
59
+ override.
60
+
61
+ Nothing to configure. If you work around this today by disabling `exposeTokenInBody` in CI, you can
62
+ undo that.
63
+
64
+ ---
65
+
66
+ ## 2. If you override `processAuthResult()` or a sign-in handler (action required)
67
+
68
+ `BetterAuthCookieHelper.processAuthResult()` takes a fifth parameter, `sessionToken`, since 11.36.3:
69
+
70
+ ```typescript
71
+ processAuthResult(res, result, cookiesEnabled, exposeTokenInBody, sessionToken?)
72
+ ```
73
+
74
+ **TypeScript permits an override with fewer parameters.** A subclass still declaring the old
75
+ four-argument signature compiles cleanly and silently drops the session token. Check yours:
76
+
77
+ ```bash
78
+ grep -rn "processAuthResult\|processCookies" src/
79
+ ```
80
+
81
+ Since 11.36.3 that mistake is no longer silent — `setSessionCookies()` refuses the JWT and logs an
82
+ error, so you lose the cookie rather than getting one that authenticates nothing. Still worth fixing:
83
+ add the parameter and pass it through.
84
+
85
+ **Deriving the value yourself?** Use `this.sessionTokenForCookie(response)` rather than reading
86
+ `response.session?.token`. `session.token` is optional on the `hasSession()` type guard, and
87
+ `api.signInEmail()` returns the token at the **top level with no `session` object at all** — so the
88
+ obvious expression yields `undefined` in exactly the case that matters, and the cookie falls back to
89
+ the JWT.
90
+
91
+ ---
92
+
93
+ ## 3. Tooling, framework repo only
94
+
95
+ Not shipped behaviour; relevant if you maintain a fork of this repo's tooling.
96
+
97
+ `scripts/check-mutations.mjs` gained `--no-infra` (the mutations that need no MongoDB) and
98
+ `--since=<ref>` (mutations touching files changed since a ref). Both are for local work and print
99
+ what they did **not** check. `--since` is a heuristic — it does not follow transitive imports — and
100
+ is deliberately kept off the publish path.
101
+
102
+ Several defects in that script were fixed in the same pass, all of the same family: a non-zero exit
103
+ with no reported test failures is now **INCONCLUSIVE** rather than "evidence confirmed"; the parallel
104
+ path no longer disables the e2e starvation guards it itself triggers; the worktree-failure fallback
105
+ no longer runs every mutation concurrently against the real working tree; the race guard no longer
106
+ overwrites the concurrent edit it was written to detect; `--since` no longer accepts a value git
107
+ would read as an option (`--output=<path>` truncated arbitrary files); and an empty selection exits
108
+ `2` instead of `0`, so no wrapper can read "nothing ran" as "everything passed".
109
+
110
+ ---
111
+
112
+ ## Module Documentation
113
+
114
+ The better-auth module's own docs were updated in the same pass — read these if §2 applies to you:
115
+
116
+ | Document | What changed |
117
+ |----------|--------------|
118
+ | [`better-auth/CUSTOMIZATION.md`](../src/core/modules/better-auth/CUSTOMIZATION.md) | New section *"Session cookie vs. body token — do not conflate them"*: which carrier holds which value, why `sessionTokenForCookie()` is the right source in an override, and the fifth-parameter warning |
119
+ | [`better-auth/README.md`](../src/core/modules/better-auth/README.md) | The `cookies` row now states that the body token is a JWT when the JWT plugin is active, and that this is NOT the value the session cookie carries |
120
+ | [`better-auth/INTEGRATION-CHECKLIST.md`](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md) | §6 (the `ci` / `e2e` config block that turns `exposeTokenInBody` on) now points at the CUSTOMIZATION section above |
121
+ | [`docs/REQUEST-LIFECYCLE.md`](../docs/REQUEST-LIFECYCLE.md) | The auth-modes table gained a note on the hybrid row |
122
+
123
+ ---
124
+
125
+ ## Troubleshooting
126
+
127
+ | Symptom | Cause | Fix |
128
+ |---------|-------|-----|
129
+ | `Refusing to write a JWT into the session cookie` in the log | An override or custom handler passed the body token | §2 — pass the opaque session token, or use `sessionTokenForCookie()` |
130
+ | Cookie auth worked before the upgrade, now no cookie is set | Same as above, previously silent | §2 |
131
+ | `check:mutations` reports INCONCLUSIVE | The run failed without reporting failing tests — crash, timeout or starvation | Re-run that mutation alone; it is no longer counted as evidence |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.36.2",
3
+ "version": "11.36.3",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -181,6 +181,31 @@ export class IamController extends CoreBetterAuthController {
181
181
  }
182
182
  ```
183
183
 
184
+ ### Session cookie vs. body token — do not conflate them
185
+
186
+ If you override a sign-in / sign-up handler, or `processCookies()` itself, know that these two
187
+ carry **different values** whenever `cookies.exposeTokenInBody` is on together with the JWT plugin:
188
+
189
+ | Carrier | Value |
190
+ | ------------------------------------ | ---------------------------------------- |
191
+ | `Set-Cookie: <prefix>.session_token` | the **opaque** Better-Auth session token |
192
+ | response body `token` | a **JWT** |
193
+
194
+ Better-Auth resolves a session by the opaque token it stored, so a JWT in the cookie authenticates
195
+ nothing — sign-in succeeds and every following request is anonymous. That shipped once (11.36.3).
196
+
197
+ In an override, derive the cookie value with `this.sessionTokenForCookie(response)` rather than
198
+ reading `response.session?.token` yourself: `session.token` is optional on the `hasSession()` type
199
+ guard, and `api.signInEmail()` returns the token at the top level with no `session` object at all,
200
+ so the obvious expression yields `undefined` in exactly the case that matters.
201
+
202
+ You do not have to get this right for it to be safe: `setSessionCookies()` refuses a JWT-shaped
203
+ value and logs an error, so a mistake here costs you the cookie, not the session.
204
+
205
+ Overriding `processAuthResult()` on `BetterAuthCookieHelper`? Keep the fifth parameter
206
+ (`sessionToken`). TypeScript accepts an override with fewer parameters, so dropping it compiles
207
+ cleanly and silently disables the distinction above.
208
+
184
209
  ### Important Notes
185
210
 
186
211
  - Always extend `CoreBetterAuthController`
@@ -179,6 +179,12 @@ export class ServerModule {}
179
179
  > **Never set `exposeTokenInBody: true` in production** — the framework throws at
180
180
  > startup if this is detected in `production` or `staging` environments (XSS-risk guard).
181
181
  >
182
+ > **In this hybrid mode the body token and the session cookie carry DIFFERENT values** whenever
183
+ > the JWT plugin is also active: the body gets a JWT, the cookie keeps the opaque Better-Auth
184
+ > session token. If you override a sign-in handler or `processCookies()`, read
185
+ > [CUSTOMIZATION.md → _Session cookie vs. body token_](./CUSTOMIZATION.md#session-cookie-vs-body-token--do-not-conflate-them)
186
+ > before you derive the cookie value yourself — conflating the two is what caused 11.36.3.
187
+ >
182
188
  > To keep the old behavior (cookies off, tokens in body everywhere), set `cookies: false`.
183
189
 
184
190
  #### Zero-Config (Default):
@@ -262,10 +262,10 @@ Read the security section below for production deployments.
262
262
 
263
263
  **Global server-level settings that affect BetterAuth behavior (since v11.25.0):**
264
264
 
265
- | Setting (top-level `IServerOptions`) | Technical Purpose | Impact of Wrong Value |
266
- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
267
- | `cookies` (`boolean \| ICookiesConfig`, default: `true`) | Controls cookie-parser middleware and session cookie setting. `cookies.exposeTokenInBody` additionally returns the token in the response body (test-only; **forbidden in production**) | Tokens missing from response body surprises test clients; `exposeTokenInBody` in prod = XSS-risk, framework throws at startup |
268
- | `cors` (`boolean \| ICorsConfig`, default: enabled with auto-derived origins) | Unified CORS config — propagates to GraphQL (Apollo), REST (Express), and BetterAuth `trustedOrigins` from a single source | `cors.enabled: false` disables the REST/GraphQL layers; `cors.allowAll` mirrors any request origin for REST/GraphQL (dev only) but BetterAuth keeps restricting to `appUrl` (its origin check has no "allow all" mode) |
265
+ | Setting (top-level `IServerOptions`) | Technical Purpose | Impact of Wrong Value |
266
+ | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
267
+ | `cookies` (`boolean \| ICookiesConfig`, default: `true`) | Controls cookie-parser middleware and session cookie setting. `cookies.exposeTokenInBody` additionally returns a token in the response body — a JWT when the JWT plugin is active, which is NOT the value the session cookie carries (see CUSTOMIZATION.md) (test-only; **forbidden in production**) | Tokens missing from response body surprises test clients; `exposeTokenInBody` in prod = XSS-risk, framework throws at startup |
268
+ | `cors` (`boolean \| ICorsConfig`, default: enabled with auto-derived origins) | Unified CORS config — propagates to GraphQL (Apollo), REST (Express), and BetterAuth `trustedOrigins` from a single source | `cors.enabled: false` disables the REST/GraphQL layers; `cors.allowAll` mirrors any request origin for REST/GraphQL (dev only) but BetterAuth keeps restricting to `appUrl` (its origin check has no "allow all" mode) |
269
269
 
270
270
  **For Development:** The defaults (`http://localhost:3000`, `/iam`) are correct.
271
271
 
@@ -119,6 +119,36 @@ export function hasSession<T>(
119
119
  );
120
120
  }
121
121
 
122
+ /**
123
+ * Whether a Better-Auth response carries a usable top-level `token`.
124
+ *
125
+ * Sibling of {@link hasSession} / {@link hasUser}, and the reason it exists: `api.signInEmail()`
126
+ * and `api.signUpEmail()` return the session token at the TOP level and no `session` object at all,
127
+ * so `response.session?.token` is `undefined` on exactly the routes that matter most.
128
+ */
129
+ export function hasToken<T>(response: T): response is T & { token: string } {
130
+ return typeof (response as { token?: unknown })?.token === 'string' && (response as { token: string }).token !== '';
131
+ }
132
+
133
+ /**
134
+ * The OPAQUE Better-Auth session token carried by an outgoing auth response, if any.
135
+ *
136
+ * A PRECEDENCE CHAIN, not a ternary on {@link hasSession}, and the difference is load-bearing:
137
+ * `hasSession()` narrows to `session: { …; token?: string }`, so `token` is OPTIONAL on the guard
138
+ * itself. A response carrying a `session` object WITHOUT a token inside it satisfies the guard,
139
+ * yields `undefined` and skips the top-level fallback. `api.signInEmail()` is the mirror case:
140
+ * top-level token, no `session` object at all.
141
+ *
142
+ * Neither shape is reachable in better-auth 1.6.26 today; both are one minor bump away, and the
143
+ * failure is silent — which is why every caller that needs the stored session value shares this
144
+ * one chain rather than re-deriving it. Callers that need the value for a COOKIE must additionally
145
+ * refuse a JWT; see `CoreBetterAuthController.sessionTokenForCookie()`.
146
+ */
147
+ export function sessionTokenFromResponse(response: unknown): string | undefined {
148
+ const fromSession = hasSession(response) && hasToken(response.session) ? response.session.token : undefined;
149
+ return fromSession ?? (hasToken(response) ? response.token : undefined);
150
+ }
151
+
122
152
  /**
123
153
  * Type guard to check if response has user
124
154
  * Preserves the original type while asserting user is defined
@@ -3,8 +3,20 @@ import { Response } from 'express';
3
3
 
4
4
  import { isProductionLikeEnv } from '../../common/helpers/cookies.helper';
5
5
  import { resolveBetterAuthSessionCookieName } from './better-auth-cookie-prefix.helper';
6
+ import { isJwtShaped } from './core-better-auth-token.helper';
6
7
  import { signCookieValue } from './core-better-auth-web.helper';
7
8
 
9
+ /**
10
+ * Last-resort logger for the JWT refusal below.
11
+ *
12
+ * `BetterAuthCookieHelperConfig.logger` is optional and `createCookieHelper()` takes it as an
13
+ * optional third argument, so a consumer constructing this class directly gets none. The refusal
14
+ * is the one message here that must never be silent: without it the symptom is "no cookie, HTTP
15
+ * 200, no explanation" — the same diagnostic blind spot that let the defect it guards against ship
16
+ * in the first place.
17
+ */
18
+ const fallbackLogger = new Logger('BetterAuthCookieHelper');
19
+
8
20
  /**
9
21
  * Standard cookie names used by Better-Auth and nest-server.
10
22
  *
@@ -193,6 +205,24 @@ export class BetterAuthCookieHelper {
193
205
  * @param _sessionId - Deprecated, kept for API compatibility but no longer used
194
206
  */
195
207
  setSessionCookies(res: Response, sessionToken: string, _sessionId?: string): void {
208
+ // Structural invariant, independent of whichever call site got here: Better-Auth resolves a
209
+ // session by the OPAQUE token it stored, so a JWT in this cookie authenticates nothing. That is
210
+ // not hypothetical — it shipped: with `cookies.exposeTokenInBody` the body's JWT was written
211
+ // here, sign-in appeared to work and every following request was anonymous.
212
+ //
213
+ // Enforced here rather than at the call sites because a call site can forget, and this one
214
+ // cannot: whoever adds the next authentication route inherits the guard for free.
215
+ if (isJwtShaped(sessionToken)) {
216
+ // NOT `this.config.logger?.error(...)`: `logger` is optional, and a silent refusal leaves the
217
+ // caller with no cookie, a 200 response and nothing to go on.
218
+ (this.config.logger ?? fallbackLogger).error(
219
+ 'Refusing to write a JWT into the session cookie — Better-Auth resolves a session by the ' +
220
+ 'opaque token it stored, so this would authenticate nothing. Pass the session token, ' +
221
+ 'not the value destined for the response body.',
222
+ );
223
+ return;
224
+ }
225
+
196
226
  const cookieOptions = this.getDefaultCookieOptions();
197
227
 
198
228
  // Sign the session token for Better-Auth
@@ -323,6 +353,13 @@ export class BetterAuthCookieHelper {
323
353
  * @param result - The result object to process (modified in place)
324
354
  * @param cookiesEnabled - Whether cookie handling is enabled (from config)
325
355
  * @param exposeTokenInBody - Whether to keep the token in the response body (default: false)
356
+ * @param sessionToken - The OPAQUE Better-Auth session token, for the COOKIE. Pass it whenever
357
+ * the body's `token` may already be a JWT — i.e. `exposeTokenInBody` together with the JWT
358
+ * plugin. Better-Auth resolves a session by the opaque value it stored, so a JWT in the cookie
359
+ * authenticates nothing: sign-in appears to succeed and every following request is anonymous.
360
+ * Falls back to `result.token`, which is correct only in cookies-only mode, where the two
361
+ * values are the same thing. `setSessionCookies()` refuses a JWT-shaped value regardless, so
362
+ * omitting this fails loudly rather than silently.
326
363
  * @returns The modified result (same reference as input)
327
364
  */
328
365
  processAuthResult<T extends CookieProcessingResult>(
@@ -330,19 +367,24 @@ export class BetterAuthCookieHelper {
330
367
  result: T,
331
368
  cookiesEnabled: boolean,
332
369
  exposeTokenInBody: boolean = false,
370
+ sessionToken?: string,
333
371
  ): T {
334
372
  if (!cookiesEnabled) {
335
373
  return result;
336
374
  }
337
375
 
338
- if (result.token) {
339
- // Set cookies
340
- this.setSessionCookies(res, result.token);
376
+ // The cookie and the body carry DIFFERENT values whenever both delivery modes are on at once —
377
+ // see the `sessionToken` note on the signature above. `result.token` is the fallback for
378
+ // cookies-only mode, where the two are the same thing anyway.
379
+ const cookieToken = sessionToken ?? result.token;
341
380
 
342
- // Remove token from response body unless exposeTokenInBody is enabled
343
- if (!exposeTokenInBody) {
344
- delete result.token;
345
- }
381
+ if (cookieToken) {
382
+ this.setSessionCookies(res, cookieToken);
383
+ }
384
+
385
+ // Remove token from response body unless exposeTokenInBody is enabled
386
+ if (result.token && !exposeTokenInBody) {
387
+ delete result.token;
346
388
  }
347
389
 
348
390
  return result;
@@ -156,6 +156,22 @@ export function isJwt(token: string): boolean {
156
156
  return result.type === TokenType.LEGACY_JWT || result.type === TokenType.BETTER_AUTH_JWT;
157
157
  }
158
158
 
159
+ /**
160
+ * Whether a value has the SHAPE of a JWT — three dot-separated segments starting with `eyJ`.
161
+ *
162
+ * Deliberately structural and deliberately not `startsWith('eyJ')` alone: a Better-Auth session
163
+ * token is `generateId(32)` over `a-zA-Z0-9-_`, so roughly one in 262 144 of them begins with those
164
+ * three characters by chance. That is rare enough to reach production as a ghost and frequent
165
+ * enough to happen. Requiring the dots removes the false positive entirely, because an opaque
166
+ * session token can never contain one.
167
+ *
168
+ * Unlike {@link analyzeToken} this makes no claim about the token's CONTENTS or validity — it
169
+ * answers "could this be a JWT?", which is the question a guard on a session cookie needs.
170
+ */
171
+ export function isJwtShaped(value: unknown): value is string {
172
+ return typeof value === 'string' && value.startsWith('eyJ') && value.split('.').length === 3;
173
+ }
174
+
159
175
  /**
160
176
  * Checks if a token is a Legacy JWT (Passport/JWT strategy).
161
177
  *
@@ -34,11 +34,17 @@ import type { ICookiesConfig } from '../../common/interfaces/server-options.inte
34
34
  import { maskEmail, maskToken } from '../../common/helpers/logging.helper';
35
35
  import { ConfigService } from '../../common/services/config.service';
36
36
  import { ErrorCode } from '../error-code/error-codes';
37
- import { BetterAuthSignInResponse, hasSession, hasUser, requires2FA } from './better-auth.types';
37
+ import {
38
+ BetterAuthSignInResponse,
39
+ hasSession,
40
+ hasUser,
41
+ requires2FA,
42
+ sessionTokenFromResponse,
43
+ } from './better-auth.types';
38
44
  import { BetterAuthCookieHelper, createCookieHelper } from './core-better-auth-cookie.helper';
39
45
  import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service';
40
46
  import { CoreBetterAuthSignUpValidatorService } from './core-better-auth-signup-validator.service';
41
- import { isSessionToken } from './core-better-auth-token.helper';
47
+ import { isJwtShaped, isSessionToken } from './core-better-auth-token.helper';
42
48
  import { BetterAuthSessionUser, CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
43
49
  import { convertExpressHeaders, sendWebResponse, toWebRequest } from './core-better-auth-web.helper';
44
50
  import { CoreBetterAuthService } from './core-better-auth.service';
@@ -469,8 +475,15 @@ export class CoreBetterAuthController {
469
475
  // Get token: JWT accessToken > top-level token > session.token
470
476
  const rawToken =
471
477
  responseAny.accessToken || responseAny.token || (hasSession(response) ? response.session.token : undefined);
478
+ // `rawToken` prefers the top-level token; `sessionTokenForCookie()` prefers `session.token`.
479
+ // The inversion is deliberate: the body wants whatever Better-Auth called the token, the
480
+ // cookie wants the stored session value specifically.
472
481
  const token = await this.resolveJwtToken(rawToken);
473
482
 
483
+ // Kept separately from `token`: `resolveJwtToken` turns the session token into a JWT when
484
+ // the body is meant to carry one, and the cookie must not get that JWT.
485
+ const sessionToken = this.sessionTokenForCookie(response);
486
+
474
487
  const result: CoreBetterAuthResponse = {
475
488
  requiresTwoFactor: false,
476
489
  session: hasSession(response) ? this.mapSession(response.session) : undefined,
@@ -479,7 +492,7 @@ export class CoreBetterAuthController {
479
492
  user: mappedUser ? this.mapUser(response.user, mappedUser) : undefined,
480
493
  };
481
494
 
482
- return this.processCookies(res, result);
495
+ return this.processCookies(res, result, sessionToken);
483
496
  }
484
497
 
485
498
  throw new UnauthorizedException(ErrorCode.INVALID_CREDENTIALS);
@@ -583,7 +596,11 @@ export class CoreBetterAuthController {
583
596
  // The user must verify their email before they can use any session
584
597
  if (this.emailVerificationService?.isEnabled()) {
585
598
  // Revoke the Better-Auth session server-side so the token is invalidated
586
- const sessionToken = hasSession(response) ? response.session.token : undefined;
599
+ // Shared chain, NOT `hasSession(response) ? response.session.token : undefined`: `token` is
600
+ // optional on the guard, so that expression returns `undefined` for a response shape that
601
+ // still carries a revocable token — and the endpoint would then report a revoked session
602
+ // while a live one remained in the database.
603
+ const sessionToken = sessionTokenFromResponse(response);
587
604
  if (sessionToken) {
588
605
  await this.betterAuthService.revokeSession(sessionToken);
589
606
  }
@@ -607,7 +624,9 @@ export class CoreBetterAuthController {
607
624
  user: mappedUser ? this.mapUser(response.user, mappedUser) : undefined,
608
625
  };
609
626
 
610
- return this.processCookies(res, result);
627
+ // Same as sign-in: the cookie gets the opaque session token, the body keeps whatever
628
+ // `resolveJwtToken` produced.
629
+ return this.processCookies(res, result, this.sessionTokenForCookie(response));
611
630
  }
612
631
 
613
632
  throw new BadRequestException(ErrorCode.SIGNUP_FAILED);
@@ -867,6 +886,27 @@ export class CoreBetterAuthController {
867
886
  };
868
887
  }
869
888
 
889
+ /**
890
+ * The value that belongs in the SESSION COOKIE — never the value destined for the response body.
891
+ *
892
+ * Not to be confused with {@link extractSessionToken}, which reads a token out of an INCOMING
893
+ * request. This one derives it from an OUTGOING Better-Auth response; the two run in opposite
894
+ * directions, which is why they do not share a name.
895
+ *
896
+ * The precedence chain itself lives in {@link sessionTokenFromResponse}, shared with the
897
+ * session-revocation paths so the two cannot drift. What this method adds is the COOKIE-specific
898
+ * half: a JWT-shaped result is refused outright, because Better-Auth resolves a session by the
899
+ * opaque value it stored and a JWT in the cookie would authenticate nothing.
900
+ *
901
+ * Override to source the cookie token differently. `setSessionCookies()` refuses a JWT-shaped
902
+ * value regardless, so an override cannot reintroduce the defect either.
903
+ */
904
+ protected sessionTokenForCookie(response: unknown): string | undefined {
905
+ const resolved = sessionTokenFromResponse(response);
906
+ // A JWT here means somebody handed over the body's value; refuse rather than pass it on.
907
+ return isJwtShaped(resolved) ? undefined : resolved;
908
+ }
909
+
870
910
  /**
871
911
  * Process cookies for response
872
912
  *
@@ -903,8 +943,14 @@ export class CoreBetterAuthController {
903
943
  return result;
904
944
  }
905
945
 
906
- // Otherwise, use the cookie helper's standard processing
907
- return this.cookieHelper.processAuthResult(res, result, cookiesEnabled, exposeTokenInBody);
946
+ // Otherwise the helper's standard processing. Read this carefully before deleting anything:
947
+ // a TRUTHY `sessionToken` reaches here only when cookies are disabled, in which case
948
+ // `processAuthResult` returns immediately anyway. But `sessionTokenForCookie()` legitimately
949
+ // returns `undefined` — no token in the response, or a JWT it refused — and THAT case falls
950
+ // through with cookies ENABLED, where `cookieToken` falls back to `result.token`, i.e. the
951
+ // body's JWT. What makes that safe is not unreachability; it is `setSessionCookies()` refusing
952
+ // a JWT-shaped value. Remove the refusal and this line reinstates the defect.
953
+ return this.cookieHelper.processAuthResult(res, result, cookiesEnabled, exposeTokenInBody, sessionToken);
908
954
  }
909
955
 
910
956
  /**
@@ -13,6 +13,7 @@ import {
13
13
  hasSession,
14
14
  hasUser,
15
15
  requires2FA,
16
+ sessionTokenFromResponse,
16
17
  } from './better-auth.types';
17
18
  import { CoreBetterAuthAuthModel } from './core-better-auth-auth.model';
18
19
  import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service';
@@ -490,7 +491,11 @@ export class CoreBetterAuthResolver {
490
491
  // If email verification is enabled, revoke the session and don't return session data
491
492
  // The user must verify their email before they can use any session
492
493
  if (this.emailVerificationService?.isEnabled()) {
493
- const sessionToken = hasSession(response) ? response.session.token : undefined;
494
+ // Shared chain, NOT `hasSession(response) ? response.session.token : undefined`: `token` is
495
+ // optional on the guard, so that expression returns `undefined` for a response shape that
496
+ // still carries a revocable token — and the endpoint would then report a revoked session
497
+ // while a live one remained in the database.
498
+ const sessionToken = sessionTokenFromResponse(response);
494
499
  if (sessionToken) {
495
500
  await this.betterAuthService.revokeSession(sessionToken);
496
501
  }
@@ -11,6 +11,7 @@ import { ConfigService } from '../../common/services/config.service';
11
11
  import { ErrorCode } from '../error-code/error-codes';
12
12
  import { resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
13
13
  import { BetterAuthInstance } from './better-auth.config';
14
+ import { isJwtShaped } from './core-better-auth-token.helper';
14
15
  import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
15
16
  import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
16
17
  import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants';
@@ -359,8 +360,10 @@ export class CoreBetterAuthService implements OnModuleInit {
359
360
  return token;
360
361
  }
361
362
 
362
- // Already a JWT (three base64url segments separated by dots)
363
- if (token.startsWith('eyJ') && token.split('.').length === 3) {
363
+ // Already a JWT (three base64url segments separated by dots). Shares ONE definition with the
364
+ // session-cookie guard two copies of this predicate would let the conversion path and the
365
+ // cookie path disagree about the same token, silently.
366
+ if (isJwtShaped(token)) {
364
367
  return token;
365
368
  }
366
369