@nestjs-kit/better-auth 0.0.0-stage → 2.1.0

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 (142) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +1078 -2
  3. package/dist/admin.cjs +88 -0
  4. package/dist/admin.cjs.map +1 -0
  5. package/dist/admin.d.cts +14 -0
  6. package/dist/admin.d.cts.map +1 -0
  7. package/dist/admin.d.mts +14 -0
  8. package/dist/admin.d.mts.map +1 -0
  9. package/dist/admin.mjs +85 -0
  10. package/dist/admin.mjs.map +1 -0
  11. package/dist/api-key.cjs +164 -0
  12. package/dist/api-key.cjs.map +1 -0
  13. package/dist/api-key.d.cts +42 -0
  14. package/dist/api-key.d.cts.map +1 -0
  15. package/dist/api-key.d.mts +42 -0
  16. package/dist/api-key.d.mts.map +1 -0
  17. package/dist/api-key.mjs +160 -0
  18. package/dist/api-key.mjs.map +1 -0
  19. package/dist/auth-contracts-Brh_Qko9.d.cts +1654 -0
  20. package/dist/auth-contracts-Brh_Qko9.d.cts.map +1 -0
  21. package/dist/auth-contracts-Cz7C4_Ch.d.mts +1654 -0
  22. package/dist/auth-contracts-Cz7C4_Ch.d.mts.map +1 -0
  23. package/dist/auth-decorators-B1mFnGJ6.mjs +284 -0
  24. package/dist/auth-decorators-B1mFnGJ6.mjs.map +1 -0
  25. package/dist/auth-decorators-Cu_nxWnF.cjs +421 -0
  26. package/dist/auth-decorators-Cu_nxWnF.cjs.map +1 -0
  27. package/dist/auth-errors-C3rbTV9p.mjs +301 -0
  28. package/dist/auth-errors-C3rbTV9p.mjs.map +1 -0
  29. package/dist/auth-errors-DmiKl0L_.cjs +378 -0
  30. package/dist/auth-errors-DmiKl0L_.cjs.map +1 -0
  31. package/dist/auth-guard-CLyhAEEj.cjs +136 -0
  32. package/dist/auth-guard-CLyhAEEj.cjs.map +1 -0
  33. package/dist/auth-guard-h9QB_l11.mjs +125 -0
  34. package/dist/auth-guard-h9QB_l11.mjs.map +1 -0
  35. package/dist/auth-module-BgcY2HpA.cjs +2809 -0
  36. package/dist/auth-module-BgcY2HpA.cjs.map +1 -0
  37. package/dist/auth-module-CtHx3gRU.mjs +2708 -0
  38. package/dist/auth-module-CtHx3gRU.mjs.map +1 -0
  39. package/dist/auth-scope-interceptor-BjxAL1-S.cjs +290 -0
  40. package/dist/auth-scope-interceptor-BjxAL1-S.cjs.map +1 -0
  41. package/dist/auth-scope-interceptor-DYPn3b8M.mjs +231 -0
  42. package/dist/auth-scope-interceptor-DYPn3b8M.mjs.map +1 -0
  43. package/dist/bridge-protocol-BDw8opAG.cjs +25 -0
  44. package/dist/bridge-protocol-BDw8opAG.cjs.map +1 -0
  45. package/dist/bridge-protocol-c66aEkr5.mjs +8 -0
  46. package/dist/bridge-protocol-c66aEkr5.mjs.map +1 -0
  47. package/dist/decorate-Cr4Q7bNE.cjs +319 -0
  48. package/dist/decorate-Cr4Q7bNE.cjs.map +1 -0
  49. package/dist/decorate-Dt0e9K6R.mjs +80 -0
  50. package/dist/decorate-Dt0e9K6R.mjs.map +1 -0
  51. package/dist/express.cjs +256 -0
  52. package/dist/express.cjs.map +1 -0
  53. package/dist/express.d.cts +37 -0
  54. package/dist/express.d.cts.map +1 -0
  55. package/dist/express.d.mts +37 -0
  56. package/dist/express.d.mts.map +1 -0
  57. package/dist/express.mjs +254 -0
  58. package/dist/express.mjs.map +1 -0
  59. package/dist/fastify.cjs +218 -0
  60. package/dist/fastify.cjs.map +1 -0
  61. package/dist/fastify.d.cts +28 -0
  62. package/dist/fastify.d.cts.map +1 -0
  63. package/dist/fastify.d.mts +28 -0
  64. package/dist/fastify.d.mts.map +1 -0
  65. package/dist/fastify.mjs +216 -0
  66. package/dist/fastify.mjs.map +1 -0
  67. package/dist/graphql.cjs +509 -0
  68. package/dist/graphql.cjs.map +1 -0
  69. package/dist/graphql.d.cts +38 -0
  70. package/dist/graphql.d.cts.map +1 -0
  71. package/dist/graphql.d.mts +38 -0
  72. package/dist/graphql.d.mts.map +1 -0
  73. package/dist/graphql.mjs +503 -0
  74. package/dist/graphql.mjs.map +1 -0
  75. package/dist/index.cjs +86 -0
  76. package/dist/index.d.cts +2 -0
  77. package/dist/index.d.mts +2 -0
  78. package/dist/index.mjs +8 -0
  79. package/dist/microservices.cjs +231 -0
  80. package/dist/microservices.cjs.map +1 -0
  81. package/dist/microservices.d.cts +39 -0
  82. package/dist/microservices.d.cts.map +1 -0
  83. package/dist/microservices.d.mts +39 -0
  84. package/dist/microservices.d.mts.map +1 -0
  85. package/dist/microservices.mjs +222 -0
  86. package/dist/microservices.mjs.map +1 -0
  87. package/dist/organization.cjs +191 -0
  88. package/dist/organization.cjs.map +1 -0
  89. package/dist/organization.d.cts +40 -0
  90. package/dist/organization.d.cts.map +1 -0
  91. package/dist/organization.d.mts +40 -0
  92. package/dist/organization.d.mts.map +1 -0
  93. package/dist/organization.mjs +177 -0
  94. package/dist/organization.mjs.map +1 -0
  95. package/dist/platform-CZl7TY8j.mjs +414 -0
  96. package/dist/platform-CZl7TY8j.mjs.map +1 -0
  97. package/dist/platform-YICqqYv6.cjs +473 -0
  98. package/dist/platform-YICqqYv6.cjs.map +1 -0
  99. package/dist/platform.cjs +10 -0
  100. package/dist/platform.d.cts +43 -0
  101. package/dist/platform.d.cts.map +1 -0
  102. package/dist/platform.d.mts +43 -0
  103. package/dist/platform.d.mts.map +1 -0
  104. package/dist/platform.mjs +1 -0
  105. package/dist/plugin.cjs +323 -0
  106. package/dist/plugin.cjs.map +1 -0
  107. package/dist/plugin.d.cts +24 -0
  108. package/dist/plugin.d.cts.map +1 -0
  109. package/dist/plugin.d.mts +24 -0
  110. package/dist/plugin.d.mts.map +1 -0
  111. package/dist/plugin.mjs +321 -0
  112. package/dist/plugin.mjs.map +1 -0
  113. package/dist/principal-readings-Chmt6rQV.cjs +208 -0
  114. package/dist/principal-readings-Chmt6rQV.cjs.map +1 -0
  115. package/dist/principal-readings-Ied2staw.mjs +191 -0
  116. package/dist/principal-readings-Ied2staw.mjs.map +1 -0
  117. package/dist/rolldown-runtime-VH7oDXx4.cjs +28 -0
  118. package/dist/testing/conformance.cjs +6214 -0
  119. package/dist/testing/conformance.cjs.map +1 -0
  120. package/dist/testing/conformance.d.cts +528 -0
  121. package/dist/testing/conformance.d.cts.map +1 -0
  122. package/dist/testing/conformance.d.mts +528 -0
  123. package/dist/testing/conformance.d.mts.map +1 -0
  124. package/dist/testing/conformance.mjs +6204 -0
  125. package/dist/testing/conformance.mjs.map +1 -0
  126. package/dist/testing.cjs +221 -0
  127. package/dist/testing.cjs.map +1 -0
  128. package/dist/testing.d.cts +64 -0
  129. package/dist/testing.d.cts.map +1 -0
  130. package/dist/testing.d.mts +64 -0
  131. package/dist/testing.d.mts.map +1 -0
  132. package/dist/testing.mjs +214 -0
  133. package/dist/testing.mjs.map +1 -0
  134. package/dist/websockets.cjs +396 -0
  135. package/dist/websockets.cjs.map +1 -0
  136. package/dist/websockets.d.cts +67 -0
  137. package/dist/websockets.d.cts.map +1 -0
  138. package/dist/websockets.d.mts +67 -0
  139. package/dist/websockets.d.mts.map +1 -0
  140. package/dist/websockets.mjs +383 -0
  141. package/dist/websockets.mjs.map +1 -0
  142. package/package.json +327 -3
package/README.md CHANGED
@@ -1,3 +1,1079 @@
1
- # Temporary Holding Version
1
+ # @nestjs-kit/better-auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/%40nestjs-kit%2Fbetter-auth)](https://www.npmjs.com/package/@nestjs-kit/better-auth)
4
+ [![npm monthly downloads](https://img.shields.io/npm/dm/%40nestjs-kit%2Fbetter-auth)](https://www.npmjs.com/package/@nestjs-kit/better-auth)
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/thilllon/nestjs-kit/ci.yml?branch=main)](https://github.com/thilllon/nestjs-kit/actions/workflows/ci.yml)
6
+
7
+ A NestJS integration for [Better Auth](https://www.better-auth.com). A construction-time Better Auth plugin bridges hooks and cookies into Nest; the Nest module mounts Better Auth's routes on Express or Fastify, authenticates controllers, GraphQL operations, WebSocket messages and microservice messages through one guard, and evaluates admin, organization and API-key permissions through Better Auth's own endpoints.
8
+
9
+ It supersedes the deprecated `nestjs-slightly-better-auth`; [Migrate from `nestjs-slightly-better-auth`](#migrate-from-nestjs-slightly-better-auth) lists the steps.
10
+
11
+ - [Install](#install)
12
+ - [Migrate from `nestjs-slightly-better-auth`](#migrate-from-nestjs-slightly-better-auth)
13
+ - [Entry points](#entry-points)
14
+ - [Construct the Better Auth instance](#construct-the-better-auth-instance)
15
+ - [Register the module](#register-the-module)
16
+ - [HTTP platforms](#http-platforms)
17
+ - [Access, principals and readers](#access-principals-and-readers)
18
+ - [Authorization](#authorization)
19
+ - [Hooks](#hooks)
20
+ - [Direct `auth.api` calls](#direct-authapi-calls)
21
+ - [Origin checks](#origin-checks)
22
+ - [Lifecycle and operational limits](#lifecycle-and-operational-limits)
23
+ - [GraphQL](#graphql), [WebSocket gateways](#websocket-gateways), [RPC authentication](#rpc-authentication)
24
+ - [Testing helpers](#testing-helpers-testing), [Conformance kits](#conformance-kits-testingconformance), [Examples](#examples)
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ pnpm add @nestjs-kit/better-auth better-auth
30
+ ```
31
+
32
+ Peer dependencies: `@nestjs/common` and `@nestjs/core` 12, `better-auth` 1.7.7 or newer (below 2), `reflect-metadata` and `rxjs`. The package requires Node.js 24.11 or newer (`engines.node` is `>=24.11.0`). Install the Nest platform adapter your application uses, `@nestjs/platform-express` or `@nestjs/platform-fastify`; the `./express` and `./fastify` entries import neither at runtime.
33
+
34
+ The `./testing` and `./testing/conformance` entries also need the optional peer `@nestjs/testing` 12; the root entry never loads it or a test runner. API keys use Better Auth's `@better-auth/api-key` plugin, which the application installs and registers itself (`pnpm add @better-auth/api-key`); the `./api-key` entry never imports it. [Compatibility](#compatibility) lists the tested versions and module systems.
35
+
36
+ The `./graphql` entry has two optional peer dependencies, `@nestjs/graphql` ^14.0.2 and `graphql` ^16.14.2, plus the Nest driver of your GraphQL server:
37
+
38
+ ```sh
39
+ # Apollo on Express
40
+ pnpm add @nestjs/graphql graphql @nestjs/apollo @apollo/server @as-integrations/express5
41
+ # Mercurius on Fastify
42
+ pnpm add @nestjs/graphql graphql @nestjs/mercurius mercurius @nestjs/platform-fastify
43
+ ```
44
+
45
+ The `./websockets` entry also needs the optional peer `@nestjs/websockets` 12 and the Nest adapter for your socket library:
46
+
47
+ ```sh
48
+ pnpm add @nestjs/websockets @nestjs/platform-socket.io # Socket.IO
49
+ pnpm add @nestjs/websockets @nestjs/platform-ws # raw ws
50
+ ```
51
+
52
+ The `./microservices` entry also needs the optional peer `@nestjs/microservices` 12 and the client library Nest uses for your transport, for example:
53
+
54
+ ```sh
55
+ pnpm add @nestjs/microservices @nats-io/transport-node
56
+ ```
57
+
58
+ Nest's gRPC transport uses `@grpc/grpc-js` and `@grpc/proto-loader`, Kafka uses `kafkajs`, RabbitMQ uses `amqplib` and `amqp-connection-manager`, MQTT uses `mqtt`, and Redis uses `ioredis`. TCP needs no client library. Applications that do not import `./microservices` do not need any of these packages.
59
+
60
+ ## Migrate from `nestjs-slightly-better-auth`
61
+
62
+ `@nestjs-kit/better-auth` 2.0.0 has the API of `nestjs-slightly-better-auth` 2.0.0, which is deprecated and receives no further releases.
63
+
64
+ 1. Replace the dependency:
65
+
66
+ ```sh
67
+ pnpm remove nestjs-slightly-better-auth
68
+ pnpm add @nestjs-kit/better-auth
69
+ ```
70
+
71
+ 2. Replace the module specifier `nestjs-slightly-better-auth` with `@nestjs-kit/better-auth` in every import and every `declare module` augmentation, subpaths included: `nestjs-slightly-better-auth/express` becomes `@nestjs-kit/better-auth/express`, and likewise for `/plugin`, `/platform`, `/fastify`, `/graphql`, `/websockets`, `/microservices`, `/admin`, `/organization`, `/api-key`, `/testing` and `/testing/conformance`.
72
+ 3. Replace both halves together. The Better Auth plugin id (`NESTJS_PLUGIN_ID` of `@nestjs-kit/better-auth/plugin`), the `Symbol.for` keys and the injection token values belong to the package name, so the Better Auth plugin of one package does not work with the Nest module of the other. Code that compares the plugin id with the string `"nestjs-slightly-better-auth"` uses `NESTJS_PLUGIN_ID` instead.
73
+
74
+ ## Compatibility
75
+
76
+ | Dependency | Supported | Tested |
77
+ | -------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------- |
78
+ | Node.js | 24.11.0 or newer | 24.11.0 and the current LTS |
79
+ | `@nestjs/common`, `@nestjs/core` | `^12.0.3` | 12.0.3 and the newest 12.x release, with the matching `@nestjs/platform-express` |
80
+ | `better-auth` | `>=1.7.7 <2` | 1.7.7 and the newest 1.x release |
81
+ | TypeScript declarations | `nodenext`, `node16` ESM and `bundler` resolution | TypeScript 5.9, the newest 6.x and 7 in each resolution mode |
82
+ | Bundlers | esbuild, `nest build --webpack` | esbuild 0.28 and Nest CLI 12 with webpack 5 |
83
+
84
+ The packaging tests pack this package, install the tarball with pnpm into temporary applications outside the workspace and run them with the peers installed at their declared floors and at the newest versions inside the peer ranges. The newest versions are those that pnpm's default minimum release age admits, so a new release enters the matrix after that delay. Each application compiles one Nest source as ESM and as CommonJS, signs up users of a default and a named Better Auth instance, calls a guarded route of each with its session cookie, checks that neither instance accepts the other's cookie, and closes. CI runs these tests on the current Node.js LTS and on Node.js 24.11.0.
85
+
86
+ The declared floors are the supported floors. NestJS 11 and Better Auth releases before 1.7.7 are outside the peer ranges and are not tested.
87
+
88
+ Module systems:
89
+
90
+ - Node.js resolves `import` and `require()` to the same ESM build through the `module-sync` export condition, so ESM and CommonJS applications share one copy of every class. The `.cjs` build is the fallback for resolvers without `module-sync`; the tests also run the applications with `require()` routed to it.
91
+ - Better Auth publishes ESM only, so CommonJS applications need `require()` of ES modules, which Node.js 24 enables by default. With `--no-experimental-require-module`, `require()` resolves every entry to its `.cjs` file, but only `./platform` and `./fastify` load; every other entry stops at its first Better Auth import with `ERR_REQUIRE_ESM`.
92
+ - With TypeScript `module: nodenext`, ESM and CommonJS files compile against the published declarations; `module: preserve` with `moduleResolution: bundler` uses the ESM declarations. With `module: node16`, ESM files compile, but CommonJS files cannot import NestJS 12 or Better Auth, whose declarations are ESM only; use `nodenext` for CommonJS applications.
93
+ - Each optional entry loads with only its own optional peers installed, and every other entry loads without any optional peer.
94
+
95
+ Toolchains:
96
+
97
+ - TypeScript 5.9, the newest 6.x and 7 compile the application, a `node16` CommonJS file that imports only this package, and a principal-kind check in each resolution mode. That check runs once with and once without an import of `./api-key`: `'api-key'` is a `PrincipalKind` only in programs that import it.
98
+ - esbuild bundles the compiled application, including this package and Better Auth, as ESM and as CommonJS, and each bundle authenticates both instances from a directory without `node_modules`. The ESM bundle needs a `createRequire` banner for bundled CommonJS dependencies and `--splitting`: without it, esbuild 0.28 leaves Better Auth's `memoryAdapter`, which Better Auth also imports dynamically, uninitialized. Mark Nest's optional `@nestjs/microservices`, `@nestjs/websockets`, `class-transformer` and `class-validator` external when the application does not install them.
99
+ - `nest build --webpack` compiles the application with the Nest CLI's default webpack configuration, which keeps `node_modules` external, and the build authenticates both instances.
100
+
101
+ | Setup | Status |
102
+ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
103
+ | ESM and CommonJS applications on Node.js 24.11.0 or newer | Supported and tested |
104
+ | TypeScript 5.9, 6.x and 7 with `nodenext`, `node16` ESM or `bundler` resolution | Supported and tested |
105
+ | esbuild ESM bundles with `--splitting`, esbuild CommonJS bundles, `nest build --webpack` | Supported and tested |
106
+ | TypeScript `node16` CommonJS files that import NestJS 12 or Better Auth | Unsupported: their declarations are ESM only |
107
+ | CommonJS with `--no-experimental-require-module` | Unsupported: only `./platform` and `./fastify` load |
108
+ | NestJS 11, Better Auth before 1.7.7 | Unsupported: outside the peer ranges |
109
+ | TypeScript before 5.9, the Nest CLI's rspack builder, webpack bundles of `node_modules`, other bundlers | Untested |
110
+
111
+ The packaging tests also inspect the packed archive. It contains only `package.json`, this README, the license and the `dist` files that the export map reaches; source maps name only this package's sources, and runtime maps embed them. Each entry exports exactly the names listed in [`fixtures/public-api.json`](fixtures/public-api.json), and no emitted file has a default export or top-level `await`.
112
+
113
+ ## Entry points
114
+
115
+ Every entry ships ESM and CommonJS builds with declarations. The `module-sync` export condition gives Node `import` and `require()` consumers one shared ESM module identity, and optional peers load only through the entry that needs them. The [changelog](CHANGELOG.md) records the version that introduces each entry.
116
+
117
+ | Entry | Provides | Extra peers |
118
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
119
+ | `.` | `BetterAuthModule`, `BetterAuthService`, `BetterAuthGuard`, `BetterAuthScopeInterceptor`, access, principal and hook decorators, requirement composition, token helpers, errors, the session unit, `httpTransport()` and `betterAuthCorsOrigin()` | none |
120
+ | `./plugin` | `nestjs()`, the Better Auth construction plugin | none |
121
+ | `./platform` | Node/Web helpers for third-party platforms and transports: `toWebHeaders()`, `readBoundedBody()`, `writeWebResponse()`, `appendSetCookie()`, `upgradeRequestUrl()` and related functions | none |
122
+ | `./express` | `expressPlatform()`, `ExpressPlatform` | none |
123
+ | `./fastify` | `fastifyPlatform()`, `FastifyPlatform` | none |
124
+ | `./graphql` | `apolloTransport()`, `mercuriusTransport()`, `mercuriusSubscriptionContext()` | `@nestjs/graphql`, `graphql` |
125
+ | `./websockets` | `socketIoTransport()`, `wsTransport()`, `withUpgradeRequest()`, `WsConnectionAuth`, `WS_CONNECTION_AUTH`, `wsCloseCodeFor()` | `@nestjs/websockets` |
126
+ | `./microservices` | `rpcTransport()` and the credential carriers `grpcCarrier()`, `natsCarrier()`, `kafkaCarrier()`, `rmqCarrier()`, `mqttCarrier()`, `payloadCarrier()` | `@nestjs/microservices` |
127
+ | `./admin` | `permission()`, `RequirePermission()` | none |
128
+ | `./organization` | `orgPermission()`, `RequireOrgPermission()`, `orgMember()`, `RequireOrgMember()`, `activeOrganization()`, `fromParam()`, `fromHeader()`, `organizationRef()`, `ActiveOrganizationId()`, `ActiveMemberRole()` | none |
129
+ | `./api-key` | `apiKeyPrincipal()`, `apiKeyPermission()`, `RequireApiKeyPermission()`, `API_KEY_PRINCIPAL_KIND` | none |
130
+ | `./testing` | `overrideAuthGuard()`, `overridePrincipal()`, `overrideDecisions()`, `stampPrincipal()`, `testPrincipal()`, `authHeadersFor()`, `initTestApp()` | `@nestjs/testing` |
131
+ | `./testing/conformance` | Runner-agnostic conformance kits for platforms, transports, principal sources and policies | `@nestjs/testing` |
132
+
133
+ The [design workspace](docs/design/README.md) holds the reviewed [specification](docs/design/design-v7.md), its [review ledger](docs/design/ledger.md) and the [implementation plan](docs/plans/2026-09-21-better-auth.md).
134
+
135
+ ## Construct the Better Auth instance
136
+
137
+ Add `nestjs()` from `./plugin` to every `betterAuth()` call, as the last entry of `plugins`:
138
+
139
+ ```ts
140
+ // auth.ts
141
+ import { apiKey } from "@better-auth/api-key";
142
+ import { betterAuth } from "better-auth";
143
+ import { admin, bearer, organization } from "better-auth/plugins";
144
+ import { nestjs } from "@nestjs-kit/better-auth/plugin";
145
+ import { Pool } from "pg";
146
+
147
+ export const auth = betterAuth({
148
+ baseURL: process.env.BETTER_AUTH_URL,
149
+ database: new Pool({ connectionString: process.env.DATABASE_URL }),
150
+ emailAndPassword: { enabled: true },
151
+ // nestjs() comes last, after every plugin with hooks.
152
+ plugins: [admin(), organization(), apiKey(), bearer(), nestjs()],
153
+ });
154
+
155
+ declare module "@nestjs-kit/better-auth" {
156
+ interface Register {
157
+ auth: typeof auth;
158
+ }
159
+ }
160
+ ```
161
+
162
+ The example stores data in PostgreSQL through `pg` (`pnpm add pg`); any [Better Auth database](https://www.better-auth.com/docs/concepts/database) works. The plugin is pure construction-time configuration: it adds fixed hook entries and database-hook dispatchers when Better Auth builds its pipeline, and the Nest module binds its hook tables to them later. The module never wraps, clones or mutates the `auth` object. Startup fails with `PLUGIN_MISSING` when an instance has no `nestjs()`, and with `PLUGIN_SHARED_BETWEEN_INSTANCES` when two instances share one plugin object, so call `nestjs()` inside each `betterAuth()` call. When a plugin after `nestjs()` declares after-hooks, startup logs `W_PLUGIN_NOT_LAST`: those hooks would run after the plugin's cookie bridge and endpoint-result observer, which then miss their changes. Nest `@BeforeAuth()` and `@AfterAuth()` hooks run after the application's own `hooks` option and after the hooks of earlier plugins.
163
+
164
+ `nestjs({ clientIpHeader })` controls how Better Auth learns the client IP. By default the plugin adds a random header name per `nestjs()` call (`x-nestjs-kit-better-auth-ip-<32 hex characters>`) to `advanced.ipAddress.ipAddressHeaders`, the platform sets it from its own trust-proxy-aware client IP, and the kernel deletes any client-sent copy, so a client cannot choose its rate-limit bucket or the IP recorded on its sessions. A string pins a fixed header name for a trusted sidecar that calls `auth.handler` itself; `false` contributes nothing, and Better Auth's own `advanced.ipAddress` settings apply.
165
+
166
+ The `Register` augmentation types `BetterAuthService`, `AuthSession`, `AuthUser`, `AuthPrincipal`, hook contexts and permission maps from `typeof auth`. Named instances go under `instances: { admin: typeof adminAuth }`. Without the augmentation, types fall back to Better Auth's default session shape and permission maps accept any resource. With it, the default instance's `forRoot()` rejects an `auth` of another type.
167
+
168
+ ## Register the module
169
+
170
+ Register the default instance once, in the root module:
171
+
172
+ ```ts
173
+ // app.module.ts
174
+ import { Module } from "@nestjs/common";
175
+ import { BetterAuthModule } from "@nestjs-kit/better-auth";
176
+ import { apiKeyPrincipal } from "@nestjs-kit/better-auth/api-key";
177
+ import { expressPlatform } from "@nestjs-kit/better-auth/express";
178
+ import { auth } from "./auth";
179
+
180
+ @Module({
181
+ imports: [
182
+ BetterAuthModule.forRoot({
183
+ auth,
184
+ platforms: [expressPlatform()],
185
+ principals: [apiKeyPrincipal()],
186
+ }),
187
+ ],
188
+ })
189
+ export class AppModule {}
190
+ ```
191
+
192
+ `forRoot()` returns a global module by default. It registers `BetterAuthGuard` as `APP_GUARD` and `BetterAuthScopeInterceptor` as `APP_INTERCEPTOR`, so every controller route requires a session unless it opts out. Undecorated handlers follow `defaultAccess` (`"authenticated"` by default; `"public"` makes the application opt-in).
193
+
194
+ ### Static and runtime options
195
+
196
+ Options that decide which providers exist are static. Everything else is a runtime option, which `forRootAsync()` can compute from injected providers.
197
+
198
+ | Kind | Options |
199
+ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
200
+ | App-level static, default instance only | `platforms`, `transports` |
201
+ | Instance static | `name`, `global` (default `true`), `globalGuard` (default `true` for the default instance, `false` for named ones), `globalScope` (default `true`, default instance only), `principals` |
202
+ | Runtime | `auth`, `defaultAccess`, `defaultRequirements`, `session`, `http` (`mount`, `bodyLimit`, `around`, `allowRootMount`, `allowRequestDerivedBaseURL`, `diagnostics`), `cookies.forwardDirectCalls`, `originCheck` (`mode`, `missingOrigin`), `errors` (`map`, `exposeRawCause`), `limits.maxAuthorizationCallsPerRequest`, `logSummary` |
203
+
204
+ `forRootAsync()` takes the static options next to `useFactory`; the factory returns runtime options only. Returning a static key from the factory is a compile-time error with a readable message, and a runtime error (`STATIC_OPTION_IN_FACTORY`) for untyped callers:
205
+
206
+ ```ts
207
+ import { Injectable, Module } from "@nestjs/common";
208
+ import { BetterAuthModule } from "@nestjs-kit/better-auth";
209
+ import { fastifyPlatform } from "@nestjs-kit/better-auth/fastify";
210
+ import { auth } from "./auth";
211
+
212
+ @Injectable()
213
+ export class AuthSettings {
214
+ readonly bodyLimit = Number(process.env.AUTH_BODY_LIMIT ?? 1_048_576);
215
+ }
216
+
217
+ @Module({
218
+ providers: [AuthSettings],
219
+ exports: [AuthSettings],
220
+ })
221
+ export class AuthSettingsModule {}
222
+
223
+ @Module({
224
+ imports: [
225
+ BetterAuthModule.forRootAsync({
226
+ platforms: [fastifyPlatform()],
227
+ imports: [AuthSettingsModule],
228
+ inject: [AuthSettings],
229
+ useFactory: (settings: AuthSettings) => ({
230
+ auth,
231
+ http: { bodyLimit: settings.bodyLimit },
232
+ }),
233
+ }),
234
+ ],
235
+ })
236
+ export class AppModule {}
237
+ ```
238
+
239
+ The factory can also build the instance from injected providers, for example a database pool. Register its type through the factory's return type, `interface Register { auth: ReturnType<typeof createAuth> }`, and give the Better Auth CLI its own module-level instance.
240
+
241
+ ### Default and named instances
242
+
243
+ An application has one default instance and any number of named instances. Each named instance has its own injection tokens, `nestjs()` binding, mount path, principal sources and runtime options; `platforms` and `transports` are app-wide and belong to the default instance only (`APP_EXTENSIONS_ON_NAMED_INSTANCE` otherwise). Named registrations require a default registration (`NO_DEFAULT_INSTANCE`). Two instances must not share a mount path (`OVERLAPPING_MOUNTS`) or cookie names on overlapping scopes (`COOKIE_NAME_COLLISION`), so give the second instance its own `basePath` and `advanced.cookiePrefix`:
244
+
245
+ ```ts
246
+ import { Controller, Get, Inject, Module } from "@nestjs/common";
247
+ import { betterAuth } from "better-auth";
248
+ import {
249
+ type AuthOf,
250
+ type AuthUser,
251
+ BetterAuthModule,
252
+ BetterAuthService,
253
+ CurrentUser,
254
+ getBetterAuthServiceToken,
255
+ UseAuthInstance,
256
+ } from "@nestjs-kit/better-auth";
257
+ import { expressPlatform } from "@nestjs-kit/better-auth/express";
258
+ import { nestjs } from "@nestjs-kit/better-auth/plugin";
259
+ import { auth } from "./auth";
260
+
261
+ export const adminAuth = betterAuth({
262
+ baseURL: process.env.BETTER_AUTH_URL,
263
+ basePath: "/api/admin-auth",
264
+ advanced: { cookiePrefix: "admin-auth" },
265
+ emailAndPassword: { enabled: true },
266
+ plugins: [nestjs()],
267
+ });
268
+
269
+ declare module "@nestjs-kit/better-auth" {
270
+ interface Register {
271
+ instances: { admin: typeof adminAuth };
272
+ }
273
+ }
274
+
275
+ @UseAuthInstance("admin")
276
+ @Controller("admin")
277
+ export class AdminController {
278
+ constructor(
279
+ @Inject(getBetterAuthServiceToken("admin"))
280
+ private readonly adminAuth: BetterAuthService<AuthOf<"admin">>,
281
+ ) {}
282
+
283
+ @Get("me")
284
+ me(@CurrentUser() operator: AuthUser<"admin">) {
285
+ return { id: operator.id };
286
+ }
287
+
288
+ @Get("session")
289
+ async session() {
290
+ const session = await this.adminAuth.getSession();
291
+ return { expiresAt: session?.session.expiresAt };
292
+ }
293
+ }
294
+
295
+ @Module({
296
+ imports: [
297
+ BetterAuthModule.forRoot({ auth, platforms: [expressPlatform()] }),
298
+ BetterAuthModule.forRoot({ name: "admin", auth: adminAuth }),
299
+ ],
300
+ controllers: [AdminController],
301
+ })
302
+ export class AppModule {}
303
+ ```
304
+
305
+ The one global guard evaluates each handler against the instance named by `@UseAuthInstance()` (method over class, default `"default"`). Named instances do not register a second global guard; at most one registration may set `globalGuard: true` (`DUPLICATE_GLOBAL_GUARD`). A named instance registers the same way through `forRootAsync({ name: "admin", useFactory })`.
306
+
307
+ ### Injection tokens
308
+
309
+ Inject with Nest's `@Inject()` and the token helpers; the default instance also resolves by class (`BetterAuthService`). An empty alias or `"default"` returns the default token, and a named alias returns `<BASE_TOKEN>_<alias>`.
310
+
311
+ | Helper | Resolves to |
312
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
313
+ | `getBetterAuthServiceToken(alias?)` | `BetterAuthService` for the instance |
314
+ | `getBetterAuthInstanceToken(alias?)` | the exact object `betterAuth()` returned |
315
+ | `getBetterAuthOptionsToken(alias?)` | the instance's resolved runtime options |
316
+ | `getBetterAuthHandleToken(alias?)` | the instance's `AuthHandle`, which runs `auth.api` calls inside an explicit scope and checks origins, for extensions |
317
+
318
+ `BetterAuthService` exposes `instance`, `api` and `context()` (the awaited `$context`), `mount()` (base path, body limit and platform id, or `undefined` when unmounted), the readers described under [Access, principals and readers](#access-principals-and-readers), `headersFrom(request)` and the direct-call helpers described under [Direct `auth.api` calls](#direct-authapi-calls).
319
+
320
+ ## HTTP platforms
321
+
322
+ Pass exactly one platform that supports the application's HTTP adapter (`NO_PLATFORM` or `AMBIGUOUS_PLATFORM` otherwise). Microservice-only and application-context bootstraps have no HTTP adapter and mount nothing.
323
+
324
+ ```ts
325
+ // main.ts
326
+ import { NestFactory } from "@nestjs/core";
327
+ import { ExpressAdapter } from "@nestjs/platform-express";
328
+ import { betterAuthCorsOrigin } from "@nestjs-kit/better-auth";
329
+ import { AppModule } from "./app.module";
330
+ import { auth } from "./auth";
331
+
332
+ const app = await NestFactory.create(AppModule, new ExpressAdapter());
333
+ // Optional: follow Better Auth's exact trusted origins for credentialed CORS.
334
+ app.enableCors({ origin: betterAuthCorsOrigin(auth), credentials: true });
335
+ app.enableShutdownHooks();
336
+ await app.listen(3000);
337
+ ```
338
+
339
+ For Fastify, pass `fastifyPlatform()` from `@nestjs-kit/better-auth/fastify` and create the application with `new FastifyAdapter()`. Both factories accept `{ clientIp }`, a function that returns the client IP from the native request; the default is Express `req.ip` or Fastify `request.ip`, which honor the adapter's proxy trust setting.
340
+
341
+ **Mount path.** Each instance's routes are mounted at the path Better Auth routes: the pathname of its resolved base URL, which is `baseURL` with `basePath` (default `/api/auth`) appended when `baseURL` has no path of its own, or `basePath` alone when the base URL is unset or dynamic. Auth routes live outside `setGlobalPrefix()` and `enableVersioning()`; put a prefix into `basePath` or `baseURL` instead. Everything below the base path goes to Better Auth, which answers unknown paths, methods and trailing slashes itself. A root mount (`/`) requires `http.allowRootMount`, and `http.mount: false` keeps an instance guard-only. `http.diagnostics` (on by default outside production) logs each auth path Better Auth answers 404 for once, with the closest endpoint path. A Nest controller route under an auth base path takes precedence and logs `W_ROUTE_SHADOWS_AUTH`.
342
+
343
+ **Bodies.** Auth routes receive exactly the bytes the client sent, bounded by `http.bodyLimit` (default 1 MiB; a number of bytes or a string such as `"2mb"`). A larger body receives `413 { code: "PAYLOAD_TOO_LARGE" }` on both platforms, with the host's CORS headers, and an unparsable request URL receives `400 { code: "INVALID_REQUEST_URL" }`. Application routes keep Nest's parsers, `rawBody`, `useBodyParser()` limits and Fastify's secure JSON parser. Express captures auth-route bodies before Nest registers its parsers; global middleware that reads the raw request stream without checking whether it already ended waits on a finished stream for auth routes. When an Express instance passed to `new ExpressAdapter(app)` already parsed the body, the platform recovers it from `req.rawBody` or re-serializes `req.body` and logs `W_BODY_ALREADY_CONSUMED` once, because raw-signature endpoints such as webhooks then receive different bytes. Fastify parses auth routes in its own encapsulated context and never reaches that path.
344
+
345
+ **Responses and cookies.** Responses are written verbatim, including redirects, streams and arbitrary content types. Every `Set-Cookie` line is appended to any cookies already set, except a line identical to the latest line already set for the same cookie, `Vary` values are merged, and host headers such as CORS are kept. Nest interceptors, pipes and serializers never run on auth routes; exception filters receive only failures that reach the host error pipeline, such as `BetterAuthInfrastructureError` or an `APIError` rethrown under Better Auth's `onAPIError: { throw: true }`. `http.around` wraps every auth-route exchange for request contexts or tracing:
346
+
347
+ ```ts
348
+ BetterAuthModule.forRoot({
349
+ auth,
350
+ platforms: [expressPlatform()],
351
+ http: {
352
+ around: [
353
+ async (call, next) => {
354
+ const started = Date.now();
355
+ const response = await next();
356
+ console.log(call.instance, call.request.url, Date.now() - started);
357
+ return response;
358
+ },
359
+ ],
360
+ },
361
+ });
362
+ ```
363
+
364
+ **Base URL.** In production (any `NODE_ENV` other than `development`, `dev` or `test`, including an unset one), startup fails with `UNSAFE_BASE_URL` when Better Auth has no static base URL and no dynamic `baseURL.allowedHosts` configuration: Better Auth would otherwise derive token links and a trusted origin from each request's `Host` header. `http.allowRequestDerivedBaseURL: true` accepts that behavior explicitly. A production instance that signs with Better Auth's public default secret fails with `DEFAULT_SECRET`.
365
+
366
+ **Proxies and rate limits.** Configure proxy trust on the Nest adapter: Express `app.set("trust proxy", ...)` or Fastify `trustProxy`. The boot summary prints each platform's trust mode; Express reports it from its settings, and Fastify reports `unknown` because it exposes no public inspection API, which disables the diagnostics that depend on it. `W_PROXY_TRUST_ALL` warns when the platform trusts every proxy in production. Better Auth enables its rate limiter only when it saw `NODE_ENV=production` at import time; `W_RATE_LIMIT_DISABLED` reports a production deployment where the limiter resolved off, for example because `.env` set `NODE_ENV` after `auth.ts` was imported. Set `rateLimit.enabled` explicitly in that case.
367
+
368
+ **Express specifics.** The platform recognizes a request by Node object identity: an `http.IncomingMessage` or stream request whose `res` is the `http.ServerResponse` bound to it. Requests dispatched with light-my-request's `inject(app.getHttpAdapter().getInstance(), ...)` are supported. `inject()` re-parents Express's shared request and response prototypes for the whole process, and a listening Express server in the same process then fails inside light-my-request, so keep injected and listening tests in separate test files. Express preserves native controller parsing for unconditional routes that overlap the auth mount. Body-capable controller routes that also depend on host or non-URI version conditions are rejected at startup with `CONDITIONAL_ROUTE_SHADOW`; move them outside the auth mount. The integration checks the effective Express router flags, because changing application settings after router creation does not change existing route matching.
369
+
370
+ **HTTP/2.** Fastify adapters created with `http2: true` are supported: HTTP/2 pseudo-headers never reach Better Auth, and `host` is taken from `:authority` when absent. Nest's Express adapter has no HTTP/2 server.
371
+
372
+ **CORS.** The library configures no CORS. Host CORS middleware and `@fastify/cors` apply to auth routes, including preflight and the 413 response. `betterAuthCorsOrigin(auth, { allowPatterns })` returns an origin callback that accepts Better Auth's exact trusted `http(s)` origins, including plugin-contributed ones; wildcard and custom-scheme entries need `allowPatterns: true`, and a function-valued `trustedOrigins` throws at first use because a CORS callback has no request. Every origin the callback accepts gains credentialed read access to the routes it covers, so prefer to scope it to the auth mount and give application routes their own allow-list.
373
+
374
+ ## Access, principals and readers
375
+
376
+ The guard compiles one plan per controller class and handler at startup and validates every plan before the application starts: contradictory decorators, unknown instances, unsatisfiable principal kinds and uncovered transport handlers fail boot with one aggregated `AUTH_BOOT_FAILED` error that lists every problem.
377
+
378
+ ```ts
379
+ import { Controller, Delete, Get } from "@nestjs/common";
380
+ import {
381
+ AcceptPrincipals,
382
+ type AuthPrincipal,
383
+ type AuthSession,
384
+ type AuthUser,
385
+ CurrentPrincipal,
386
+ CurrentSession,
387
+ CurrentUser,
388
+ OptionalAuth,
389
+ Public,
390
+ RequireAuth,
391
+ } from "@nestjs-kit/better-auth";
392
+ import "@nestjs-kit/better-auth/api-key";
393
+
394
+ @Controller("projects")
395
+ export class ProjectsController {
396
+ @Public()
397
+ @Get("health")
398
+ health() {
399
+ return { ok: true };
400
+ }
401
+
402
+ @Get()
403
+ list(@CurrentUser() user: AuthUser) {
404
+ return { owner: user.id };
405
+ }
406
+
407
+ @OptionalAuth()
408
+ @Get("feed")
409
+ feed(@CurrentSession() session: AuthSession | null) {
410
+ return { signedIn: session !== null };
411
+ }
412
+
413
+ @AcceptPrincipals("session", "api-key")
414
+ @Get("export")
415
+ export(@CurrentPrincipal() principal: AuthPrincipal) {
416
+ return { kind: principal.kind, userId: principal.userId };
417
+ }
418
+
419
+ @RequireAuth({ authoritative: true })
420
+ @Delete(":id")
421
+ remove() {
422
+ return { removed: true };
423
+ }
424
+ }
425
+ ```
426
+
427
+ | Decorator | Effect |
428
+ | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
429
+ | `@Public()` | No principal I/O. A presented credential is not inspected, and principal readers return `null`. Principal parameter decorators on the same handler fail boot. |
430
+ | `@OptionalAuth()` | Resolves a principal when credentials are present; a missing credential is anonymous, an invalid one is still denied. |
431
+ | `@RequireAuth({ authoritative })` | Requires a principal (the default access). `authoritative: true` bypasses Better Auth's cookie cache for that route, so a revoked session fails on the next request. |
432
+ | `@AcceptPrincipals(...kinds)` | Principal kinds the route admits, in addition to the kinds its requirements name. Without it, a route admits the kinds of sources with default acceptance: sessions only. A source whose kinds a route does not admit is not consulted. |
433
+ | `@UseAuthInstance(name)` | Evaluates the handler against a named instance. |
434
+ | `@UseBetterAuth()` | Applies the guard and the scope interceptor locally, for applications with `globalGuard: false` and for gateways. |
435
+ | `@Require(...)`, `@SkipDefaultRequirements()` | Authorization requirements; see [Authorization](#authorization). |
436
+ | `@ForwardAuthCookies()`, `@SkipOriginCheck()` | Direct-call cookie forwarding and origin-check opt-out; see [Direct `auth.api` calls](#direct-authapi-calls) and [Origin checks](#origin-checks). |
437
+
438
+ The method level wins over the class level; `@Public()` or `@OptionalAuth()` on a method is a visible opt-out of class-level requirements. The boot summary counts public, optional and inheriting handlers and handlers that skip default requirements.
439
+
440
+ Failures answer `401 UNAUTHENTICATED`, `403 FORBIDDEN` or `429 RATE_LIMITED` with a `reason` such as Better Auth's error code. On HTTP the body is `{ statusCode, error, code, reason?, message }`, with `WWW-Authenticate` and `Retry-After` headers where applicable; `errors.map(failure, context, transport)` replaces the thrown object per instance. Infrastructure failures (storage, network, Better Auth 5xx) throw `BetterAuthInfrastructureError` (message `Authentication service unavailable`, redacted cause) and are never reported as 401 or 403. The cause is redacted of the request's cookie values, `Authorization` value and principal sources' credential headers, including when a principal source or policy threw the error itself; with `errors.exposeRawCause: true`, `getRawCause(error)` returns the unredacted cause for local debugging. Request-time configuration errors throw `BetterAuthConfigurationError` with the fixed message `Authentication is misconfigured`; its `code`, `detail` and `hint` are logged, not sent. Nest's exception layer answers both with a 500.
441
+
442
+ ### Readers
443
+
444
+ `@CurrentSession()` returns the Better Auth session, `@CurrentUser()` its user, and `@CurrentPrincipal()` the principal of any kind (`AuthPrincipal`, narrowed by `kind`). All three return `null` on an optional route without a principal. `@CurrentSession()` and `@CurrentUser()` accept only session principals: a route that could admit another kind fails boot with `PRINCIPAL_PARAM_CONFLICT`. `definePrincipalParam({ kind, reason, project })` builds kind-constrained parameter decorators of the same shape, and `defineInvocationParam(slot, { missing })` exposes a value a policy published for the current invocation.
445
+
446
+ `BetterAuthService.getSession()` and `getPrincipal()` return what the guard recorded for the current handler; they never start a resolution and cost no I/O. They return `null` in public handlers. `getSession()` throws `SESSION_REQUIRED` for an authenticated principal of another kind; use `getPrincipal()` and narrow `kind` on mixed-kind routes. Outside a handler scope (middleware, other guards, background jobs) they throw `NO_AUTH_SCOPE`, and in a handler the guard never ran for they throw `NO_AUTH_RESULT`.
447
+
448
+ Guards and interceptors read the principal with `principalFor(context)`. It is synchronous and returns a `PrincipalReading`: `{ outcome: "no-identity" }` on public plans, otherwise the recorded `authenticated`, `absent` or `rejected` result, each tagged with its instance:
449
+
450
+ ```ts
451
+ import {
452
+ type CanActivate,
453
+ type ExecutionContext,
454
+ Inject,
455
+ Injectable,
456
+ } from "@nestjs/common";
457
+ import {
458
+ BetterAuthService,
459
+ getBetterAuthServiceToken,
460
+ } from "@nestjs-kit/better-auth";
461
+
462
+ @Injectable()
463
+ export class VerifiedEmailGuard implements CanActivate {
464
+ constructor(
465
+ @Inject(getBetterAuthServiceToken())
466
+ private readonly auth: BetterAuthService,
467
+ ) {}
468
+
469
+ canActivate(context: ExecutionContext): boolean {
470
+ const reading = this.auth.principalFor(context);
471
+ if (reading.outcome === "no-identity") {
472
+ return true;
473
+ }
474
+ return (
475
+ reading.outcome === "authenticated" &&
476
+ reading.principal.kind === "session" &&
477
+ reading.principal.session.user.emailVerified
478
+ );
479
+ }
480
+ }
481
+ ```
482
+
483
+ A guard that reads the principal must run after `BetterAuthGuard`, or `principalFor()` throws `PRINCIPAL_READ_BEFORE_GUARD`. Nest runs an `APP_GUARD` declared in `AppModule` before the global guard that `BetterAuthModule.forRoot()` contributes, so register such a guard with `@UseGuards()`, in a module imported after `BetterAuthModule`, or after your own `{ provide: APP_GUARD, useExisting: BetterAuthGuard }` with `globalGuard: false`. The boot summary prints the global guards in the order Nest runs them.
484
+
485
+ ## Authorization
486
+
487
+ Requirements are values that carry a policy. `@Require(...requirements)` appends them to the class or method; stacked decorators and base-class requirements accumulate instead of overwriting each other. Every required plan evaluates the instance's `defaultRequirements` first, then class requirements (base class first), then method requirements, and stops at the first denial. `anyOf()` allows when any child allows and `allOf()` groups requirements inside `anyOf()`. An empty `anyOf()` or `allOf()`, for example one built from an empty configured list, fails planning with `EMPTY_REQUIREMENT_GROUP` instead of allowing or denying every caller.
488
+
489
+ ```ts
490
+ import { Controller, Get, Post } from "@nestjs/common";
491
+ import { anyOf, Require, RequireFreshSession } from "@nestjs-kit/better-auth";
492
+ import { permission, RequirePermission } from "@nestjs-kit/better-auth/admin";
493
+ import {
494
+ ActiveOrganizationId,
495
+ fromParam,
496
+ orgPermission,
497
+ RequireOrgMember,
498
+ } from "@nestjs-kit/better-auth/organization";
499
+
500
+ @RequireFreshSession()
501
+ @Controller("members")
502
+ export class MembersController {
503
+ @RequirePermission({ user: ["list"] })
504
+ @Get()
505
+ list() {
506
+ return [];
507
+ }
508
+
509
+ @Require(
510
+ anyOf(
511
+ permission({ user: ["ban"] }),
512
+ orgPermission(
513
+ { member: ["delete"] },
514
+ { organization: fromParam("orgId") },
515
+ ),
516
+ ),
517
+ )
518
+ @Post(":orgId/remove")
519
+ remove() {
520
+ return { removed: true };
521
+ }
522
+
523
+ @RequireOrgMember()
524
+ @Get("active")
525
+ active(@ActiveOrganizationId() organizationId: string) {
526
+ return { organizationId };
527
+ }
528
+ }
529
+ ```
530
+
531
+ | Requirement (entry) | Delegates to | Principals | Denials |
532
+ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
533
+ | `freshSession({ maxAgeSeconds })`, `@RequireFreshSession()` (`.`) | the session's `createdAt` against `maxAgeSeconds` or Better Auth's `session.freshAge` | `session` | 403 `SESSION_NOT_FRESH` |
534
+ | `permission(p, { principals })`, `@RequirePermission()` (`./admin`) | `auth.api.userHasPermission` with the user id and no headers, so Better Auth reads the stored user's role, `defaultRole` and `adminUserIds`; the route reads identity authoritatively | `session`; opt in to more kinds | 403 `MISSING_PERMISSION`; delegated principals: 401 `USER_BANNED` or `USER_NOT_FOUND`, 403 `INSUFFICIENT_SCOPE`, 403 `USER_REQUIRED` for organization-owned keys |
535
+ | `orgPermission(p, { organization })`, `@RequireOrgPermission()` (`./organization`) | `auth.api.hasPermission` with the request's headers and an explicit organization id | `session` | 403 with the organization reference's missing reason, Better Auth's membership code or `MISSING_PERMISSION` |
536
+ | `orgMember({ organization })`, `@RequireOrgMember()` (`./organization`) | `auth.api.getActiveMemberRole` for the organization id | `session` | 403 with the missing reason or `YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION` |
537
+ | `apiKeyPermission(p)`, `@RequireApiKeyPermission()` (`./api-key`) | the verified key's own permissions through Better Auth's access-control `role().authorize()` | `api-key` (admits keys by itself) | 403 `MISSING_PERMISSION` |
538
+
539
+ Permission maps are typed from the registered instance's access-control statements, so unknown resources and actions are compile errors, and each requirement's plugin (`admin`, `organization`, `api-key`) is checked at startup (`PLUGIN_PREREQUISITE`). Empty permission maps fail boot. Named instances pass the instance name as a type argument, for example `permission<"admin">({ ... })`. The admin policy calls Better Auth without request headers, so an instance with a dynamic `baseURL.allowedHosts` configuration needs a `fallback` (`DYNAMIC_BASE_URL_WITHOUT_FALLBACK`).
540
+
541
+ **Organization references.** The organization policies always send an explicit organization id. `activeOrganization()` (the default) reads `session.activeOrganizationId`; with Better Auth's `custom-session` plugin and a session shape without it, it asks `getActiveMember`. `fromParam(name)` reads a route parameter, GraphQL argument or WebSocket/RPC payload field, `fromHeader(name)` reads a header, and `organizationRef(fn, { missingReason })` wraps any function. Only a non-empty string counts as an organization id; anything else denies with the reference's `missingReason` (`NO_ACTIVE_ORGANIZATION` for the active organization, `ORGANIZATION_REQUIRED` otherwise) without calling Better Auth. Slugs are not ids: map a slug to an id in your own `organizationRef()`, for example through `auth.api.listOrganizations({ headers })`. Startup warns with `W_ORG_PARAM_IGNORED` when a handler takes an `orgId`-like input while its requirement checks the active organization, and with `W_ORG_PARAM_MISSING` when `fromParam()` names an input the handler does not have. `@ActiveOrganizationId()` and `@ActiveMemberRole()` return the organization id and member role the policy resolved for the current invocation. Only requirements that contributed to the allow decision publish them. When two of a handler's requirements resolve different organizations, for example `defaultRequirements: [orgMember()]` on the active organization and an `orgPermission()` with `fromParam()`, both decorators throw `AMBIGUOUS_INVOCATION_VALUE` (a generic 500) instead of choosing one; read the organization from the route, or check one organization per handler.
542
+
543
+ **API keys and delegation.** `apiKeyPrincipal({ header, configId, references, outageProbe, acceptance })` is a principal source for Better Auth's API-key plugin. It reads `x-api-key` by default, calls `verifyApiKey` once per request and only on routes that admit `"api-key"`, and never exposes the raw key. Its acceptance is explicit: a route admits keys through `@AcceptPrincipals("session", "api-key")` or a requirement that names the kind, such as `@RequireApiKeyPermission()`. Each verification counts against the key's usage and rate limit; `RATE_LIMITED` answers 429 with `Retry-After`, `USAGE_EXCEEDED` answers 429, and `KEY_NOT_FOUND`, `KEY_DISABLED`, `KEY_EXPIRED` and `INVALID_API_KEY` answer 401. Keys that belong to an organization need `references: "organization"` (or a function of the key's `configId`), because the plugin object does not expose its configuration.
544
+
545
+ An API-key principal is delegated: it acts with a narrower grant than its owner. A policy that does not name the principal's kind never judges it; the requirement denies `403 PRINCIPAL_NOT_SUPPORTED`, so a user-level policy never extends a key to its owner's full rights. `permission(p, { principals: ["session", "api-key"] })` opts keys into an admin check: the policy reads the owner's stored user row on every request, denies an owner under an active ban (`banUser` revokes sessions but not API keys), evaluates the owner's stored role, and additionally requires the key's own permissions. The organization policies never accept API keys, because Better Auth's organization endpoints need a session.
546
+
547
+ With `apiKey({ enableSessionForAPIKeys: true })`, Better Auth itself turns a key into a user session on every endpoint that sees the header. Such keys authenticate ordinary routes as `session` principals with the owner's full rights and spend their quota on every Better Auth call that carries them, including each organization check; authoritative routes and the admin policy reject them. When the API-key plugin is present, the session unit's boot advice (`W_API_KEY_FULL_SESSION`, and `W_API_KEY_SESSION_MULTIPLIER` listing the policies that present the request's credentials) describes this, worded conditionally by default. `session: { apiKeySessions: true }` states that a configuration enables the option and words the advice as fact; `session: { apiKeySessions: false }` states that none does and drops the advice. Prefer `apiKeyPrincipal()` with `@RequireApiKeyPermission()` for key traffic.
548
+
549
+ **Default requirements.** `defaultRequirements: [orgMember()]` adds a company-wide rule to every required plan of the instance. `@SkipDefaultRequirements()` opts one handler or controller out of the defaults while keeping its own requirements; startup fails with `UNSATISFIABLE_PRINCIPAL_KINDS` when the AND-ed requirements of a plan admit no common principal kind.
550
+
551
+ **Custom policies.** `definePolicy({ id, requires, evaluate, validate })` returns a requirement builder. Policies return `allow()` or `deny({ reason, status })` and throw only for infrastructure faults. `context.memo(key, compute)` deduplicates Better Auth calls per logical request; `limits.maxAuthorizationCallsPerRequest` (default 100, `false` disables it) bounds the distinct memoized calls of one request, past which a requirement answers `429 TOO_MANY_AUTHORIZATION_CHECKS` without calling Better Auth. For DI-backed policies use `requirement(PolicyClass, params)`. A policy call that meets Better Auth's generic 401 for a session principal re-reads the session once: a session that still resolves turns the failure into a 5xx, a session that ended into 401; a 401 with another code becomes a 403 denial.
552
+
553
+ Decisions are made per invocation: each GraphQL field, alias and WebSocket message gets its own decision, while Better Auth I/O is shared per logical request.
554
+
555
+ ## Hooks
556
+
557
+ Hook providers are ordinary singleton providers; any provider in the application can declare hooks, without a class marker. Hooks bind to the default instance unless `instance` names another one.
558
+
559
+ ```ts
560
+ import { Injectable } from "@nestjs/common";
561
+ import { APIError } from "better-auth/api";
562
+ import {
563
+ AfterAuth,
564
+ AfterDatabase,
565
+ type AuthAfterHookContext,
566
+ type AuthHookContext,
567
+ BeforeAuth,
568
+ BeforeDatabase,
569
+ type DatabaseHookData,
570
+ } from "@nestjs-kit/better-auth";
571
+
572
+ @Injectable()
573
+ export class AccountHooks {
574
+ @BeforeAuth("/sign-up/email")
575
+ checkDomain(ctx: AuthHookContext<"/sign-up/email">) {
576
+ const email = ctx.body.email;
577
+ if (typeof email !== "string" || !email.endsWith("@example.com")) {
578
+ throw new APIError("FORBIDDEN", { message: "Unsupported domain" });
579
+ }
580
+ }
581
+
582
+ @AfterAuth(["/sign-in/email", "/sign-in/social", "/callback/:id"], {
583
+ calls: "http",
584
+ skipInternal: true,
585
+ })
586
+ signedIn(
587
+ ctx: AuthAfterHookContext<
588
+ "/sign-in/email" | "/sign-in/social" | "/callback/:id"
589
+ >,
590
+ ) {
591
+ console.log("sign-in", ctx.context.newSession?.user.id);
592
+ }
593
+
594
+ @BeforeDatabase("user.create")
595
+ normalize(user: DatabaseHookData<"user.create">) {
596
+ return { data: { name: user.name.trim() } };
597
+ }
598
+
599
+ @BeforeDatabase("user.delete")
600
+ protectOwner(user: DatabaseHookData<"user.delete">) {
601
+ return user.email !== "owner@example.com";
602
+ }
603
+
604
+ @AfterDatabase("session.create")
605
+ audit(session: DatabaseHookData<"session.create", "after">) {
606
+ console.log("session", session.userId);
607
+ }
608
+ }
609
+ ```
610
+
611
+ | Decorator | Signature | Return value |
612
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
613
+ | `@BeforeAuth(match?, options?)` | `(ctx: AuthHookContext<P>) => unknown` | `undefined` continues; `{ context }` merges into the endpoint input; any other object short-circuits and becomes the response; a thrown `APIError` rejects |
614
+ | `@AfterAuth(match?, options?)` | `(ctx: AuthAfterHookContext<P>) => unknown` | `undefined` keeps the result; another value replaces `ctx.context.returned`; a thrown `APIError` replaces the response and later hooks still run |
615
+ | `@BeforeDatabase(target, options?)` | `(data: DatabaseHookData<E>, ctx: GenericEndpointContext \| null \| undefined) => DatabaseHookResult<E, "before">` | `create` and `update`: `void`, `false` (abort) or `{ data }` (merged); `delete`: `void` or `false` only |
616
+ | `@AfterDatabase(target, options?)` | `(data: DatabaseHookData<E, "after">, ctx: GenericEndpointContext \| null \| undefined) => void` | ignored; runs after the write commits |
617
+
618
+ A decorated method may declare fewer parameters than the signature and may return a narrower result. `match` is an endpoint route pattern such as `"/callback/:id"` (not the concrete URL), an array of patterns or a predicate `(ctx) => boolean`; without it, the hook runs for every endpoint. Patterns autocomplete from the registered instance and are validated at startup with a "did you mean" (`UNKNOWN_HOOK_PATH`). Database targets are `user`, `session`, `account` and `verification` with `create`, `update` or `delete`.
619
+
620
+ Hook methods receive Better Auth's own context objects. Before-hook bodies are typed `unknown` per field, because Better Auth validates the body only after before-hooks ran. Update payloads are partial, and every `update` hook sees the original payload while `create` hooks see the data earlier hooks returned. A database hook receives `null` or `undefined` as its context outside an endpoint. As in Better Auth, `ctx.setCookie()` and `ctx.setHeader()` in a before-hook have no effect when the endpoint runs; set response cookies and headers in `@AfterAuth()`.
621
+
622
+ `HookOptions` are `calls` (`"all"` by default, `"http"` for requests routed through the mount, `"server"` for direct `auth.api` calls), `skipInternal` (default `false`: hooks also run for the library's own calls, such as the guard's `getSession`; set it for audit hooks), `order` (ascending, default `0`) and `instance`. Database hooks take `order` and `instance`.
623
+
624
+ Security rules belong where every path passes. A `@BeforeAuth("/get-session")` rule sees the original request and fails open for bearer and API-key callers, and an `@AfterAuth("/get-session")` rule applies to Nest routes and direct `getSession` calls only, because Better Auth's own endpoints read the session without dispatching hooks. Rules that must hold everywhere change state Better Auth consults on every path: Better Auth's admin ban, session revocation, or a `@BeforeDatabase("session.create")` hook.
625
+
626
+ ## Direct `auth.api` calls
627
+
628
+ A direct `auth.api.*` call in a handler behaves as in plain Better Auth: its `Set-Cookie` does not reach the caller's response. `service.headersFrom(request)` returns the platform request's headers with the client-IP header set and HTTP/2 pseudo-headers removed, for direct calls on the caller's behalf.
629
+
630
+ **Forwarding cookies.** `@ForwardAuthCookies()` on a handler or controller (and `@ForwardAuthCookies(false)` to opt one handler back out), or `cookies.forwardDirectCalls: true` for a whole instance (`W_FORWARD_DIRECT_CALLS`), forwards the `Set-Cookie` of direct calls that carry no credential, such as a sign-in, or the caller's own credential (session cookie, `Authorization` and the principal sources' credential headers). Calls with another credential have their cookies dropped. `service.forwardForeignCookies(fn)` forwards every call inside `fn`, for deliberate account switching, and throws `FORWARDING_NOT_DECLARED` in a handler without forwarding. Cookies from the library's own calls (principal sources and policies) always reach the caller, so session refreshes during authorization are delivered. The Express and Fastify cookie sinks skip a line identical to the latest line already set for the same cookie, so a source that also appends a call's cookies itself does not deliver them twice. A handler that already sent its response through `@Res()` drops them.
631
+
632
+ A forwarding handler is in form mode: the guard enforces Better Auth's form-CSRF rule before the handler runs, on every HTTP method and GraphQL operation kind, with or without an inbound cookie, and for public and optional handlers too. A forwarding handler therefore needs a guard that reaches it, even when it is public; startup reports forwarding handlers that no guard covers.
633
+
634
+ ```ts
635
+ import { Body, Controller, Inject, Post, Req } from "@nestjs/common";
636
+ import {
637
+ BetterAuthService,
638
+ ForwardAuthCookies,
639
+ getBetterAuthServiceToken,
640
+ OptionalAuth,
641
+ } from "@nestjs-kit/better-auth";
642
+
643
+ @Controller("session")
644
+ export class SessionController {
645
+ constructor(
646
+ @Inject(getBetterAuthServiceToken())
647
+ private readonly auth: BetterAuthService,
648
+ ) {}
649
+
650
+ @OptionalAuth()
651
+ @ForwardAuthCookies()
652
+ @Post("sign-in")
653
+ signIn(
654
+ @Req() request: unknown,
655
+ @Body() body: { email: string; password: string },
656
+ ) {
657
+ return this.auth.api.signInEmail({
658
+ body,
659
+ headers: this.auth.headersFrom(request),
660
+ });
661
+ }
662
+ }
663
+ ```
664
+
665
+ **Rate limits and origin checks on direct calls.** Better Auth applies its rate limiter and its own origin check only to requests routed through `auth.handler`. A direct `signInEmail`, `signUpEmail` or password call from a handler bypasses both. Prefer letting clients call Better Auth's routes through the mount for sign-in, sign-up and password flows, keep direct credential calls for trusted back-office code, and throttle proxy handlers in the application. The form-mode origin rule above protects forwarding handlers against login CSRF; it does not replace the rate limiter.
666
+
667
+ **Caller-session check.** A direct call that carries the caller's own session cookie on a browser leg must have a passing origin verdict for that leg and instance. Otherwise the call fails before the endpoint runs with `PUBLIC_HANDLER_USED_CALLER_SESSION` (a generic 500 with a logged hint). This covers public handlers, which run no origin check, safe methods such as a `GET /logout` proxy, and handlers the guard never ran for. Calls without the caller's session cookie (a sign-up without headers, bearer or API-key calls), calls with a session cookie that the transport mapped in-band rather than the browser leg's own cookie (graphql-ws connection parameters, WebSocket `credentials`), cookie-less transports and `@SkipOriginCheck()` handlers are unaffected. A cross-site state-changing proxy therefore needs a state-changing method, `@OptionalAuth()` or a stricter access mode, `@ForwardAuthCookies()` when it returns auth cookies, and a trusted `Origin`.
668
+
669
+ **Scopes.** The global scope interceptor opens a scope for every handler a transport recognizes, independently of `globalGuard`. The scope carries the cookie bridge, refresh suppression on cookie-less transports and the caller-session check. `globalScope: false` removes it and logs `W_NO_GLOBAL_SCOPE`. Code outside a handler scope (middleware, guards, policies, background jobs, GraphQL field resolvers without the `"interceptors"` enhancer) gets plain Better Auth semantics. `service.withoutCookies(fn)` runs calls with no cookie capability, which also suppresses session refresh, and `service.runOutsideScope(fn)` runs `fn` as if no scope existed: no forwarding, no refresh suppression and no caller-session check, so an unsafe public handler that uses it is open to cross-site requests.
670
+
671
+ ## Origin checks
672
+
673
+ Better Auth's origin check protects cookie-carrying requests on its own routes only. The guard applies the same rule to application routes, GraphQL operations and WebSocket messages through `originCheck`.
674
+
675
+ - **Cookie mode** (`originCheck.mode: "cookie"`, the default). An unsafe operation whose browser leg carries a cookie must come from a trusted origin, whatever credential authenticated it: HTTP methods other than GET, HEAD and OPTIONS, GraphQL mutations over HTTP, every WebSocket message and every GraphQL operation over a WebSocket. Denials are `403` with reason `INVALID_ORIGIN` or `MISSING_OR_NULL_ORIGIN`.
676
+ - **Advisory verdict on safe methods.** In cookie mode, a guarded safe request that carries a cookie also computes the origin verdict, without denying the request. An untrusted origin still reads its data; only a later direct call with the caller's session cookie consults the verdict and fails. Browsers send no `Origin` on same-origin GET requests, and `Referrer-Policy: no-referrer` also removes `Referer`; the advisory verdict then takes the leg's own origin from `Sec-Fetch-Site: same-origin` and checks it against the trusted origins, as it does for `Origin: null`. Enforcing checks keep Better Auth's rule. Public handlers compute no verdict.
677
+ - **Form mode** (forwarding handlers). Always enforced, on every method and operation kind, following Better Auth's form-CSRF rule: with a cookie, the cookie-mode rule; without one, a cross-site navigation is denied with `CROSS_SITE_NAVIGATION_LOGIN_BLOCKED`, a present `Origin` or `Referer` must be trusted, and a request without any browser signal is allowed.
678
+
679
+ Trusted origins are the instance's post-init `trustedOrigins`, including plugin-contributed ones and function-valued `trustedOrigins`, matched with Better Auth's own matcher. As on Better Auth's router, a function-valued `trustedOrigins` contributes only its result for the request, never the no-request result Better Auth computes at startup. The request's own origin counts as trusted only when `baseURL` is unset, never from forwarding headers. A verdict is memoized per browser leg, so a socket evaluates a function-valued `trustedOrigins` once. The first denial per reason and origin within an hour logs a warning with the sanitized origin.
680
+
681
+ **Escapes for non-browser and native clients.**
682
+
683
+ - Server-side callers that forward a user's cookie on unsafe requests, such as an SSR server, send a trusted `Origin` header, as Better Auth's own routes require. Node's built-in `fetch` sends none by default.
684
+ - Machine clients use bearer tokens or API keys without cookies; cookie mode does not inspect them.
685
+ - Native apps that send the session cookie themselves (Better Auth's Expo client calling your routes) send no `Origin`, `Referer` or `Sec-Fetch-*` headers. Either set `originCheck.missingOrigin: "allow-non-browser"`, which admits requests without all three signals, or send `Origin: <app scheme>://` and list it in `trustedOrigins`. With the Expo plugin present and `missingOrigin: "reject"`, startup logs `W_ORIGIN_CHECK_NATIVE_CLIENTS`.
686
+ - `@SkipOriginCheck()` removes the check from a handler or controller that deliberately serves cookie-bearing server-to-server calls, and `originCheck.mode: "off"` removes it from the instance (`W_ORIGIN_CHECK` in production).
687
+
688
+ Better Auth skips its own origin checks under `NODE_ENV=test` or a truthy `TEST` variable, and the kernel follows the same resolved flags (`advanced.disableCSRFCheck`, `advanced.disableOriginCheck`); startup logs `W_ORIGIN_CHECK` whenever they are off. Set `advanced.disableOriginCheck: false` on test instances that exercise origin rules.
689
+
690
+ ## Lifecycle and operational limits
691
+
692
+ **Binding and shutdown.** At `onModuleInit`, each instance's `nestjs()` plugin binds to the application; the binding records the application's hook tables. One live application binds an instance at a time: a second application initialized while the first is bootstrapped fails with `INSTANCE_ALREADY_BOUND`. An application whose `init()` failed never bootstrapped, and the next application takes its binding over with `W_INSTANCE_TAKEN_OVER`; if that application's `init()` was still running, it fails at bootstrap with `INSTANCE_ALREADY_BOUND` instead of serving without its hooks; a closed application's binding is taken over silently. `app.close()` marks the binding closed but keeps its hooks dispatching until another application binds the instance, so work that other modules drain during shutdown, such as queue workers or outbox relays, still runs the application's `@BeforeDatabase()` rules; a hook whose own dependencies already shut down throws, and that Better Auth call fails. Call `app.enableShutdownHooks()` so process signals run this sequence. Better Auth calls and database writes made before the binding, for example seeding in another global module's `onModuleInit`, run without Nest hooks; when the application declares hooks, startup logs `W_CALLS_BEFORE_BIND`. Seed in `onApplicationBootstrap`. Hybrid applications with `deferInitialization: true` initialize the HTTP application (`app.init()` or `app.listen()`) before `app.startAllMicroservices()`.
693
+
694
+ **Revocation and caching.** Identity reads honor Better Auth's cookie cache: when `session.cookieCache` is enabled, a revoked session keeps working on ordinary routes until the cached cookie expires (`session.cookieCache.maxAge`), as in Better Auth. `@RequireAuth({ authoritative: true })`, `session: { freshness: "authoritative" }` or any admin `permission()` requirement read identity past the cache. WebSocket transports (`principalTtlMs`) and GraphQL socket operations (`subscriptionPrincipalTtlMs`) resolve the principal for every message or operation by default; a positive TTL reuses a connection's principal and delays revocation by up to the TTL, except on authoritative plans. A GraphQL subscription is authorized when it starts; events it delivers afterwards are not re-authorized, so revoking a session does not end an open subscription.
695
+
696
+ **Storage outages.** An outage never turns into a 401 or 403 where the library can tell it apart: resolution and policy failures surface as 5xx. Better Auth hides some outages itself:
697
+
698
+ - With the `customSession` plugin, Better Auth catches storage errors inside `getSession`, so an outage during the guard's session read or a policy's re-read answers 401 instead of a 5xx.
699
+ - `verifyApiKey` reports storage failures as `INVALID_API_KEY`. `apiKeyPrincipal()` treats an `INVALID_API_KEY` that took longer than `outageProbe.slowMs` (default 500 ms) as an outage, and otherwise runs a probe (a read and a zero-row write on the key table, at most once per second per instance) before answering 401. Failures confined to the key's own row that return faster than `slowMs`, and keys stored only in secondary storage, still answer 401; set `slowMs` below your pool's lock and statement timeouts and above the cost of a custom key hasher. Better Auth logs `Failed to validate API key` at ERROR for every rejected key.
700
+ - Public routes perform no principal I/O and keep working during an outage.
701
+
702
+ **Coverage checks.** Transports report the handlers they reach, and startup fails when a claimed handler is not covered by `BetterAuthGuard`: an HTTP route that declares access metadata or cookie forwarding while no guard reaches it (`ROUTE_UNGUARDED`, for example under `globalGuard: false` without `@UseBetterAuth()`), every WebSocket message handler without `@UseBetterAuth()` (`GATEWAY_UNGUARDED`), every hybrid microservice handler without explicit coverage unless `inheritAppConfig` is asserted (`RPC_HANDLER_UNGUARDED`), GraphQL field resolvers that declare access metadata without field guards (`FIELD_RESOLVER_UNGUARDED`), and federation reference resolvers (`FEDERATION_FIELD_GUARDS_REQUIRED`, `REFERENCE_RESOLVER_UNGUARDED`). `gatewayCoverage`, `hybridCoverage`, `fieldResolverCoverage` and `federationCoverage` lower the transport checks to `"warn"` or `"off"` for deployments that cover the handlers another way. The transport sections below describe each case.
703
+
704
+ **Boot summary.** With `logSummary` (default `true`), each instance logs one line with its mount, platform and proxy trust, transports, principal sources, hook counts, default access, origin-check mode, handler counts by access, environment, rate-limit state, whether the global scope is on and the order of global guards.
705
+
706
+ ## GraphQL
707
+
708
+ The `./graphql` entry provides `apolloTransport()` for `@nestjs/apollo` and `mercuriusTransport()` for `@nestjs/mercurius`. Queries, mutations, subscriptions and federation reference resolvers use the same access decorators, principal parameters and authorization units as controllers. Only this entry imports `@nestjs/graphql` and `graphql`.
709
+
710
+ Create the Better Auth instance with the construction plugin as shown under [Construct the Better Auth instance](#construct-the-better-auth-instance), then register the GraphQL module and the transport that matches its driver:
711
+
712
+ ```ts
713
+ // app.module.ts
714
+ import { ApolloDriver, type ApolloDriverConfig } from "@nestjs/apollo";
715
+ import { Module } from "@nestjs/common";
716
+ import { GraphQLModule, Query, Resolver } from "@nestjs/graphql";
717
+ import {
718
+ type AuthPrincipal,
719
+ BetterAuthModule,
720
+ CurrentPrincipal,
721
+ Public,
722
+ } from "@nestjs-kit/better-auth";
723
+ import { expressPlatform } from "@nestjs-kit/better-auth/express";
724
+ import { apolloTransport } from "@nestjs-kit/better-auth/graphql";
725
+ import { auth } from "./auth";
726
+
727
+ @Resolver()
728
+ export class ViewerResolver {
729
+ @Query(() => String)
730
+ viewer(@CurrentPrincipal() principal: AuthPrincipal) {
731
+ return principal.userId;
732
+ }
733
+
734
+ @Query(() => String)
735
+ @Public()
736
+ health() {
737
+ return "ok";
738
+ }
739
+ }
740
+
741
+ @Module({
742
+ imports: [
743
+ GraphQLModule.forRoot<ApolloDriverConfig>({
744
+ driver: ApolloDriver,
745
+ autoSchemaFile: true,
746
+ subscriptions: { "graphql-ws": true },
747
+ fieldResolverEnhancers: ["guards", "filters"],
748
+ }),
749
+ BetterAuthModule.forRoot({
750
+ auth,
751
+ platforms: [expressPlatform()],
752
+ transports: [apolloTransport()],
753
+ }),
754
+ ],
755
+ providers: [ViewerResolver],
756
+ })
757
+ export class AppModule {}
758
+ ```
759
+
760
+ For Mercurius, use `MercuriusDriver`, `fastifyPlatform()` from `@nestjs-kit/better-auth/fastify` and `mercuriusTransport()`. `mercuriusSubscriptionContext()` returns a Mercurius `subscription.context` function that keeps the upgrade request and its original browser headers with every socket operation; the socket tests cover Mercurius with and without it. A custom GraphQL `context` must be a function that returns a fresh object for every operation and every graphql-ws connection; a function that returns a cached object is a configuration error that the transports cannot always detect. An HTTP operation whose context carries an answered request fails closed with `AUTH_MISCONFIGURED` (`GRAPHQL_CONTEXT_STALE_REQUEST`). The Apollo transport classifies an operation's graphql-ws socket once, when the operation starts. When that socket is not open, the operation reads no credential of its connection (connection parameters, cookies, `subscriptionCredentials`, upgrade-request headers or the upgrade-request query string) and runs anonymously: required operations answer the unauthenticated denial, and optional and public operations run without a principal. Its request URL, which principal sources receive as `PrincipalRequest.request` and policies as the context's `request`, is the upgrade URL without its query string or fragment, so a source that authenticates a URL query token finds none; the origin check still reads the connection's handshake, which grants no credential. The transport logs nothing at ERROR for it, because a client that closes its connection while graphql-ws prepares an operation leaves the same socket state. An operation whose socket closes after the operation started keeps its connection's credentials. A cached context of a closed connection therefore authenticates nothing. While the earlier request or connection is still open, its context is indistinguishable from the current operation's own, and the operation authenticates with the earlier caller's credentials, so the fresh-object rule is mandatory. Mercurius socket operations take their context from `subscription.context`, which Mercurius builds for every connection, so the GraphQL `context` option does not carry one connection's socket into another. For Apollo HTTP operations, it keeps the platform request at `context.req`: the Express request, or the Fastify request that the context function receives as its first argument, not that request's `raw` `IncomingMessage`. For Apollo socket operations, it either leaves `context.req` unset, so Nest assigns the graphql-ws context, or keeps graphql-ws's `extra` at `context.extra` and the connection parameters at `context.connectionParams`; the upgrade request alone is not recognized. An operation whose context carries neither fails closed with `AUTH_MISCONFIGURED` (`GRAPHQL_CONTEXT_UNRECOGNIZED`). Startup fails with `GRAPHQL_STATIC_CONTEXT` for a static context object and with `GRAPHQL_DRIVER_MISMATCH` when the transport does not match the configured driver.
761
+
762
+ ### Operations and fields
763
+
764
+ Each root field of an operation is authorized independently, so aliases and batched operations can share one session read while receiving different organization decisions. A denied field produces a GraphQL error whose `extensions` contain `code`, `statusCode` and, where applicable, `reason`. Authentication infrastructure failures surface as `Internal server error` with `code: "INTERNAL_SERVER_ERROR"` and `reason` `AUTH_UNAVAILABLE` or `AUTH_MISCONFIGURED`; the underlying error never reaches the client. Better Auth's browser-origin check applies to HTTP mutations and to every socket operation.
765
+
766
+ Field resolvers without access decorators inherit the principal of their actual ancestor operation, including across named instances: the reading comes from the operation's lineage, not from sibling fields, and does not depend on the order in which their guards settle. Class-level access metadata does not apply to field resolvers; method-level metadata gives a field resolver its own plan. Protecting field and reference resolvers requires Nest to run guards on them: set `fieldResolverEnhancers: ["guards", "filters"]`, and add `"interceptors"` when those fields use scoped service readers. With `"guards"` enabled, Nest runs every global guard once per field invocation, and the boot advice `W_FIELD_GUARDS_MULTIPLY_GLOBAL_ENHANCERS` names the affected guards and interceptors. A field resolver that declares access metadata without field guards fails startup with `FIELD_RESOLVER_UNGUARDED`; `fieldResolverCoverage: "warn" | "off"` relaxes that check. Without `"filters"`, Nest does not route field errors through its exception pipeline, so they are not logged; the library then emits `W_FIELD_EXCEPTION_FILTERS_DISABLED`. With filters enabled, repeated infrastructure and reader errors produce one Nest ERROR per logical request while every affected field receives a safe error.
767
+
768
+ `@CurrentSession()` on a field resolver under an operation that admits other principal kinds receives the enclosing principal at run time, so it fails with `SESSION_REQUIRED` for an API-key principal; startup warns with `W_NESTED_PRINCIPAL_PARAM`. Use `@CurrentPrincipal()` and narrow `kind` there. `limits.maxAuthorizationCallsPerRequest` bounds the distinct policy calls of many aliased fields, but not their concurrency; keep GraphQL depth or complexity limits in place.
769
+
770
+ ### Subscriptions and socket operations
771
+
772
+ Socket operations authenticate each operation independently from the WebSocket upgrade request and never refresh the session. A subscription is authorized when it starts, and the events it delivers are not re-authorized. Browser-origin checks always use the original upgrade headers, before any session read, so connection parameters cannot replace the browser's `Origin`. A session cookie that arrives in connection parameters is not ambient, so a resolver's direct `auth.api` call that carries it needs no origin verdict; a call that carries the upgrade request's own session cookie does. The platform's own request predicate classifies HTTP operations first, and Apollo recognizes a socket operation only through the graphql-ws connection and its WebSocket. A client-sent `Upgrade: websocket` header therefore never selects the socket path: an HTTP operation keeps the platform client IP, response cookie forwarding and the completed-request check, and an unrecognized context still fails closed. Mercurius treats an operation whose context carries the route's own Fastify reply as HTTP before any socket detection, and otherwise identifies sockets only through its subscription context, so neither request headers nor client fields spread into a custom context select the socket path.
773
+
774
+ By default, `authorization` and `cookie` values in the `connection_init` payload replace the matching upgrade headers. `connectionParamHeaders` replaces that list of keys, matched case-insensitively. A `connection_init` message without a payload, which a graphql-ws `createClient()` without `connectionParams` sends, carries no connection parameters: the upgrade request's own credential headers apply, and a connection without them is anonymous. A selected value that is not a string or is not a valid header value rejects the operation with `UNAUTHENTICATED` and `reason: "MALFORMED_CREDENTIALS"`, without falling back to another credential and without echoing the value in errors or logs.
775
+
776
+ `subscriptionCredentials(connectionContext)` replaces this mapping. The `HeadersInit` it returns becomes the operation's credentials, and `host`, `x-forwarded-host` and `x-forwarded-proto` are copied from the upgrade request when absent. Returning `undefined` presents no credentials, and `connectionParamHeaders` is not consulted. Invalid header values reject with `MALFORMED_CREDENTIALS`; errors thrown by the function are application errors, not credential denials.
777
+
778
+ `subscriptionPrincipalTtlMs` reuses a connection's principal for that many milliseconds. The default `0` resolves the principal for every socket operation. A positive value delays the effect of session revocation on open connections by up to the TTL; routes with `@RequireAuth({ authoritative: true })` always resolve a fresh principal.
779
+
780
+ ### Federation
781
+
782
+ Both transports support `ApolloFederationDriver` and `MercuriusFederationDriver` with schema-first schemas and code-first federation versions 1 and 2. `@ResolveReference()` resolvers are always checked: without field guards, startup fails with `FEDERATION_FIELD_GUARDS_REQUIRED` or `REFERENCE_RESOLVER_UNGUARDED`, and `federationCoverage` relaxes the check. `federationCoverage: "warn"` suits a subgraph that only an authenticating router can reach. Field resolvers under an entity inherit the reading of its type's reference resolver.
783
+
784
+ Federation schema generation in `@nestjs/graphql` loads `@apollo/subgraph`, which the install commands above do not include. Federation applications must install a compatible version, for example `pnpm add @apollo/subgraph@^2.15.1`; otherwise startup fails before authentication runs. `@apollo/subgraph` 2.15 requires `@nestjs/graphql` 14.0.2 or newer, as described under [Supported versions](#supported-versions).
785
+
786
+ ### Supported versions
787
+
788
+ The native GraphQL tests run Nest 12.0.3 with `@nestjs/graphql`, `@nestjs/apollo` and `@nestjs/mercurius` 14.0.2, `graphql` 16.14.2, `@apollo/server` 5.5.1, `mercurius` 16.10.0, `@mercuriusjs/federation` 5.1.1 and `@apollo/subgraph` 2.15.1. Code-first federation with `@apollo/subgraph` 2.15 requires `@nestjs/graphql` 14.0.2 or newer. With `@nestjs/graphql` 14.0.1, schema generation fails before authentication runs: federation 1 cannot load the subgraph directives module that 2.15 removed, and federation 2 fails with `TypeError: doc.definitions is not iterable`. The `@nestjs/graphql` peer range therefore starts at 14.0.2.
789
+
790
+ ## WebSocket gateways
791
+
792
+ The `./websockets` entry authenticates Socket.IO and raw `ws` gateways. Register `socketIoTransport()` or `wsTransport()` in the module's `transports`, and decorate every gateway with `@UseBetterAuth()` so both the guard and the invocation scope run for its message handlers. Startup fails with `GATEWAY_UNGUARDED` when a message handler lacks that coverage; `@Public()` opts a gateway or handler out, and the transports' `gatewayCoverage` option downgrades the check to `"warn"` or `"off"`. Message handlers use the same access, session and authorization decorators as controllers.
793
+
794
+ ```ts
795
+ import { Inject, Module } from "@nestjs/common";
796
+ import { SubscribeMessage, WebSocketGateway } from "@nestjs/websockets";
797
+ import type { Server } from "socket.io";
798
+ import {
799
+ BetterAuthModule,
800
+ CurrentSession,
801
+ RequireAuth,
802
+ UseBetterAuth,
803
+ } from "@nestjs-kit/better-auth";
804
+ import { expressPlatform } from "@nestjs-kit/better-auth/express";
805
+ import {
806
+ socketIoTransport,
807
+ WS_CONNECTION_AUTH,
808
+ type WsConnectionAuth,
809
+ } from "@nestjs-kit/better-auth/websockets";
810
+ import { auth } from "./auth"; // betterAuth({ plugins: [bearer(), nestjs()], ... })
811
+
812
+ @WebSocketGateway()
813
+ @UseBetterAuth()
814
+ export class EventsGateway {
815
+ constructor(
816
+ @Inject(WS_CONNECTION_AUTH)
817
+ private readonly connectionAuth: WsConnectionAuth,
818
+ ) {}
819
+
820
+ afterInit(server: Server) {
821
+ // Optional: reject unauthenticated handshakes before any message arrives.
822
+ server.use(this.connectionAuth.socketIoMiddleware({ required: true }));
823
+ }
824
+
825
+ @RequireAuth()
826
+ @SubscribeMessage("whoami")
827
+ whoami(@CurrentSession() session: { user: { email: string } }) {
828
+ return { email: session.user.email };
829
+ }
830
+ }
831
+
832
+ @Module({
833
+ imports: [
834
+ BetterAuthModule.forRoot({
835
+ auth,
836
+ platforms: [expressPlatform()],
837
+ transports: [socketIoTransport()],
838
+ }),
839
+ ],
840
+ providers: [EventsGateway],
841
+ })
842
+ export class AppModule {}
843
+ ```
844
+
845
+ Socket.IO reads credentials from the handshake headers. When the handshake has no `Authorization` header, a string `auth.token` becomes `Authorization: Bearer <token>`, which Better Auth's `bearer()` plugin accepts. Raw `ws` reads the upgrade request, so wrap Nest's adapter to record it: `app.useWebSocketAdapter(new (withUpgradeRequest(WsAdapter))(app))`. Without that record, public messages still run and protected messages fail with a 500 `WS_UPGRADE_REQUEST_MISSING` configuration error.
846
+
847
+ A `credentials(client)` option on either transport replaces the default mapping: the entries of the `HeadersInit` it returns override the handshake headers of the same name for authentication. The browser-origin check always uses the original handshake, so mapped credentials cannot bypass it. A mapped session cookie is not ambient, so a handler's direct `auth.api` call that carries it needs no origin verdict; a call that carries the handshake's own session cookie does. A non-string `auth.token`, or mapped credentials that are not string pairs or contain CR, LF, NUL or other invalid header characters, fail with a 401 `MALFORMED_CREDENTIALS` before any Better Auth call. Other credentials on the connection are not tried in their place, and the failure does not carry the rejected value. Errors thrown by the mapper itself are not converted into authentication failures.
848
+
849
+ Connection authentication runs the same origin check and default credential sources as messages, for the default instance or the one named by `instance`. `socketIoMiddleware({ required, instance })` rejects a Socket.IO handshake with a `connect_error` whose `data` holds `statusCode`, `code` and `reason`; `required: false` admits connections without credentials but still rejects invalid ones. Unexpected errors reach the client as a 500 `Internal server error`, and the server logs a summary that omits foreign error messages because they can quote credentials. For raw `ws`, call `authenticate(client)` in `handleConnection` and close rejected connections with `wsCloseCodeFor(result.failure)`, which maps 401, 403 and 429 failures to close codes 4401, 4403 and 4429.
850
+
851
+ Principal caching is off by default. A positive `principalTtlMs` reuses a successful authentication on the same connection and therefore delays revocation for ordinary handlers; handlers declared with `@RequireAuth({ authoritative: true })` always revalidate. Neither message nor connection authentication refreshes cookies. A quota-consuming principal source such as `apiKeyPrincipal()` verifies on every message unless a TTL is set, which startup reports as `W_QUOTA_PER_MESSAGE`.
852
+
853
+ Tests run real Socket.IO 4.8.3 and ws 8.21.3 clients against Nest 12.0.3, where Nest delivers raw `ws` authentication failures as native `exception` messages.
854
+
855
+ ## RPC authentication
856
+
857
+ The `./microservices` entry authenticates messages in standalone and hybrid Nest microservices. `rpcTransport()` reads credentials from the transport's native carrier: gRPC metadata, NATS headers, Kafka headers, RabbitMQ message headers and MQTT 5 user properties. TCP and Redis messages have no header carrier, so they send credentials in a payload envelope field, `auth` by default. Every message is authenticated separately.
858
+
859
+ ```ts
860
+ import { Controller, Module } from "@nestjs/common";
861
+ import { NestFactory } from "@nestjs/core";
862
+ import {
863
+ MessagePattern,
864
+ Transport,
865
+ type MicroserviceOptions,
866
+ } from "@nestjs/microservices";
867
+ import { betterAuth } from "better-auth";
868
+ import { bearer } from "better-auth/plugins";
869
+ import { BetterAuthModule, CurrentUser, Public } from "@nestjs-kit/better-auth";
870
+ import { rpcTransport } from "@nestjs-kit/better-auth/microservices";
871
+ import { nestjs } from "@nestjs-kit/better-auth/plugin";
872
+
873
+ const auth = betterAuth({
874
+ // RPC messages have no HTTP origin, so the base URL must be static.
875
+ baseURL: "https://auth.example.com",
876
+ // Add your database adapter and other Better Auth options.
877
+ plugins: [bearer(), nestjs()],
878
+ });
879
+
880
+ @Controller()
881
+ class ProfileController {
882
+ @MessagePattern("profile.get")
883
+ profile(@CurrentUser() user: { id: string }) {
884
+ return { userId: user.id };
885
+ }
886
+
887
+ @Public()
888
+ @MessagePattern("health")
889
+ health() {
890
+ return { ok: true };
891
+ }
892
+ }
893
+
894
+ @Module({
895
+ imports: [BetterAuthModule.forRoot({ auth, transports: [rpcTransport()] })],
896
+ controllers: [ProfileController],
897
+ })
898
+ class AppModule {}
899
+
900
+ const app = await NestFactory.createMicroservice<MicroserviceOptions>(
901
+ AppModule,
902
+ { transport: Transport.TCP, options: { host: "127.0.0.1", port: 4000 } },
903
+ );
904
+ await app.listen();
905
+ ```
906
+
907
+ A TCP client puts credentials in the envelope, for example `{ auth: { authorization: "Bearer <token>" }, id: 1 }`; a NATS client sets an `authorization` header through `NatsRecordBuilder`. Carriers accept only the `authorization`, `cookie` and `x-api-key` fields by default. Pass carrier factories such as `grpcCarrier({ metadata: ["authorization"] })` or `payloadCarrier({ field: "credentials" })` in `rpcTransport({ carriers })` to change the accepted fields or the envelope. Host, forwarding and `set-cookie` metadata never reach Better Auth, so a broker message cannot claim an HTTP origin.
908
+
909
+ Configure a static Better Auth base URL, or a fallback for a dynamic one: RPC transports have no request host, and startup fails with `DYNAMIC_BASE_URL_WITHOUT_FALLBACK` otherwise. The admin `permission()` policy calls Better Auth without headers and has the same requirement. Message authentication never refreshes or sets cookies. Public messages run without credentials; protected messages without a matching carrier are rejected as unauthenticated. Malformed credential values, such as values containing CR, LF or NUL, are rejected with reason `MALFORMED_CREDENTIALS` without reaching Better Auth, the handler or logs.
910
+
911
+ In a hybrid application, Nest applies global guards to microservice handlers only when `connectMicroservice()` receives `{ inheritAppConfig: true }`. Either pass that option to every `connectMicroservice()` call and pass `rpcTransport({ inheritAppConfig: true })`, or apply `@UseBetterAuth()` to message controllers and `@Public()` to public handlers. Startup verifies the explicit form and fails with `RPC_HANDLER_UNGUARDED` for an uncovered message or event handler; `hybridCoverage: "warn"` or `"off"` lowers that check. The inheritance option is an assertion that the application passes the same flag to Nest, and startup logs the `W_RPC_INHERIT_APP_CONFIG_ASSERTED` warning for it.
912
+
913
+ Failures become `RpcException` payloads with `statusCode`, `code` and, where present, `reason`. gRPC calls receive native status codes: 16 for authentication, 7 for authorization, 8 for throttling and 13 for redacted infrastructure failures. End-to-end tests run real TCP and gRPC servers and real NATS, Kafka, RabbitMQ, MQTT 5 and Redis brokers against Nest 12.0.3, including hybrid applications with both coverage modes and default plus named instances.
914
+
915
+ ## Testing helpers (`./testing`)
916
+
917
+ The helpers replace one collaborator at a time in a `TestingModuleBuilder`, so the rest of the pipeline stays real.
918
+
919
+ ```ts
920
+ import { Test } from "@nestjs/testing";
921
+ import { allow } from "@nestjs-kit/better-auth";
922
+ import {
923
+ initTestApp,
924
+ overrideAuthGuard,
925
+ overrideDecisions,
926
+ overridePrincipal,
927
+ stampPrincipal,
928
+ } from "@nestjs-kit/better-auth/testing";
929
+
930
+ // Fixed principal: acceptance, the origin check and policies still run; Better Auth is not called for resolution.
931
+ const builder = overridePrincipal(
932
+ Test.createTestingModule({ imports: [AppModule] }),
933
+ {
934
+ kind: "session",
935
+ source: "better-auth:session",
936
+ userId: "u1",
937
+ session,
938
+ },
939
+ );
940
+
941
+ // Stub a policy decision; returning undefined runs the real policy.
942
+ overrideDecisions(
943
+ builder,
944
+ (policyId) =>
945
+ policyId === "better-auth:organization/permission" ? allow() : undefined, // gitleaks:allow: public policy identifier
946
+ );
947
+
948
+ // Replace the guard everywhere: the global APP_GUARD and every @UseBetterAuth()/@UseGuards(BetterAuthGuard) site.
949
+ overrideAuthGuard(builder, {
950
+ canActivate(ctx) {
951
+ stampPrincipal(ctx, testUser); // publishes the principal to @CurrentPrincipal(), @CurrentSession() and the service
952
+ return true;
953
+ },
954
+ });
955
+
956
+ // Inside a Vitest test or beforeEach: onTestFinished closes this app when the test ends (node:test: t.after).
957
+ const app = await initTestApp(
958
+ (await builder.compile()).createNestApplication(),
959
+ { closeWith: onTestFinished },
960
+ );
961
+ ```
962
+
963
+ - `overridePrincipal(builder, principal, { instance })` overrides `PRINCIPAL_RESOLVER` for one instance (default `'default'`). A policy's re-classification read of a generic 401 answers absent, so organization policies deny 401 under a fixed principal; stub them with `overrideDecisions` or use real sessions from `authHeadersFor`. The admin policy judges the stored user row of the fixed `userId`.
964
+ - `overrideAuthGuard(builder, guard | { principal })` overrides `GUARD_CORE` and `SCOPE_CORE` once each. `overrideProvider(BetterAuthGuard)` and `overrideGuard(BetterAuthGuard)` cannot be combined: `TestingModuleBuilder` keeps one override per token, so one set of call sites would keep the real guard. An object with a `canActivate` method always runs as the guard, even when it also has a `principal` property. The stand-in scope reads the test stamp first; `{ principal }` stamps for the handler's instance, including named instances.
965
+ - `stampPrincipal(ctx, principal, { instance })` records the principal on the invocation's argument array and its object elements. Readers accept it from the same array (WebSocket, RPC and GraphQL guards and interceptors share it) or from another array with the same elements when at least two elements are objects (an HTTP request and its response). A lone shared element, such as a socket beside a primitive payload, never identifies an invocation, so concurrent invocations do not see each other's stamps. A stand-in that stamps nothing makes principal readers fail with the generic `NO_AUTH_RESULT` error.
966
+ - `testPrincipal(principal | (request) => principal | null, { kinds, acceptance, delegates })` is a principal source for composed test modules.
967
+ - `authHeadersFor(auth, userId)` creates real session headers through Better Auth's `testUtils()` plugin.
968
+ - `initTestApp(app, { closeWith })` runs `init()` and Fastify `ready()`. A Better Auth instance binds to one application at a time (a second bound application fails with `INSTANCE_ALREADY_BOUND`), so the helper first closes the previous application that it initialized for the same instance; a failed `init()` leaves its binding to the next application, which takes it over with `W_INSTANCE_TAKEN_OVER`. An application whose `init()` is still running when another application binds the same instance fails at bootstrap with `INSTANCE_ALREADY_BOUND`, so initialize applications that share an instance one after another, including in concurrent tests. The helper makes `app.close()` idempotent and passes it to `closeWith`, a per-test cleanup such as Vitest's `onTestFinished` or node:test's `t.after`. Jest cannot register hooks while a test runs, so collect the closes and run them in a suite-level `afterEach`. Without `closeWith`, close the last application yourself. On Express, compile one `TestingModule` per application; a second application from the same module fails with `APP_ADAPTER_CHANGED`.
969
+
970
+ Better Auth treats a process with `NODE_ENV=test` or a truthy `TEST` variable (Vitest sets it; Jest sets `NODE_ENV=test`) as a test run and then skips its origin checks, and this library's origin check follows the same flags. Set `advanced.disableOriginCheck: false` on test instances that exercise origin rules.
971
+
972
+ ## Conformance kits (`./testing/conformance`)
973
+
974
+ The kits verify extension units against the invariants of the [reviewed specification](docs/design/design-v7.md) (§4 and §14.1). Each kit returns `ConformanceCase[]` (`{ id, title, skip?, run() }`) that `runConformance(cases, { describe, it })` registers with Vitest, Jest or `node:test`. Assertions use `node:assert/strict`; the kits import no test runner.
975
+
976
+ Applicability comes from the unit, not from the options a harness passes. A platform that declares `capabilities.http2` needs the `http2` option. The platform kit's trusted-hop rows need `trustOneProxy`, which trusts one forwarding hop on a created application (Express `app.set("trust proxy", 1)`), or `createTrustingHttpAdapter` (and `http2.createTrustingHttpAdapter` for their HTTP/2 rows), which returns a fresh adapter that already trusts it, for platforms that fix proxy trust at construction (Fastify `new FastifyAdapter({ trustProxy: "loopback" })`; the kit's requests come from loopback). Its microservice rows of `H-no-adapter` need `microservice`, which returns the transport options to boot with (for example `{ transport: Transport.TCP, options: { host: "127.0.0.1", port: 0 } }`); the kit does not load the optional `@nestjs/microservices` peer itself. The production-mode rows set `process.env.NODE_ENV` to `production` while they run and restore it afterwards, so run the kit's cases sequentially. A transport with a lineage needs `invokeTwice`, one that nests handlers (`defaultAccessFor`) needs `invokeGraph`, and one that claims the kit's federation reference resolver needs `invokeEntities`. A transport whose browser leg is a connection's handshake, shared by the connection's messages (the WebSocket transports), runs the `T-ws-origin-*` cases instead of the `T-csrf-*` cases and needs `invokeConnection`, plus `authenticateConnection` when the app provides `WS_CONNECTION_AUTH`. Such a case fails when the helper is missing, and `T-selection` fails `expectBrowserLeg: false` for a transport that describes a browser leg. A transport that nests handlers needs `graphqlContexts` to list `'static'` and `'cached'` for `T-stale-context`. Its cached-context row closes the first caller's request or connection before a later caller runs. After a completed request, it expects the later caller's own principal or `GRAPHQL_CONTEXT_STALE_REQUEST`. Where the browser leg is a connection's handshake, it runs through `invokeConnection`, which resolves only after the server has observed the connection's close, and expects the later caller's own principal, an anonymous answer or the unauthenticated denial, never the first caller's principal, with nothing logged at ERROR; the custom-context rows of `T-subscription-origin` run for the listed `'fresh'` and `'plain-request'` contexts. The `T-subscription-*` cases run when `invocationShapes` includes `'subscriptions'` and need `invokeConnection` with its `connectionParams` option. `T-inherited-handler` runs with `invokeInherited`; harnesses of transports that address a handler by a name one inherited method shares across subclasses (an RPC pattern, a GraphQL root field) omit it, and the case is skipped with the reason. A case whose optional capability is absent is skipped with the reason. The kits read these members from unit objects before running; for classes and `defineExtension()` definitions they boot once and read the resolved unit. A case known to be inapplicable registers with its reason in the test name. A case that finds out at run time resolves with `{ skipped: reason }`, which `runConformance` reports through the runner's `context.skip(reason)` (Vitest, `node:test`; Jest has no runtime skip, so the test passes).
977
+
978
+ ```ts
979
+ import { ExpressAdapter } from "@nestjs/platform-express";
980
+ import { describe, it } from "vitest";
981
+ import {
982
+ createConformanceAuth,
983
+ httpPlatformConformance,
984
+ policyConformance,
985
+ principalSourceConformance,
986
+ runConformance,
987
+ } from "@nestjs-kit/better-auth/testing/conformance";
988
+
989
+ runConformance(
990
+ httpPlatformConformance({
991
+ platform: myPlatform(),
992
+ createHttpAdapter: () => new ExpressAdapter(),
993
+ }),
994
+ { describe, it },
995
+ );
996
+
997
+ const auth = createConformanceAuth({ plugins: [myPlugin()] });
998
+ runConformance(
999
+ principalSourceConformance({
1000
+ source: myTokenSource(),
1001
+ auth,
1002
+ credentials: {
1003
+ valid: issueToken,
1004
+ invalid: () => new Headers({ authorization: "Bearer invalid" }),
1005
+ },
1006
+ }),
1007
+ { describe, it },
1008
+ );
1009
+ ```
1010
+
1011
+ | Kit | Unit | Covers |
1012
+ | ---------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1013
+ | `httpPlatformConformance` | `HttpPlatform` | Mounting (including a dynamic `allowedHosts` base URL), byte-exact bodies, limits with host CORS, URL and header facts, client IP (production-mode per-IP rate-limit buckets, Better Auth `trustedProxies`), HTTP/2, write-back, request accessors, error delivery, lifecycle (no adapter, including microservices, late adapter, reused `TestingModule`, double `forRoot`, hooks kept after `close()`), diagnostics (404 hints, `W_PROXY_UNTRUSTED`) and log hygiene |
1014
+ | `transportConformance` | `AuthTransport` | Selection, credentials, per-request memoization, per-invocation decisions, acceptance, cookie forwarding or refresh suppression, origin and form-CSRF rules including forwarding on safe operations and inherited forwarding fields, `@SkipOriginCheck()` and `disableCSRFCheck` opt-outs, application-route checks under Better Auth's boolean origin skip, direct caller-session calls, WebSocket handshake origins on every message and at connection time (untrusted, missing and forwarded origins, junk tokens, dynamic base URLs, function-valued `trustedOrigins`), subscription credentials and origins, error shapes, logging (one ERROR entry per request and distinct reader configuration error across GraphQL list items), the exact coverage report of the transport's claims, metadata canaries, `globalScope: false` and its boot summary line, readings with and without the scope interceptor, public handlers in unclaimed contexts, inherited handlers, dynamic base URLs, stale GraphQL contexts, mixed-kind session reads, nested and federation readings, lazy scope construction and fail-closed extraction |
1015
+ | `principalSourceConformance` | `PrincipalSource` | Rejection without throwing, storage outages (resolved directly and through a guarded HTTP route), delivery of every own-credential `Set-Cookie` exactly once (to the cookie sink and to an HTTP client, without other headers of those calls), refresh suppression, clean absence, principal shape, declared credential headers, `sessionBacked` and `delegates` declarations, dynamic base URLs (direct and behind a trusted proxy), credential redaction in logs and raw causes; for API-key sources the verification results, outage and write-outage probes and organization-owned keys; for session-backed sources short-circuited session reads and the cookie bridge (third-party sign-up, foreign credentials); for sources of the `jwt` plugin the issuer and audience claims and key-set outages |
1016
+ | `policyConformance` | `AuthorizationPolicy` | Deny values, storage outages with the cookie cache off and warm, APIError mapping and re-classification, sessions lost before the policy's call, delegation scope, unnamed-kind gating, per-invocation decisions with shared I/O, request-scoped class policies, class policies' `requires` fields, plugin prerequisites, and the admin, organization and API-key rows below; requirements name policy objects (construct a DI policy with its dependencies) |
1017
+
1018
+ The principal-source kit passes each credential factory the instance the case runs against: `credentials.valid(auth)` receives `auth`, or for `S-dynamic-base-url` a variant with a dynamic `baseURL` (allowed hosts `localhost:3000` and `public.example`, no fallback) that `variant(overrides)` builds. The default variant is `createConformanceAuth()` with the plugins of `auth`; a call that creates a credential on the variant passes a `host` header. `credentials.apiKey(auth, fields)` creates the keys of the `S-apikey-*` cases (default: `auth.api.createApiKey` for a new user), `credentials.organizationKey(auth)` enables `S-apikey-org-key`, and `http: { platform, createHttpAdapter }` enables the guarded route of the HTTP-route rows of `S-cookie-forwarded` and `S-infra-throws` and the `S-bridge-*` routes. Each case boots its own module, so register a source that keeps state across requests, such as the API-key outage probe or a JWT key cache, as a `defineExtension()` factory.
1019
+
1020
+ Every case boots real Nest applications or modules with a real Better Auth instance that includes `conformanceProbePlugin()`. The probe counts and faults every operation of the instance's database adapter, so storage outages reach endpoints that would swallow them, and records the `Set-Cookie` lines each call produces. `createConformanceAuth({ plugins })` builds such an instance: memory adapter, `testUtils()`, `bearer()`, the probe plugin, `nestjs()` last, and `session.updateAge: 0` and origin checks on (`advanced.disableOriginCheck: false` and `advanced.disableCSRFCheck: false`), whatever `options` says. Its second argument, `ConformanceAuthSettings`, sets those two flags explicitly: `createConformanceAuth(options, { disableOriginCheck: true, disableCSRFCheck: false })` builds the instance of the `T-csrf-http-unsafe` row in which Better Auth skips origin checks on its own routes while application routes stay checked. The principal-source and policy kits take that instance as `auth`; the principal-source kit rejects an instance without `session.updateAge: 0`. The transport kit creates its own instance, with the organization plugin and the organizations its `org` fixture names, and passes it to your `createApp` harness together with the kit's API-key source in `principals`, a `logger` to boot with, and the `globalScope`, `appEnhancers`, `forwardDirectCalls`, `logSummary`, `fieldResolverEnhancers` (including `'filters'`), `fieldResolverCoverage`, `federation` and `graphqlContext` settings of each boot. GraphQL harnesses serve `graph.roots.items`, when given, as a list of `FixtureNode`, and the `organizationReader` and `serviceSession` fields as `String` fields. Apply every fixture decorator, including the kit's `SetMetadata()` marker, which maps the transport's claims to fixtures, and the class decorators of `inherited.subclasses` and `graph.classDecorators`. On a connection-shaped browser leg, the kit's cookie credential carries the instance's base URL, `http://localhost:3000`, as `Origin`, as a browser page of that origin does; the `T-ws-origin-*` cases pass their own `Origin`, `Host` and forwarding headers, which the harness sends in the handshake.
1021
+
1022
+ The policy kit judges principals through the real evaluator, and cases that need route planning, acceptance or principal resolution send in-process requests through the real `BetterAuthGuard`. Pass the sources of the requirement's other principal kinds in `sources` (for example `apiKeyPrincipal()`). Unit-specific cases run when the requirement contains the built-in admin policy (`Z-admin-*`), an organization policy (`Z-org-ref-types` and the organization row of `Z-apikey-quota-per-request`) or the API-key permission policy (its `Z-apikey-quota-per-request` row), and skip with the reason otherwise. They use the instance's configuration where it matters: `session.cookieCache.enabled` for the warm-cache rows and `apiKey({ enableSessionForAPIKeys: true })` for the API-key session and quota rows. The kit builds the instances that need another configuration (`customSession` role shapes, `adminUserIds`, a dynamic `baseURL`, an instance without `apiKey()`) with `variant(overrides)`, which defaults to `createConformanceAuth(overrides)`. A storage job passes a variant that builds the same instance on its own database: add what `createConformanceAuth()` adds (`testUtils()`, `bearer()`, `conformanceProbePlugin()`, `nestjs()` last, `session.updateAge: 0` and `advanced.disableOriginCheck: false`), and migrate the schema before returning it.
1023
+
1024
+ `deliveries` also sends the transport-specific rows through real transports. A `PolicyDelivery` has these members:
1025
+
1026
+ - `name` names the delivery in case titles.
1027
+ - `createApp(handlers, auth, { principals, logger })` boots an app with `globalGuard` that exposes each kit handler (a `FixtureHandler`, as in `transportConformance`) under its name: a gateway message with explicit coverage, a message pattern or a GraphQL query field. The kit's only named input, `orgId`, keeps its JSON type; a GraphQL harness declares it as a JSON scalar argument.
1028
+ - `invoke(app, handler, headers, input)` sends one logical request.
1029
+ - Connection transports add `connect(app, headers)`, which opens one connection whose `invoke()` sends one message, or one subscription operation for GraphQL, after the previous one answered. They also add `connectionPrincipalTtlMs`, the transport's `principalTtlMs` or `subscriptionPrincipalTtlMs`.
1030
+
1031
+ The kit sends the probe plugin's trusted `Origin` with every delivered request. For each delivery it adds these cases, titled `<name>: …`:
1032
+
1033
+ - `Z-admin-rejects-api-key-session`
1034
+ - `Z-org-ref-types`, with junk `orgId` values in the transport's payload
1035
+ - `Z-apikey-quota-per-request`, the API-key session setup
1036
+ - `Z-admin-banned` on one connection. It runs when `connect` and a positive `connectionPrincipalTtlMs` are present, and first checks that a default-freshness handler reuses the connection's principal after sign-out.
1037
+
1038
+ This repository runs the platform kit against Express and Fastify, including the Fastify HTTP/2 adapter for the HTTP/2 rows; the transport kit against the built-in HTTP transport, an in-process reference transport, Socket.IO and `ws` (each with and without a principal TTL), the RPC transport over TCP (standalone and hybrid), gRPC, NATS, RabbitMQ, MQTT, Redis and Kafka, and the Apollo (Express and Fastify) and Mercurius transports over HTTP and over graphql-ws (with and without a subscription principal TTL; Mercurius also without `mercuriusSubscriptionContext()`), with federation 2 subgraphs and a federation 1 run of `T-reference-resolver`; the principal-source kit against the session source (its HTTP-route and `S-bridge-*` rows on Express and Fastify), the API-key source with user-owned and organization-owned keys, and the design's JWT source sketch with derived and explicit claims; and the policy kit against the admin, organization (also with `customSession`), API-key and fresh-session policies. The policy kit's delivery rows run over Socket.IO and `ws` with a 300 s principal TTL, over RPC over TCP (standalone and hybrid), and over the Apollo (Express and Fastify) and Mercurius transports over HTTP and over graphql-ws with a 300 s subscription principal TTL. `Z-admin-null-role` also runs on PGlite with Better Auth's Kysely adapter and migrations. gRPC skips `T-error-shape`, because it delivers only a status and a message. The kit reads each call's browser leg while its invocation runs, because the GraphQL transports reject a completed request. Mutation tests confirm that each of these defects fails the intended case: a platform merging `Set-Cookie`, handing Better Auth no client IP, ignoring its proxy trust, dropping the request host or stripping forwarding headers, a kernel that unbinds hooks on `close()`, reports taking over a closed binding or keeps a microservice's binding open, a transport sharing invocation state across aliases, a transport without a browser leg on safe operations, a `defineExtension()` transport with a lineage or nesting and no helper, a WebSocket transport that enforces its handshake like a safe read, exempts handshakes that carry a token, builds its leg URL from the bare handshake path or from forwarded headers, or answers internal errors with their cause, a GraphQL transport whose aliases share one invocation, whose lineage gives every field of an operation one position, whose field resolvers do not inherit their root's access, or that serves reference resolvers without field guards, a GraphQL harness that omits `invokeGraph`, `invokeEntities` or `invokeConnection`, a transport that claims every context type, runs no metadata canary, claims classes without handlers or drops the `Host` header without declaring hostless calls, a planner that caches plans by handler function, ignores `@SkipOriginCheck()` or skips the origin check of safe operations, a kernel that treats Better Auth's boolean origin skip as its own, a harness that boots without the summary `T-coverage-claims` asks for, an HTTP transport whose invocation is not the handler's request (Fastify), a WebSocket transport whose messages on one socket share their invocation, a GraphQL transport that treats a completed request as live or keeps using the credentials of a graphql-ws connection that closed before an operation started, ignores `connectionParams` credentials, treats socket operations as safe reads, exempts `connectionParams` tokens, serves inherited forwarding fields without field guards, omits `W_FIELD_RESOLVER_INHERITS` or `I_CLASS_METADATA_OPERATIONS_ONLY`, a planner that applies class-level acceptance to field resolvers or records a reading on a form-mode nested public field, a source throwing its denial, a source dropping its refresh cookie, a source turning a storage outage into a 401, API-key sources that report every failure as an invalid key, verify once per call, run without the outage probe or latency signal, probe on every invalid key, touch key rows, or map organization keys to users, a session source that trusts hook answers on authoritative routes, a source that wraps errors without the request's credentials, JWT sources that expect the basePath URL as issuer or deny a key-set outage, sources that drop the host or forwarded headers, a route planner that forwards every handler's direct calls, a `forwardForeignCookies()` without a declaration check or without forwarding, a policy widening delegated principals, a policy turning a lost session or a storage outage into a denial, an organization policy coercing non-string inputs (also over `ws`) or repeating its Better Auth call, admin policies that admit API keys by default, lack fresh identity (also over RPC) or hostless calls, ignore a key owner's ban, judge the session's role or trust the stored user row (also on PGlite), a request scope that reuses a connection's principal for authoritative handlers (Socket.IO, graphql-ws), a WebSocket transport that declares a principal TTL it does not apply, a graphql-ws delivery that opens a new socket for each operation, and boot validation or route planning that ignore request-scoped dependencies or a class policy's `requires` field.
1039
+
1040
+ ## Develop in this monorepo
1041
+
1042
+ From the repository root:
1043
+
1044
+ ```sh
1045
+ mise install
1046
+ pnpm install
1047
+ pnpm --filter @nestjs-kit/better-auth typecheck
1048
+ pnpm --filter @nestjs-kit/better-auth build
1049
+ pnpm --filter @nestjs-kit/better-auth test:packaging
1050
+ ```
1051
+
1052
+ The E2E tests use the services in this package's `compose.yaml`:
1053
+
1054
+ ```sh
1055
+ pnpm --filter @nestjs-kit/better-auth docker:up
1056
+ pnpm --filter @nestjs-kit/better-auth test:e2e
1057
+ pnpm --filter @nestjs-kit/better-auth docker:down
1058
+ ```
1059
+
1060
+ The shared toolchain uses Node.js LTS, pnpm, tsdown, TypeScript and Vitest. Builds produce ESM and CommonJS artifacts with declarations, checked by strict publint and attw.
1061
+
1062
+ [Contributing](../../CONTRIBUTING.md) explains repository checks and PRs. See [package instructions](AGENTS.md) and [import provenance](docs/provenance.md) before working with the historical material.
1063
+
1064
+ ## Examples
1065
+
1066
+ Each example application registers a default and a named instance, reads the signed-in user through the `@CurrentUser()` principal parameter and starts without external services:
1067
+
1068
+ - [Express](examples/express/README.md) and [Fastify](examples/fastify/README.md) guard HTTP routes on their platforms.
1069
+ - [Apollo GraphQL](examples/graphql/README.md) authenticates queries and mutations over HTTP and every graphql-ws operation, with the named instance bound to its own resolver.
1070
+ - [Socket.IO](examples/websockets/README.md) authenticates gateway connections and every message, with the named instance on its own namespace.
1071
+ - [TCP microservice](examples/rpc/README.md) authenticates the messages of a hybrid application through the payload credential carrier.
1072
+
1073
+ ## Releases
1074
+
1075
+ Versions come from Changesets, and publication runs from the monorepo's release workflow with npm trusted publishing and provenance; see [release automation](../../docs/releases.md). New entry points are additive, so they ship as minor versions.
1076
+
1077
+ ## License
1078
+
1079
+ [MIT](LICENSE). The archived upstream adapter retains its [original MIT notice](legacy/LICENSE).