@lastshotlabs/bunshot 0.0.21 → 0.0.25

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 (122) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +44 -0
  4. package/dist/adapters/memoryAuth.d.ts +7 -0
  5. package/dist/adapters/memoryAuth.js +144 -0
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +120 -0
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +7 -0
  12. package/dist/adapters/sqliteAuth.js +199 -0
  13. package/dist/app.d.ts +100 -3
  14. package/dist/app.js +247 -46
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +49 -7
  17. package/dist/index.js +35 -5
  18. package/dist/lib/HttpError.d.ts +5 -0
  19. package/dist/lib/HttpError.js +7 -0
  20. package/dist/lib/appConfig.d.ts +44 -0
  21. package/dist/lib/appConfig.js +16 -0
  22. package/dist/lib/auditLog.d.ts +52 -0
  23. package/dist/lib/auditLog.js +201 -0
  24. package/dist/lib/authAdapter.d.ts +69 -0
  25. package/dist/lib/constants.d.ts +4 -0
  26. package/dist/lib/constants.js +4 -0
  27. package/dist/lib/context.d.ts +19 -1
  28. package/dist/lib/context.js +17 -3
  29. package/dist/lib/createRoute.d.ts +28 -2
  30. package/dist/lib/createRoute.js +54 -3
  31. package/dist/lib/deletionCancelToken.d.ts +12 -0
  32. package/dist/lib/deletionCancelToken.js +88 -0
  33. package/dist/lib/groups.d.ts +113 -0
  34. package/dist/lib/groups.js +133 -0
  35. package/dist/lib/idempotency.d.ts +22 -0
  36. package/dist/lib/idempotency.js +182 -0
  37. package/dist/lib/metrics.d.ts +14 -0
  38. package/dist/lib/metrics.js +158 -0
  39. package/dist/lib/pagination.d.ts +119 -0
  40. package/dist/lib/pagination.js +166 -0
  41. package/dist/lib/session.d.ts +4 -0
  42. package/dist/lib/session.js +56 -2
  43. package/dist/lib/signing.d.ts +52 -0
  44. package/dist/lib/signing.js +180 -0
  45. package/dist/lib/storageAdapter.d.ts +30 -0
  46. package/dist/lib/storageAdapter.js +1 -0
  47. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  48. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  49. package/dist/lib/tenant.js +2 -2
  50. package/dist/lib/upload.d.ts +35 -0
  51. package/dist/lib/upload.js +87 -0
  52. package/dist/lib/validate.js +2 -2
  53. package/dist/lib/ws.d.ts +1 -0
  54. package/dist/lib/ws.js +21 -0
  55. package/dist/lib/wsHeartbeat.d.ts +12 -0
  56. package/dist/lib/wsHeartbeat.js +57 -0
  57. package/dist/lib/wsMessages.d.ts +40 -0
  58. package/dist/lib/wsMessages.js +330 -0
  59. package/dist/lib/wsPresence.d.ts +25 -0
  60. package/dist/lib/wsPresence.js +99 -0
  61. package/dist/middleware/auditLog.d.ts +22 -0
  62. package/dist/middleware/auditLog.js +39 -0
  63. package/dist/middleware/cacheResponse.js +5 -1
  64. package/dist/middleware/csrf.js +10 -0
  65. package/dist/middleware/identify.js +57 -9
  66. package/dist/middleware/metrics.d.ts +9 -0
  67. package/dist/middleware/metrics.js +26 -0
  68. package/dist/middleware/requestId.d.ts +3 -0
  69. package/dist/middleware/requestId.js +7 -0
  70. package/dist/middleware/requestLogger.d.ts +38 -0
  71. package/dist/middleware/requestLogger.js +68 -0
  72. package/dist/middleware/requestSigning.d.ts +20 -0
  73. package/dist/middleware/requestSigning.js +99 -0
  74. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  75. package/dist/middleware/requireMfaSetup.js +36 -0
  76. package/dist/middleware/requireRole.d.ts +9 -3
  77. package/dist/middleware/requireRole.js +23 -36
  78. package/dist/middleware/upload.d.ts +5 -0
  79. package/dist/middleware/upload.js +27 -0
  80. package/dist/middleware/webhookAuth.d.ts +30 -0
  81. package/dist/middleware/webhookAuth.js +57 -0
  82. package/dist/models/AuditLog.d.ts +30 -0
  83. package/dist/models/AuditLog.js +39 -0
  84. package/dist/models/Group.d.ts +21 -0
  85. package/dist/models/Group.js +28 -0
  86. package/dist/models/GroupMembership.d.ts +21 -0
  87. package/dist/models/GroupMembership.js +25 -0
  88. package/dist/routes/auth.js +84 -6
  89. package/dist/routes/groups.d.ts +21 -0
  90. package/dist/routes/groups.js +346 -0
  91. package/dist/routes/jobs.js +47 -45
  92. package/dist/routes/metrics.d.ts +7 -0
  93. package/dist/routes/metrics.js +52 -0
  94. package/dist/routes/mfa.js +4 -0
  95. package/dist/routes/uploads.d.ts +2 -0
  96. package/dist/routes/uploads.js +135 -0
  97. package/dist/server.d.ts +26 -0
  98. package/dist/server.js +46 -3
  99. package/dist/ws/index.js +3 -0
  100. package/docs/sections/auth-flow/full.md +779 -634
  101. package/docs/sections/auth-flow/overview.md +2 -2
  102. package/docs/sections/auth-security-examples/full.md +365 -0
  103. package/docs/sections/authentication/full.md +130 -0
  104. package/docs/sections/authentication/overview.md +5 -0
  105. package/docs/sections/cli/full.md +13 -1
  106. package/docs/sections/configuration/full.md +17 -0
  107. package/docs/sections/configuration/overview.md +1 -0
  108. package/docs/sections/exports/full.md +34 -3
  109. package/docs/sections/logging/full.md +83 -0
  110. package/docs/sections/metrics/full.md +127 -0
  111. package/docs/sections/oauth/full.md +189 -189
  112. package/docs/sections/oauth/overview.md +1 -1
  113. package/docs/sections/pagination/full.md +93 -0
  114. package/docs/sections/roles/full.md +224 -135
  115. package/docs/sections/roles/overview.md +3 -1
  116. package/docs/sections/signing/full.md +203 -0
  117. package/docs/sections/uploads/full.md +199 -0
  118. package/docs/sections/versioning/full.md +85 -0
  119. package/docs/sections/webhook-auth/full.md +100 -0
  120. package/docs/sections/websocket/full.md +83 -0
  121. package/docs/sections/websocket-rooms/full.md +6 -1
  122. package/package.json +16 -4
@@ -1,634 +1,779 @@
1
- ## Auth Flow
2
-
3
- Sessions are backed by Redis by default. Each login creates an independent session keyed by a UUID (`session:{appName}:{sessionId}`), so multiple devices / tabs can be logged in simultaneously. Set `db.sessions: "mongo"` to store them in MongoDB instead — useful when running without Redis. See [Running without Redis](#running-without-redis).
4
-
5
- ### Browser clients
6
- 1. `POST /auth/login` → JWT set as HttpOnly cookie automatically
7
- 2. All subsequent requests send the cookie — no extra code needed
8
-
9
- ### API / non-browser clients
10
- 1. `POST /auth/login` → read `token` from response body
11
- 2. Send `x-user-token: <token>` header on every request
12
-
13
- ### Session management
14
-
15
- Each login creates an independent session so multiple devices stay logged in simultaneously. The framework enforces a configurable cap (default: 6) — the oldest session is evicted when the limit is exceeded.
16
-
17
- ```
18
- GET /auth/sessions → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
19
- DELETE /auth/sessions/:sessionId → revoke a specific session (other sessions unaffected)
20
- POST /auth/logout → revoke only the current session
21
- ```
22
-
23
- Session metadata (IP address, user-agent, timestamps) is persisted even after a session expires when `sessionPolicy.persistSessionMetadata: true` (default). This enables tenant apps to detect logins from novel devices or locations and prompt for MFA or send a security alert.
24
-
25
- Set `sessionPolicy.includeInactiveSessions: true` to surface expired/deleted sessions in `GET /auth/sessions` with `isActive: false` — useful for a full device-history UI similar to Google or Meta's account security page.
26
-
27
- #### Sliding sessions
28
-
29
- Set `sessionPolicy.trackLastActive: true` to update `lastActiveAt` on every authenticated request. This adds one DB write per request but enables a sliding-session experience — sessions that are actively used stay fresh. Pair with refresh tokens (below) for true sliding behavior: short-lived access tokens (15 min) keep authorization tight, while a long-lived refresh token (30 days) lets the client silently renew without re-entering credentials.
30
-
31
- ### Refresh Tokens
32
-
33
- When configured, login and register return short-lived access tokens (default 15 min) alongside long-lived refresh tokens (default 30 days). The client uses `POST /auth/refresh` to obtain a new access token when the current one expires.
34
-
35
- ```ts
36
- await createServer({
37
- auth: {
38
- refreshTokens: {
39
- accessTokenExpiry: 900, // seconds, default: 900 (15 min)
40
- refreshTokenExpiry: 2_592_000, // seconds, default: 2_592_000 (30 days)
41
- rotationGraceSeconds: 30, // default: 30 old token still works briefly after rotation
42
- },
43
- },
44
- });
45
- ```
46
-
47
- **When not configured**, the existing 7-day JWT behavior is unchanged — fully backward compatible.
48
-
49
- #### Endpoints
50
-
51
- | Endpoint | Purpose |
52
- |---|---|
53
- | `POST /auth/login` | Returns `token` + `refreshToken` |
54
- | `POST /auth/register` | Returns `token` + `refreshToken` |
55
- | `POST /auth/refresh` | Rotates refresh token, returns new `token` + `refreshToken` |
56
-
57
- #### Rotation with grace window
58
-
59
- On each refresh, the server generates a new refresh token but keeps the old one valid for `rotationGraceSeconds` (default 30s). If the client's network drops mid-refresh, it can safely retry with the old token. If the old token is reused *after* the grace window, the entire session is invalidated — this is token-family theft detection.
60
-
61
- #### Cookie behavior
62
-
63
- The refresh token is set as an `HttpOnly` cookie (`refresh_token`) alongside the existing session cookie. For non-browser clients, it's also returned in the JSON body and accepted via the `x-refresh-token` header.
64
-
65
- ### MFA / TOTP
66
-
67
- Enable multi-factor authentication with TOTP (Google Authenticator, Authy, etc.):
68
-
69
- ```ts
70
- await createServer({
71
- auth: {
72
- mfa: {
73
- issuer: "My App", // shown in authenticator apps (default: app name)
74
- algorithm: "SHA1", // default, most compatible
75
- digits: 6, // default
76
- period: 30, // seconds, default
77
- recoveryCodes: 10, // number of recovery codes, default: 10
78
- challengeTtlSeconds: 300, // MFA challenge window, default: 5 min
79
- },
80
- },
81
- });
82
- ```
83
-
84
- Requires `otpauth` peer dependency:
85
-
86
- ```bash
87
- bun add otpauth
88
- ```
89
-
90
- #### Endpoints
91
-
92
- | Endpoint | Auth | Purpose |
93
- |---|---|---|
94
- | `POST /auth/mfa/setup` | userAuth | Generate TOTP secret + otpauth URI (for QR code) |
95
- | `POST /auth/mfa/verify-setup` | userAuth | Confirm with TOTP code, returns recovery codes |
96
- | `POST /auth/mfa/verify` | none (uses mfaToken) | Complete login after password verified |
97
- | `DELETE /auth/mfa` | userAuth | Disable all MFA (requires TOTP code) |
98
- | `POST /auth/mfa/recovery-codes` | userAuth | Regenerate codes (requires TOTP code) |
99
- | `GET /auth/mfa/methods` | userAuth | Get enabled MFA methods |
100
-
101
- #### Login flow with MFA enabled
102
-
103
- 1. `POST /auth/login` with credentials password OK + MFA enabled → `{ mfaRequired: true, mfaToken: "...", mfaMethods: ["totp"] }` (no session created)
104
- 2. `POST /auth/mfa/verify` with `{ mfaToken, code }` verifies TOTP or recovery code → creates session → returns normal token response
105
-
106
- The verify endpoint accepts an optional `method` field (`"totp"` or `"emailOtp"`) to target a specific verification method. When omitted, methods are tried automatically.
107
-
108
- **OAuth logins skip MFA** — the OAuth provider is treated as the second factor.
109
-
110
- **Recovery codes**: 10 random 8-character alphanumeric codes, stored as SHA-256 hashes. Each code can only be used once. Enabling a second MFA method regenerates recovery codes — save the new set.
111
-
112
- ### Email OTP
113
-
114
- An alternative to TOTP that sends a one-time code to the user's email. Users can enable TOTP, email OTP, or both.
115
-
116
- ```ts
117
- await createServer({
118
- auth: {
119
- mfa: {
120
- challengeTtlSeconds: 300,
121
- emailOtp: {
122
- onSend: async (email, code) => {
123
- await sendEmail(email, `Your login code: ${code}`);
124
- },
125
- codeLength: 6, // default
126
- },
127
- },
128
- },
129
- });
130
- ```
131
-
132
- #### Endpoints
133
-
134
- | Endpoint | Auth | Purpose |
135
- |---|---|---|
136
- | `POST /auth/mfa/email-otp/enable` | userAuth | Send verification code to email |
137
- | `POST /auth/mfa/email-otp/verify-setup` | userAuth | Confirm code, enable email OTP |
138
- | `DELETE /auth/mfa/email-otp` | userAuth | Disable email OTP |
139
- | `POST /auth/mfa/resend` | none (uses mfaToken) | Resend email OTP code (max 3 per challenge) |
140
-
141
- #### Setup flow
142
-
143
- 1. `POST /auth/mfa/email-otp/enable` sends code to email returns `{ setupToken }`
144
- 2. `POST /auth/mfa/email-otp/verify-setup` with `{ setupToken, code }` enables email OTP returns recovery codes
145
-
146
- This two-step flow ensures the `onSend` callback actually delivers emails before MFA is activated, preventing lockout from misconfigured email providers.
147
-
148
- #### Login flow with email OTP
149
-
150
- 1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["emailOtp"] }` — code is auto-sent to user's email
151
- 2. `POST /auth/mfa/verify` with `{ mfaToken, code }` creates session
152
- 3. If the code didn't arrive: `POST /auth/mfa/resend` with `{ mfaToken }` (max 3 resends, capped at 3x challenge TTL)
153
-
154
- #### Disabling email OTP
155
-
156
- - If TOTP is also enabled: requires a TOTP code in the `code` field
157
- - If email OTP is the only method: requires the account password in the `password` field
158
- - Disabling the last MFA method turns off MFA entirely
159
-
160
- ### WebAuthn / Security Keys
161
-
162
- Hardware security keys (YubiKey, etc.) and platform authenticators (Touch ID, Windows Hello) via the WebAuthn/FIDO2 standard. Users can register multiple keys and use them as an MFA method alongside TOTP and email OTP.
163
-
164
- ```ts
165
- await createServer({
166
- auth: {
167
- mfa: {
168
- webauthn: {
169
- rpId: "example.com", // Relying Party ID — your domain
170
- origin: "https://example.com", // Expected origin(s)
171
- rpName: "My App", // Display name (default: app name)
172
- userVerification: "preferred", // "required" | "preferred" | "discouraged"
173
- timeout: 60000, // Ceremony timeout in ms (default: 60000)
174
- strictSignCount: false, // Reject when sign count goes backward (default: false — warn only)
175
- },
176
- },
177
- },
178
- });
179
- ```
180
-
181
- Requires `@simplewebauthn/server` peer dependency:
182
-
183
- ```bash
184
- bun add @simplewebauthn/server
185
- ```
186
-
187
- If `mfa.webauthn` is configured but the dependency is missing, the server fails fast at startup with a clear error message.
188
-
189
- #### Endpoints
190
-
191
- | Endpoint | Auth | Purpose |
192
- |---|---|---|
193
- | `POST /auth/mfa/webauthn/register-options` | userAuth | Generate registration options for `navigator.credentials.create()` |
194
- | `POST /auth/mfa/webauthn/register` | userAuth | Verify attestation and store credential |
195
- | `GET /auth/mfa/webauthn/credentials` | userAuth | List registered security keys |
196
- | `DELETE /auth/mfa/webauthn/credentials/:credentialId` | userAuth | Remove a single key |
197
- | `DELETE /auth/mfa/webauthn` | userAuth | Disable WebAuthn entirely |
198
-
199
- #### Registration flow
200
-
201
- 1. `POST /auth/mfa/webauthn/register-options` → returns `{ options, registrationToken }`
202
- 2. Client passes `options` to `navigator.credentials.create()` browser prompts user to tap/scan key
203
- 3. `POST /auth/mfa/webauthn/register` with `{ registrationToken, attestationResponse, name? }` stores credential returns recovery codes
204
-
205
- #### Login flow with WebAuthn
206
-
207
- 1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["webauthn"], webauthnOptions: {...} }`
208
- 2. Client passes `webauthnOptions` to `navigator.credentials.get()` browser prompts for key
209
- 3. `POST /auth/mfa/verify` with `{ mfaToken, webauthnResponse: {...} }` → creates session
210
-
211
- The `webauthnOptions` object follows the WebAuthn spec — pass it directly to `navigator.credentials.get()`. The `webauthnResponse` is the full result from the browser API.
212
-
213
- #### Credential removal
214
-
215
- - Removing a spare key (other keys or MFA methods still active): no extra verification needed
216
- - Removing the last credential of the last MFA method: requires TOTP code or password
217
- - `DELETE /auth/mfa/webauthn` (disable all): always requires verification
218
-
219
- #### Sign count validation
220
-
221
- WebAuthn authenticators increment a sign count on each use to detect cloned keys. By default, a backward count logs a warning but allows authentication. Set `strictSignCount: true` to reject authentication when the count goes backward.
222
-
223
- ### Account Deletion
224
-
225
- Enable `DELETE /auth/me` for user-initiated account deletion:
226
-
227
- ```ts
228
- await createServer({
229
- auth: {
230
- accountDeletion: {
231
- onBeforeDelete: async (userId) => {
232
- // Throw to abort (e.g., check for active subscription)
233
- },
234
- onAfterDelete: async (userId) => {
235
- // Cleanup: delete S3 files, cancel Stripe, etc.
236
- // Runs at execution time — query current state, not a snapshot
237
- },
238
- queued: false, // set true for async deletion via BullMQ
239
- gracePeriod: 0, // seconds before queued deletion executes
240
- onDeletionScheduled: async (userId, email, cancelToken) => {
241
- // Send cancellation email with cancelToken link
242
- },
243
- },
244
- },
245
- });
246
- ```
247
-
248
- #### Behavior
249
-
250
- - Requires `userAuth` middleware (user must be logged in)
251
- - Body: `{ password?: string }` — required for credential accounts, skipped for OAuth-only
252
- - Revokes all sessions, deletes tokens, calls `adapter.deleteUser(userId)`
253
- - Rate limited (3/hour by userId)
254
-
255
- #### Queued deletion
256
-
257
- When `queued: true`, deletion is enqueued as a BullMQ job instead of running synchronously. The endpoint returns `202 Accepted` immediately. With `gracePeriod > 0`, the user can cancel via `POST /auth/cancel-deletion`.
258
-
259
- ### Password Policy
260
-
261
- Configure password complexity requirements via `auth.passwordPolicy`. The policy applies to registration and password reset — login uses `min(1)` intentionally to avoid locking out users registered under older/weaker policies.
262
-
263
- ```ts
264
- await createServer({
265
- auth: {
266
- passwordPolicy: {
267
- minLength: 10, // default: 8
268
- requireLetter: true, // default: true — at least one a–z or A–Z
269
- requireDigit: true, // default: true — at least one 0–9
270
- requireSpecial: true, // default: false — at least one non-alphanumeric character
271
- },
272
- },
273
- });
274
- ```
275
-
276
- When not configured, the default policy requires 8+ characters with at least one letter and one digit.
277
-
278
- ### Protecting routes
279
-
280
- ```ts
281
- import { userAuth, requireRole, requireVerifiedEmail } from "@lastshotlabs/bunshot";
282
-
283
- router.use("/my-route", userAuth); // returns 401 if not logged in
284
- router.use("/admin", userAuth, requireRole("admin")); // returns 403 if user lacks role
285
- router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
286
- router.use("/dashboard", userAuth, requireVerifiedEmail); // returns 403 if email not verified
287
- ```
288
-
289
- ### Custom auth adapter
290
-
291
- By default, `/auth/*` routes store users in MongoDB via `mongoAuthAdapter`. Pass `auth: { adapter: myAdapter }` to `createServer` to use any other store — Postgres, SQLite, an external service, etc. Alternatively, use `db.auth` to select a built-in adapter (`"mongo"` | `"sqlite"` | `"memory"`).
292
-
293
- The schema should include a `roles` column if you plan to use role-based access:
294
-
295
- ```sql
296
- -- roles stored as a text array in Postgres
297
- ALTER TABLE users ADD COLUMN roles text[] NOT NULL DEFAULT '{}';
298
- ```
299
-
300
- ```ts
301
- import type { AuthAdapter } from "@lastshotlabs/bunshot";
302
- import { HttpError } from "@lastshotlabs/bunshot";
303
- import { db } from "./db";
304
- import { users } from "./schema";
305
- import { eq, sql } from "drizzle-orm";
306
-
307
- const pgAuthAdapter: AuthAdapter = {
308
- async findByEmail(email) {
309
- const user = await db.query.users.findFirst({ where: eq(users.email, email) });
310
- return user ? { id: user.id, passwordHash: user.passwordHash } : null;
311
- },
312
- async create(email, passwordHash) {
313
- try {
314
- const [user] = await db.insert(users).values({ email, passwordHash }).returning({ id: users.id });
315
- return { id: user.id };
316
- } catch (err: any) {
317
- if (/* unique constraint */ err.code === "23505") throw new HttpError(409, "Email already registered");
318
- throw err;
319
- }
320
- },
321
- // --- Role methods (optional only needed if using roles / requireRole) ---
322
- async getRoles(userId) {
323
- const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
324
- return user?.roles ?? [];
325
- },
326
- async setRoles(userId, roles) { // required if using defaultRole
327
- await db.update(users).set({ roles }).where(eq(users.id, userId));
328
- },
329
- async addRole(userId, role) {
330
- await db.update(users)
331
- .set({ roles: sql`array_append(roles, ${role})` })
332
- .where(eq(users.id, userId));
333
- },
334
- async removeRole(userId, role) {
335
- await db.update(users)
336
- .set({ roles: sql`array_remove(roles, ${role})` })
337
- .where(eq(users.id, userId));
338
- },
339
- };
340
-
341
- await createServer({
342
- routesDir: import.meta.dir + "/routes",
343
- app: { name: "My App", version: "1.0.0" },
344
- auth: {
345
- roles: ["admin", "editor", "user"],
346
- defaultRole: "user",
347
- adapter: pgAuthAdapter,
348
- },
349
- });
350
- ```
351
-
352
- The adapter is responsible for:
353
- - `findByEmail` — return `{ id, passwordHash }` or `null` if not found
354
- - `create` insert the user and return `{ id }`, throw `HttpError(409, ...)` on duplicate email
355
- - `setPassword` _(optional)_ update the stored password hash for `userId`; implement to enable `POST /auth/set-password`
356
- - `findOrCreateByProvider` _(optional)_ required for OAuth social login
357
- - `linkProvider` _(optional)_ add a provider identity to an existing user; implement to enable `GET /auth/{provider}/link`
358
- - `unlinkProvider` _(optional)_ — remove all identities for a provider from a user; implement to enable `DELETE /auth/{provider}/link`
359
- - `getRoles` _(optional)_ — return the roles assigned to `userId`; implement to enable `requireRole` middleware
360
- - `setRoles` _(optional)_ — replace all roles; required if using `defaultRole`
361
- - `addRole` _(optional)_ — add a single role; implement to use `addUserRole`
362
- - `removeRole` _(optional)_ — remove a single role; implement to use `removeUserRole`
363
- - `getUser` _(optional)_ — return `{ email?, providerIds?, emailVerified? }` for `userId`; implement to populate `GET /auth/me` (including `googleLinked` and `emailVerified`)
364
- - `findByIdentifier` _(optional)_ — look up a user by the configured `primaryField` value; implement for non-email primary fields. Falls back to `findByEmail` if absent.
365
- - `setEmailVerified` _(optional)_ — mark a user as email-verified; implement to support `POST /auth/verify-email`
366
- - `getEmailVerified` _(optional)_ — return whether a user is email-verified; implement to support the `emailVerification.required` gate and `POST /auth/resend-verification`
367
-
368
- Everything else (password hashing, JWT signing, Redis sessions) is handled by the package.
369
-
370
- ### Auth Rate Limiting
371
-
372
- All built-in auth endpoints are rate-limited out of the box with sensible defaults. No configuration needed — just be aware of the behavior:
373
-
374
- | Endpoint | Key | Counts | Default limit |
375
- |---|---|---|---|
376
- | `POST /auth/login` | identifier (email/username/phone) | **Failures only** — reset on success | 10 failures / 15 min |
377
- | `POST /auth/register` | IP address | Every attempt | 5 / hour |
378
- | `POST /auth/verify-email` | IP address | Every attempt | 10 / 15 min |
379
- | `POST /auth/resend-verification` | Identifier (email/username/phone) | Every attempt | 3 / hour |
380
- | `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
381
- | `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
382
- | `POST /auth/refresh` | IP address | Every attempt | 30 / min |
383
- | `POST /auth/mfa/verify` | IP address | Every attempt | 10 / 15 min |
384
- | `POST /auth/mfa/resend` | IP address | Every attempt | 5 / min |
385
-
386
- Login is keyed by the **identifier being targeted** — an attacker rotating IPs to brute-force `alice@example.com` is blocked regardless of source IP. A successful login resets the counter so legitimate users aren't locked out.
387
-
388
- #### Tuning limits
389
-
390
- ```ts
391
- await createServer({
392
- auth: {
393
- rateLimit: {
394
- login: { windowMs: 10 * 60 * 1000, max: 5 }, // stricter: 5 failures / 10 min
395
- register: { windowMs: 60 * 60 * 1000, max: 3 },
396
- verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // leave at default
397
- resendVerification: { windowMs: 60 * 60 * 1000, max: 2 },
398
- store: "redis", // default when Redis is enabled — shared across all server instances
399
- },
400
- },
401
- });
402
- ```
403
-
404
- #### Manually clearing a limit (admin unlock)
405
-
406
- If a legitimate user gets locked out, call `bustAuthLimit` with the same key format the limiter uses:
407
-
408
- ```ts
409
- import { bustAuthLimit } from "@lastshotlabs/bunshot";
410
-
411
- // Admin route: POST /admin/unblock-login
412
- router.post("/admin/unblock-login", userAuth, requireRole("admin"), async (c) => {
413
- const { identifier } = await c.req.json();
414
- await bustAuthLimit(`login:${identifier}`);
415
- return c.json({ message: "Login limit cleared" });
416
- });
417
- ```
418
-
419
- Key formats: `login:{identifier}`, `register:{ip}`, `verify:{ip}`, `resend:{userId}`.
420
-
421
- #### Using the rate limiter in your own routes
422
-
423
- `trackAttempt` and `isLimited` are exported so you can apply the same Redis-backed rate limiting to any route in your app. They use the same store configured via `auth.rateLimit.store`.
424
-
425
- ```ts
426
- import { trackAttempt, isLimited, bustAuthLimit, getClientIp } from "@lastshotlabs/bunshot";
427
-
428
- // trackAttempt — increments the counter and returns true if now over the limit
429
- // isLimited — checks without incrementing (read-only)
430
- // bustAuthLimit — resets a key (e.g. on success or admin unlock)
431
-
432
- router.post("/api/submit", async (c) => {
433
- const ip = getClientIp(c);
434
- const key = `submit:${ip}`;
435
-
436
- if (await trackAttempt(key, { windowMs: 60 * 1000, max: 5 })) {
437
- return c.json({ error: "Too many requests" }, 429);
438
- }
439
-
440
- // ... handle request
441
- return c.json({ ok: true });
442
- });
443
- ```
444
-
445
- Use `isLimited` when you want to check the current state without counting the request itself — for example, to gate an expensive pre-check before the attempt is registered:
446
-
447
- ```ts
448
- if (await isLimited(key, opts)) {
449
- return c.json({ error: "Too many requests" }, 429);
450
- }
451
- ```
452
-
453
- Keys are automatically namespaced to the app (e.g. `rl:MyApp:submit:1.2.3.4`) when the Redis store is active, so they won't collide on a shared Redis instance.
454
-
455
- #### Store
456
-
457
- The rate limit store defaults to `"redis"` when Redis is enabled (recommended for multi-instance deployments — limits are shared across all servers). Falls back to `"memory"` automatically when Redis is disabled. In-memory limits don't persist across restarts.
458
-
459
- ---
460
-
461
- ### Bot Protection
462
-
463
- The built-in IP rate limiter is ineffective against bots that rotate IPs. The `botProtection` config adds two IP-rotation-resistant layers that run before the IP rate limit check.
464
-
465
- #### Fingerprint rate limiting
466
-
467
- When `fingerprintRateLimit: true`, every request is also rate-limited by an HTTP fingerprint — a 12-char hash derived from `User-Agent`, `Accept-*`, `Connection`, and the presence/absence of browser-only headers (`sec-fetch-*`, `sec-ch-ua-*`, `origin`, `referer`, etc.).
468
-
469
- Bots that rotate IPs but use the same HTTP client (e.g. Python `requests`, `curl`, a headless browser) produce the same fingerprint and share a rate-limit bucket regardless of their source IP. Real browser sessions produce a different fingerprint from CLI tools, so they don't interfere with each other.
470
-
471
- ```ts
472
- await createServer({
473
- security: {
474
- rateLimit: { windowMs: 60_000, max: 100 }, // applies to both IP and fingerprint buckets
475
- botProtection: {
476
- fingerprintRateLimit: true,
477
- },
478
- },
479
- });
480
- ```
481
-
482
- The fingerprint bucket uses the same window and max as `security.rateLimit`, and is stored in the same backend as `auth.rateLimit.store` (Redis by default, shared across all instances).
483
-
484
- #### IP / CIDR blocklist
485
-
486
- Block known datacenter ranges, proxy providers, or individual IPs outright. Matched requests receive a 403 before any other processing — no session lookup, no rate-limit increment.
487
-
488
- ```ts
489
- await createServer({
490
- security: {
491
- botProtection: {
492
- blockList: [
493
- "198.51.100.0/24", // IPv4 CIDR
494
- "203.0.113.42", // exact IPv4
495
- "2001:db8::1", // exact IPv6
496
- ],
497
- },
498
- },
499
- });
500
- ```
501
-
502
- Both options can be combined. The middleware order is: blocklist IP rate limit fingerprint rate limit.
503
-
504
- #### Apply `botProtection` to individual routes
505
-
506
- `botProtection` is also exported for per-route use:
507
-
508
- ```ts
509
- import { botProtection } from "@lastshotlabs/bunshot";
510
-
511
- router.use("/api/submit", botProtection({ blockList: ["198.51.100.0/24"] }));
512
- ```
513
-
514
- ---
515
-
516
- ### Trusted Proxy
517
-
518
- By default, Bunshot uses the socket-level IP address for all rate limiting and session metadata — the `X-Forwarded-For` header is **ignored entirely**. This prevents attackers from spoofing IPs to bypass rate limits.
519
-
520
- If your app runs behind a reverse proxy (nginx, Cloudflare, AWS ALB), configure `security.trustProxy` so the framework reads the real client IP from the `X-Forwarded-For` chain:
521
-
522
- ```ts
523
- await createServer({
524
- security: {
525
- trustProxy: 1, // trust 1 proxy hop use the second-to-last IP in X-Forwarded-For
526
- // trustProxy: 2, // behind 2 proxies (e.g. Cloudflare ALB app)
527
- // trustProxy: false, // default use socket IP, ignore XFF entirely
528
- },
529
- });
530
- ```
531
-
532
- The number represents how many trusted proxy hops sit between your app and the internet. With `trustProxy: N`, the framework takes the Nth-from-right entry in the `X-Forwarded-For` chain, skipping the N trusted proxies.
533
-
534
- All rate limiting (auth, general, bot protection) and session metadata (IP in `GET /auth/sessions`) use the centralized `getClientIp(c)` utility, which respects this setting. It's also exported for use in your own routes:
535
-
536
- ```ts
537
- import { getClientIp } from "@lastshotlabs/bunshot";
538
-
539
- router.post("/api/action", async (c) => {
540
- const ip = getClientIp(c); // respects trustProxy setting
541
- // ...
542
- });
543
- ```
544
-
545
- ### JWT Secret Validation
546
-
547
- JWT secrets are validated on first use. The framework throws a clear error if:
548
- - The environment variable (`JWT_SECRET_DEV` or `JWT_SECRET_PROD`) is missing
549
- - The secret is shorter than 32 characters
550
-
551
- Generate a strong secret:
552
-
553
- ```bash
554
- node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
555
- ```
556
-
557
- ---
558
-
559
- ### Setting a password after social login
560
-
561
- If a user signed up via Google or Apple and later wants to add a password, send an authenticated request to `POST /auth/set-password`:
562
-
563
- ```ts
564
- // Client (logged-in user)
565
- await fetch("/auth/set-password", {
566
- method: "POST",
567
- headers: { "Content-Type": "application/json", "x-user-token": token },
568
- body: JSON.stringify({ password: "mynewpassword" }),
569
- });
570
- ```
571
-
572
- The built-in route hashes the password and calls `adapter.setPassword(userId, hash)`. If your adapter does not implement `setPassword`, the route returns `501 Not Implemented`.
573
-
574
- To support it with a custom adapter:
575
-
576
- ```ts
577
- const myAdapter: AuthAdapter = {
578
- findByEmail: ...,
579
- create: ...,
580
- async setPassword(userId, passwordHash) {
581
- await db.update(users).set({ passwordHash }).where(eq(users.id, userId));
582
- },
583
- };
584
- ```
585
-
586
- ### CSRF Protection
587
-
588
- Opt-in via `security.csrf` — protects cookie-authenticated browser clients against cross-site request forgery attacks. Mobile apps and SPAs using header-based auth (`x-user-token`) are not affected and do not need CSRF.
589
-
590
- ```ts
591
- await createServer({
592
- security: {
593
- csrf: {
594
- enabled: true,
595
- // exemptPaths: ["/webhooks/*"], // additional exempt paths
596
- // checkOrigin: true, // validate Origin header (default: true)
597
- },
598
- },
599
- });
600
- ```
601
-
602
- **How it works:**
603
-
604
- 1. The first GET request sets a `csrf_token` cookie (non-HttpOnly, readable by JS)
605
- 2. The token is HMAC-SHA256 signed with the JWT secret to prevent forgery
606
- 3. For state-changing requests (POST/PUT/PATCH/DELETE), the client must send the cookie value back in the `x-csrf-token` header
607
- 4. The middleware validates the signature and compares the header to the cookie using timing-safe comparison
608
- 5. Requests without an auth cookie (`token`) skip validation they are not vulnerable to CSRF
609
-
610
- The CSRF cookie is refreshed on login, register, MFA verify, and OAuth exchange. It is cleared on logout.
611
-
612
- **Client-side integration:**
613
-
614
- ```js
615
- function getCsrfToken() {
616
- return document.cookie
617
- .split("; ")
618
- .find(row => row.startsWith("csrf_token="))
619
- ?.split("=")[1];
620
- }
621
-
622
- // Include on all state-changing requests
623
- fetch("/api/resource", {
624
- method: "POST",
625
- credentials: "include",
626
- headers: {
627
- "Content-Type": "application/json",
628
- "X-CSRF-Token": getCsrfToken(),
629
- },
630
- body: JSON.stringify(data),
631
- });
632
-
633
- // After login, read the NEW csrf_token value (it's refreshed on auth state changes)
634
- ```
1
+ ### Auth Flow
2
+
3
+ Sessions are backed by Redis by default. Each login creates an independent session keyed by a UUID (`session:{appName}:{sessionId}`), so multiple devices / tabs can be logged in simultaneously. Set `db.sessions: "mongo"` to store them in MongoDB instead — useful when running without Redis. See [Running without Redis](#running-without-redis).
4
+
5
+ #### Browser clients
6
+ 1. `POST /auth/login` → JWT set as HttpOnly cookie automatically
7
+ 2. All subsequent requests send the cookie — no extra code needed
8
+
9
+ #### API / non-browser clients
10
+ 1. `POST /auth/login` → read `token` from response body
11
+ 2. Send `x-user-token: <token>` header on every request
12
+
13
+ #### Current user
14
+
15
+ ```
16
+ GET /auth/me → { userId, email?, emailVerified?, googleLinked? }
17
+ ```
18
+
19
+ Requires an active session (cookie or `x-user-token` header). Returns the authenticated user's profile — `email`, `emailVerified`, and `googleLinked` are populated when the auth adapter implements `getUser`.
20
+
21
+ #### Session management
22
+
23
+ Each login creates an independent session so multiple devices stay logged in simultaneously. The framework enforces a configurable cap (default: 6) the oldest session is evicted when the limit is exceeded.
24
+
25
+ ```
26
+ GET /auth/sessions → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
27
+ DELETE /auth/sessions/:sessionId → revoke a specific session (other sessions unaffected)
28
+ POST /auth/logout → revoke only the current session
29
+ ```
30
+
31
+ Session metadata (IP address, user-agent, timestamps) is persisted even after a session expires when `sessionPolicy.persistSessionMetadata: true` (default). This enables tenant apps to detect logins from novel devices or locations and prompt for MFA or send a security alert.
32
+
33
+ Set `sessionPolicy.includeInactiveSessions: true` to surface expired/deleted sessions in `GET /auth/sessions` with `isActive: false` useful for a full device-history UI similar to Google or Meta's account security page.
34
+
35
+ ##### Sliding sessions
36
+
37
+ Set `sessionPolicy.trackLastActive: true` to update `lastActiveAt` on every authenticated request. This adds one DB write per request but enables a sliding-session experience — sessions that are actively used stay fresh. Pair with refresh tokens (below) for true sliding behavior: short-lived access tokens (15 min) keep authorization tight, while a long-lived refresh token (30 days) lets the client silently renew without re-entering credentials.
38
+
39
+ #### Refresh Tokens
40
+
41
+ When configured, login and register return short-lived access tokens (default 15 min) alongside long-lived refresh tokens (default 30 days). The client uses `POST /auth/refresh` to obtain a new access token when the current one expires.
42
+
43
+ ```ts
44
+ await createServer({
45
+ auth: {
46
+ refreshTokens: {
47
+ accessTokenExpiry: 900, // seconds, default: 900 (15 min)
48
+ refreshTokenExpiry: 2_592_000, // seconds, default: 2_592_000 (30 days)
49
+ rotationGraceSeconds: 30, // default: 30 — old token still works briefly after rotation
50
+ },
51
+ },
52
+ });
53
+ ```
54
+
55
+ **When not configured**, the existing 7-day JWT behavior is unchanged fully backward compatible.
56
+
57
+ ##### Endpoints
58
+
59
+ | Endpoint | Purpose |
60
+ |---|---|
61
+ | `POST /auth/login` | Returns `token` + `refreshToken` |
62
+ | `POST /auth/register` | Returns `token` + `refreshToken` |
63
+ | `POST /auth/refresh` | Rotates refresh token, returns new `token` + `refreshToken` |
64
+
65
+ ##### Rotation with grace window
66
+
67
+ On each refresh, the server generates a new refresh token but keeps the old one valid for `rotationGraceSeconds` (default 30s). If the client's network drops mid-refresh, it can safely retry with the old token. If the old token is reused *after* the grace window, the entire session is invalidated — this is token-family theft detection.
68
+
69
+ ##### Cookie behavior
70
+
71
+ The refresh token is set as an `HttpOnly` cookie (`refresh_token`) alongside the existing session cookie. For non-browser clients, it's also returned in the JSON body and accepted via the `x-refresh-token` header.
72
+
73
+ #### MFA / TOTP
74
+
75
+ Enable multi-factor authentication with TOTP (Google Authenticator, Authy, etc.):
76
+
77
+ ```ts
78
+ await createServer({
79
+ auth: {
80
+ mfa: {
81
+ issuer: "My App", // shown in authenticator apps (default: app name)
82
+ algorithm: "SHA1", // default, most compatible
83
+ digits: 6, // default
84
+ period: 30, // seconds, default
85
+ recoveryCodes: 10, // number of recovery codes, default: 10
86
+ challengeTtlSeconds: 300, // MFA challenge window, default: 5 min
87
+ },
88
+ },
89
+ });
90
+ ```
91
+
92
+ Requires `otpauth` peer dependency:
93
+
94
+ ```bash
95
+ bun add otpauth
96
+ ```
97
+
98
+ ##### Endpoints
99
+
100
+ | Endpoint | Auth | Purpose |
101
+ |---|---|---|
102
+ | `POST /auth/mfa/setup` | userAuth | Generate TOTP secret + otpauth URI (for QR code) |
103
+ | `POST /auth/mfa/verify-setup` | userAuth | Confirm with TOTP code, returns recovery codes |
104
+ | `POST /auth/mfa/verify` | none (uses mfaToken) | Complete login after password verified |
105
+ | `DELETE /auth/mfa` | userAuth | Disable all MFA (requires TOTP code) |
106
+ | `POST /auth/mfa/recovery-codes` | userAuth | Regenerate codes (requires TOTP code) |
107
+ | `GET /auth/mfa/methods` | userAuth | Get enabled MFA methods |
108
+
109
+ ##### Login flow with MFA enabled
110
+
111
+ 1. `POST /auth/login` with credentials → password OK + MFA enabled → `{ mfaRequired: true, mfaToken: "...", mfaMethods: ["totp"] }` (no session created)
112
+ 2. `POST /auth/mfa/verify` with `{ mfaToken, code }` → verifies TOTP or recovery code → creates session → returns normal token response
113
+
114
+ The verify endpoint accepts an optional `method` field (`"totp"` or `"emailOtp"`) to target a specific verification method. When omitted, methods are tried automatically.
115
+
116
+ **OAuth logins skip MFA** — the OAuth provider is treated as the second factor.
117
+
118
+ **Recovery codes**: 10 random 8-character alphanumeric codes, stored as SHA-256 hashes. Each code can only be used once. Enabling a second MFA method regenerates recovery codes — save the new set.
119
+
120
+ ##### Enforcing MFA for all users
121
+
122
+ By default MFA is opt-in — individual users choose whether to enable it. Set `required: true` to enforce MFA at the app level:
123
+
124
+ ```ts
125
+ await createServer({
126
+ auth: {
127
+ mfa: {
128
+ issuer: "My App",
129
+ required: true, // all authenticated users must complete MFA setup
130
+ },
131
+ },
132
+ });
133
+ ```
134
+
135
+ When `required` is `true`:
136
+
137
+ - Authenticated users who have **not** completed MFA setup receive a `403` response on all non-auth endpoints:
138
+ ```json
139
+ { "error": "MFA setup required", "code": "MFA_SETUP_REQUIRED" }
140
+ ```
141
+ - **Exempt paths** remain accessible so users can complete setup: all `/auth/*` routes (login, logout, register, MFA setup, OAuth, sessions), `/health`, `/docs`, `/openapi.json`, and the root `/`.
142
+ - **Unauthenticated requests** pass through normally — the middleware only gates users who are logged in but lack MFA.
143
+ - **OAuth users** must also set up MFA OAuth login creates a session, but the user is still blocked from service endpoints until MFA is configured.
144
+ - **Disabling MFA** (via `DELETE /auth/mfa`) when `required: true` immediately blocks the user from service endpoints until they re-enable it.
145
+
146
+ **Client-side handling:** Check for the `MFA_SETUP_REQUIRED` code in 403 responses and redirect users to an MFA setup page that calls `POST /auth/mfa/setup`.
147
+
148
+ **Per-route usage:** The `requireMfaSetup` middleware is also exported for apps that want manual, per-route enforcement instead of global:
149
+
150
+ ```ts
151
+ import { userAuth, requireMfaSetup } from "@lastshotlabs/bunshot";
152
+
153
+ router.get("/dashboard", userAuth, requireMfaSetup, (c) => {
154
+ return c.json({ message: "Welcome" });
155
+ });
156
+ ```
157
+
158
+ #### Email OTP
159
+
160
+ An alternative to TOTP that sends a one-time code to the user's email. Users can enable TOTP, email OTP, or both.
161
+
162
+ ```ts
163
+ await createServer({
164
+ auth: {
165
+ mfa: {
166
+ challengeTtlSeconds: 300,
167
+ emailOtp: {
168
+ onSend: async (email, code) => {
169
+ await sendEmail(email, `Your login code: ${code}`);
170
+ },
171
+ codeLength: 6, // default
172
+ },
173
+ },
174
+ },
175
+ });
176
+ ```
177
+
178
+ ##### Endpoints
179
+
180
+ | Endpoint | Auth | Purpose |
181
+ |---|---|---|
182
+ | `POST /auth/mfa/email-otp/enable` | userAuth | Send verification code to email |
183
+ | `POST /auth/mfa/email-otp/verify-setup` | userAuth | Confirm code, enable email OTP |
184
+ | `DELETE /auth/mfa/email-otp` | userAuth | Disable email OTP |
185
+ | `POST /auth/mfa/resend` | none (uses mfaToken) | Resend email OTP code (max 3 per challenge) |
186
+
187
+ ##### Setup flow
188
+
189
+ 1. `POST /auth/mfa/email-otp/enable` → sends code to email → returns `{ setupToken }`
190
+ 2. `POST /auth/mfa/email-otp/verify-setup` with `{ setupToken, code }` → enables email OTP → returns recovery codes
191
+
192
+ This two-step flow ensures the `onSend` callback actually delivers emails before MFA is activated, preventing lockout from misconfigured email providers.
193
+
194
+ ##### Login flow with email OTP
195
+
196
+ 1. `POST /auth/login` `{ mfaRequired: true, mfaToken, mfaMethods: ["emailOtp"] }` — code is auto-sent to user's email
197
+ 2. `POST /auth/mfa/verify` with `{ mfaToken, code }` creates session
198
+ 3. If the code didn't arrive: `POST /auth/mfa/resend` with `{ mfaToken }` (max 3 resends, capped at 3x challenge TTL)
199
+
200
+ ##### Disabling email OTP
201
+
202
+ - If TOTP is also enabled: requires a TOTP code in the `code` field
203
+ - If email OTP is the only method: requires the account password in the `password` field
204
+ - Disabling the last MFA method turns off MFA entirely
205
+
206
+ #### WebAuthn / Security Keys
207
+
208
+ Hardware security keys (YubiKey, etc.) and platform authenticators (Touch ID, Windows Hello) via the WebAuthn/FIDO2 standard. Users can register multiple keys and use them as an MFA method alongside TOTP and email OTP.
209
+
210
+ ```ts
211
+ await createServer({
212
+ auth: {
213
+ mfa: {
214
+ webauthn: {
215
+ rpId: "example.com", // Relying Party ID your domain
216
+ origin: "https://example.com", // Expected origin(s)
217
+ rpName: "My App", // Display name (default: app name)
218
+ userVerification: "preferred", // "required" | "preferred" | "discouraged"
219
+ timeout: 60000, // Ceremony timeout in ms (default: 60000)
220
+ strictSignCount: false, // Reject when sign count goes backward (default: false — warn only)
221
+ },
222
+ },
223
+ },
224
+ });
225
+ ```
226
+
227
+ Requires `@simplewebauthn/server` peer dependency:
228
+
229
+ ```bash
230
+ bun add @simplewebauthn/server
231
+ ```
232
+
233
+ If `mfa.webauthn` is configured but the dependency is missing, the server fails fast at startup with a clear error message.
234
+
235
+ ##### Endpoints
236
+
237
+ | Endpoint | Auth | Purpose |
238
+ |---|---|---|
239
+ | `POST /auth/mfa/webauthn/register-options` | userAuth | Generate registration options for `navigator.credentials.create()` |
240
+ | `POST /auth/mfa/webauthn/register` | userAuth | Verify attestation and store credential |
241
+ | `GET /auth/mfa/webauthn/credentials` | userAuth | List registered security keys |
242
+ | `DELETE /auth/mfa/webauthn/credentials/:credentialId` | userAuth | Remove a single key |
243
+ | `DELETE /auth/mfa/webauthn` | userAuth | Disable WebAuthn entirely |
244
+
245
+ ##### Registration flow
246
+
247
+ 1. `POST /auth/mfa/webauthn/register-options` → returns `{ options, registrationToken }`
248
+ 2. Client passes `options` to `navigator.credentials.create()` — browser prompts user to tap/scan key
249
+ 3. `POST /auth/mfa/webauthn/register` with `{ registrationToken, attestationResponse, name? }` → stores credential → returns recovery codes
250
+
251
+ ##### Login flow with WebAuthn
252
+
253
+ 1. `POST /auth/login` `{ mfaRequired: true, mfaToken, mfaMethods: ["webauthn"], webauthnOptions: {...} }`
254
+ 2. Client passes `webauthnOptions` to `navigator.credentials.get()` — browser prompts for key
255
+ 3. `POST /auth/mfa/verify` with `{ mfaToken, webauthnResponse: {...} }` → creates session
256
+
257
+ The `webauthnOptions` object follows the WebAuthn spec pass it directly to `navigator.credentials.get()`. The `webauthnResponse` is the full result from the browser API.
258
+
259
+ ##### Credential removal
260
+
261
+ - Removing a spare key (other keys or MFA methods still active): no extra verification needed
262
+ - Removing the last credential of the last MFA method: requires TOTP code or password
263
+ - `DELETE /auth/mfa/webauthn` (disable all): always requires verification
264
+
265
+ ##### Sign count validation
266
+
267
+ WebAuthn authenticators increment a sign count on each use to detect cloned keys. By default, a backward count logs a warning but allows authentication. Set `strictSignCount: true` to reject authentication when the count goes backward.
268
+
269
+ #### Email Verification
270
+
271
+ Opt-in via `auth.emailVerification`. Requires `primaryField: "email"` (default).
272
+
273
+ ```ts
274
+ await createServer({
275
+ auth: {
276
+ emailVerification: {
277
+ onSend: async (email, token) => {
278
+ // Send via any provider (Resend, SendGrid, etc.)
279
+ await sendEmail(email, `Verify your email: https://myapp.com/verify?token=${token}`);
280
+ },
281
+ required: true, // Block login until verified (default: false)
282
+ tokenExpiry: 86400, // Seconds — default: 86400 (24 hours)
283
+ },
284
+ },
285
+ });
286
+ ```
287
+
288
+ When configured, two routes are mounted:
289
+
290
+ | Endpoint | Auth | Purpose |
291
+ |---|---|---|
292
+ | `POST /auth/verify-email` | none | Consume verification token |
293
+ | `POST /auth/resend-verification` | none (uses credentials) | Resend verification email |
294
+
295
+ A verification token is sent automatically on registration via `emailVerification.onSend`. The token is SHA-256 hashed before storage. Rate-limited by IP (verify) and identifier (resend).
296
+
297
+ When `required: true`, unverified users receive `403 { error: "Email not verified" }` on `/auth/login`.
298
+
299
+ #### Password Reset
300
+
301
+ Opt-in via `auth.passwordReset`. Requires `primaryField: "email"` (default) and a `setPassword` implementation on your auth adapter.
302
+
303
+ ```ts
304
+ await createServer({
305
+ auth: {
306
+ passwordReset: {
307
+ onSend: async (email, token) => {
308
+ await sendEmail(email, `Reset your password: https://myapp.com/reset?token=${token}`);
309
+ },
310
+ tokenExpiry: 3600, // Seconds default: 3600 (1 hour)
311
+ },
312
+ },
313
+ });
314
+ ```
315
+
316
+ When configured, two routes are mounted:
317
+
318
+ | Endpoint | Auth | Purpose |
319
+ |---|---|---|
320
+ | `POST /auth/forgot-password` | none | Request reset email (always returns 200 — never reveals whether address is registered) |
321
+ | `POST /auth/reset-password` | none | Consume token, set new password, revoke all sessions |
322
+
323
+ The forgot-password endpoint uses a fire-and-forget pattern and is rate-limited by **both IP and email address** to prevent distributed email-bombing. Tokens are SHA-256 hashed before storage and single-use. After a successful reset, all active sessions are revoked.
324
+
325
+ #### Primary Field
326
+
327
+ By default, users register and log in with their email address. Change this via `auth.primaryField`:
328
+
329
+ ```ts
330
+ await createServer({
331
+ auth: {
332
+ primaryField: "username", // "email" (default) | "username" | "phone"
333
+ },
334
+ });
335
+ ```
336
+
337
+ When set to `"username"` or `"phone"`, the registration/login body uses that field name instead of `email`. Email verification and password reset are only available when `primaryField` is `"email"`. Implement `adapter.findByIdentifier` on your custom adapter for non-email primary fields — it falls back to `findByEmail` when absent.
338
+
339
+ #### Account Deletion
340
+
341
+ Enable `DELETE /auth/me` for user-initiated account deletion:
342
+
343
+ ```ts
344
+ await createServer({
345
+ auth: {
346
+ accountDeletion: {
347
+ onBeforeDelete: async (userId) => {
348
+ // Throw to abort (e.g., check for active subscription)
349
+ },
350
+ onAfterDelete: async (userId) => {
351
+ // Cleanup: delete S3 files, cancel Stripe, etc.
352
+ // Runs at execution time — query current state, not a snapshot
353
+ },
354
+ queued: false, // set true for async deletion via BullMQ
355
+ gracePeriod: 0, // seconds before queued deletion executes
356
+ onDeletionScheduled: async (userId, email, cancelToken) => {
357
+ // Send cancellation email with cancelToken link
358
+ },
359
+ },
360
+ },
361
+ });
362
+ ```
363
+
364
+ ##### Behavior
365
+
366
+ - Requires `userAuth` middleware (user must be logged in)
367
+ - Body: `{ password?: string }` — required for credential accounts, skipped for OAuth-only
368
+ - Revokes all sessions, deletes tokens, calls `adapter.deleteUser(userId)`
369
+ - Rate limited (3/hour by userId)
370
+
371
+ ##### Queued deletion
372
+
373
+ When `queued: true`, deletion is enqueued as a BullMQ job instead of running synchronously. The endpoint returns `202 Accepted` immediately and the user's sessions are revoked at that point. The actual user data deletion executes after the delay set by `gracePeriod`.
374
+
375
+ The framework starts a built-in BullMQ worker automatically (in-process, alongside the API server) to process deletion jobs. Requires BullMQ and Redis:
376
+
377
+ ```bash
378
+ bun add bullmq
379
+ ```
380
+
381
+ With `gracePeriod > 0`, the `POST /auth/cancel-deletion` route is mounted. Provide `onDeletionScheduled` to send the cancel token to the user:
382
+
383
+ ```ts
384
+ auth: {
385
+ accountDeletion: {
386
+ queued: true,
387
+ gracePeriod: 7 * 24 * 60 * 60, // 7 days in seconds
388
+ onDeletionScheduled: async (userId, email, cancelToken) => {
389
+ await sendEmail(email, `Cancel deletion: https://myapp.com/cancel?token=${cancelToken}`);
390
+ },
391
+ },
392
+ }
393
+ ```
394
+
395
+ The cancel endpoint accepts the token and removes the pending BullMQ job:
396
+
397
+ ```
398
+ POST /auth/cancel-deletion → { message: "Account deletion cancelled" }
399
+ Body: { token: string }
400
+ ```
401
+
402
+ The cancel token expires when the grace period elapses and can only be used once.
403
+
404
+ #### Password Policy
405
+
406
+ Configure password complexity requirements via `auth.passwordPolicy`. The policy applies to registration and password reset — login uses `min(1)` intentionally to avoid locking out users registered under older/weaker policies.
407
+
408
+ ```ts
409
+ await createServer({
410
+ auth: {
411
+ passwordPolicy: {
412
+ minLength: 10, // default: 8
413
+ requireLetter: true, // default: true at least one a–z or A–Z
414
+ requireDigit: true, // default: true — at least one 0–9
415
+ requireSpecial: true, // default: false at least one non-alphanumeric character
416
+ },
417
+ },
418
+ });
419
+ ```
420
+
421
+ When not configured, the default policy requires 8+ characters with at least one letter and one digit.
422
+
423
+ #### Protecting routes
424
+
425
+ ```ts
426
+ import { userAuth, requireRole, requireVerifiedEmail } from "@lastshotlabs/bunshot";
427
+
428
+ router.use("/my-route", userAuth); // returns 401 if not logged in
429
+ router.use("/admin", userAuth, requireRole("admin")); // returns 403 if user lacks role
430
+ router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
431
+ router.use("/dashboard", userAuth, requireVerifiedEmail); // returns 403 if email not verified
432
+ ```
433
+
434
+ #### Custom auth adapter
435
+
436
+ By default, `/auth/*` routes store users in MongoDB via `mongoAuthAdapter`. Pass `auth: { adapter: myAdapter }` to `createServer` to use any other store — Postgres, SQLite, an external service, etc. Alternatively, use `db.auth` to select a built-in adapter (`"mongo"` | `"sqlite"` | `"memory"`).
437
+
438
+ The schema should include a `roles` column if you plan to use role-based access:
439
+
440
+ ```sql
441
+ -- roles stored as a text array in Postgres
442
+ ALTER TABLE users ADD COLUMN roles text[] NOT NULL DEFAULT '{}';
443
+ ```
444
+
445
+ ```ts
446
+ import type { AuthAdapter } from "@lastshotlabs/bunshot";
447
+ import { HttpError } from "@lastshotlabs/bunshot";
448
+ import { db } from "./db";
449
+ import { users } from "./schema";
450
+ import { eq, sql } from "drizzle-orm";
451
+
452
+ const pgAuthAdapter: AuthAdapter = {
453
+ async findByEmail(email) {
454
+ const user = await db.query.users.findFirst({ where: eq(users.email, email) });
455
+ return user ? { id: user.id, passwordHash: user.passwordHash } : null;
456
+ },
457
+ async create(email, passwordHash) {
458
+ try {
459
+ const [user] = await db.insert(users).values({ email, passwordHash }).returning({ id: users.id });
460
+ return { id: user.id };
461
+ } catch (err: any) {
462
+ if (/* unique constraint */ err.code === "23505") throw new HttpError(409, "Email already registered");
463
+ throw err;
464
+ }
465
+ },
466
+ // --- Role methods (optional — only needed if using roles / requireRole) ---
467
+ async getRoles(userId) {
468
+ const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
469
+ return user?.roles ?? [];
470
+ },
471
+ async setRoles(userId, roles) { // required if using defaultRole
472
+ await db.update(users).set({ roles }).where(eq(users.id, userId));
473
+ },
474
+ async addRole(userId, role) {
475
+ await db.update(users)
476
+ .set({ roles: sql`array_append(roles, ${role})` })
477
+ .where(eq(users.id, userId));
478
+ },
479
+ async removeRole(userId, role) {
480
+ await db.update(users)
481
+ .set({ roles: sql`array_remove(roles, ${role})` })
482
+ .where(eq(users.id, userId));
483
+ },
484
+ };
485
+
486
+ await createServer({
487
+ routesDir: import.meta.dir + "/routes",
488
+ app: { name: "My App", version: "1.0.0" },
489
+ auth: {
490
+ roles: ["admin", "editor", "user"],
491
+ defaultRole: "user",
492
+ adapter: pgAuthAdapter,
493
+ },
494
+ });
495
+ ```
496
+
497
+ The adapter is responsible for:
498
+ - `findByEmail` — return `{ id, passwordHash }` or `null` if not found
499
+ - `create` — insert the user and return `{ id }`, throw `HttpError(409, ...)` on duplicate email
500
+ - `setPassword` _(optional)_ — update the stored password hash for `userId`; implement to enable `POST /auth/set-password`
501
+ - `findOrCreateByProvider` _(optional)_ — required for OAuth social login
502
+ - `linkProvider` _(optional)_ add a provider identity to an existing user; implement to enable `GET /auth/{provider}/link`
503
+ - `unlinkProvider` _(optional)_ — remove all identities for a provider from a user; implement to enable `DELETE /auth/{provider}/link`
504
+ - `getRoles` _(optional)_ — return the roles assigned to `userId`; implement to enable `requireRole` middleware
505
+ - `setRoles` _(optional)_ — replace all roles; required if using `defaultRole`
506
+ - `addRole` _(optional)_ add a single role; implement to use `addUserRole`
507
+ - `removeRole` _(optional)_ — remove a single role; implement to use `removeUserRole`
508
+ - `getUser` _(optional)_ — return `{ email?, providerIds?, emailVerified? }` for `userId`; implement to populate `GET /auth/me` (including `googleLinked` and `emailVerified`)
509
+ - `findByIdentifier` _(optional)_ look up a user by the configured `primaryField` value; implement for non-email primary fields. Falls back to `findByEmail` if absent.
510
+ - `setEmailVerified` _(optional)_ — mark a user as email-verified; implement to support `POST /auth/verify-email`
511
+ - `getEmailVerified` _(optional)_ return whether a user is email-verified; implement to support the `emailVerification.required` gate and `POST /auth/resend-verification`
512
+
513
+ Everything else (password hashing, JWT signing, Redis sessions) is handled by the package.
514
+
515
+ #### Auth Rate Limiting
516
+
517
+ All built-in auth endpoints are rate-limited out of the box with sensible defaults. No configuration needed — just be aware of the behavior:
518
+
519
+ | Endpoint | Key | Counts | Default limit |
520
+ |---|---|---|---|
521
+ | `POST /auth/login` | identifier (email/username/phone) | **Failures only** — reset on success | 10 failures / 15 min |
522
+ | `POST /auth/register` | IP address | Every attempt | 5 / hour |
523
+ | `POST /auth/verify-email` | IP address | Every attempt | 10 / 15 min |
524
+ | `POST /auth/resend-verification` | Identifier (email/username/phone) | Every attempt | 3 / hour |
525
+ | `POST /auth/forgot-password` | IP address **and** email address | Every attempt | 5 / 15 min (shared window per key) |
526
+ | `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
527
+ | `POST /auth/refresh` | IP address | Every attempt | 30 / min |
528
+ | `POST /auth/mfa/verify` | IP address | Every attempt | 10 / 15 min |
529
+ | `POST /auth/mfa/resend` | IP address | Every attempt | 5 / min |
530
+
531
+ Login is keyed by the **identifier being targeted** — an attacker rotating IPs to brute-force `alice@example.com` is blocked regardless of source IP. A successful login resets the counter so legitimate users aren't locked out.
532
+
533
+ ##### Tuning limits
534
+
535
+ ```ts
536
+ await createServer({
537
+ auth: {
538
+ rateLimit: {
539
+ login: { windowMs: 10 * 60 * 1000, max: 5 }, // stricter: 5 failures / 10 min
540
+ register: { windowMs: 60 * 60 * 1000, max: 3 },
541
+ verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // leave at default
542
+ resendVerification: { windowMs: 60 * 60 * 1000, max: 2 },
543
+ store: "redis", // default when Redis is enabled — shared across all server instances
544
+ },
545
+ },
546
+ });
547
+ ```
548
+
549
+ ##### Manually clearing a limit (admin unlock)
550
+
551
+ If a legitimate user gets locked out, call `bustAuthLimit` with the same key format the limiter uses:
552
+
553
+ ```ts
554
+ import { bustAuthLimit } from "@lastshotlabs/bunshot";
555
+
556
+ // Admin route: POST /admin/unblock-login
557
+ router.post("/admin/unblock-login", userAuth, requireRole("admin"), async (c) => {
558
+ const { identifier } = await c.req.json();
559
+ await bustAuthLimit(`login:${identifier}`);
560
+ return c.json({ message: "Login limit cleared" });
561
+ });
562
+ ```
563
+
564
+ Key formats: `login:{identifier}`, `register:{ip}`, `verify:{ip}`, `resend:{identifier}`, `forgot:ip:{ip}`, `forgot:email:{email}`, `reset:{ip}`, `refresh:ip:{ip}`, `deleteaccount:{userId}`.
565
+
566
+ ##### Using the rate limiter in your own routes
567
+
568
+ `trackAttempt` and `isLimited` are exported so you can apply the same Redis-backed rate limiting to any route in your app. They use the same store configured via `auth.rateLimit.store`.
569
+
570
+ ```ts
571
+ import { trackAttempt, isLimited, bustAuthLimit, getClientIp } from "@lastshotlabs/bunshot";
572
+
573
+ // trackAttempt — increments the counter and returns true if now over the limit
574
+ // isLimited — checks without incrementing (read-only)
575
+ // bustAuthLimit — resets a key (e.g. on success or admin unlock)
576
+
577
+ router.post("/api/submit", async (c) => {
578
+ const ip = getClientIp(c);
579
+ const key = `submit:${ip}`;
580
+
581
+ if (await trackAttempt(key, { windowMs: 60 * 1000, max: 5 })) {
582
+ return c.json({ error: "Too many requests" }, 429);
583
+ }
584
+
585
+ // ... handle request
586
+ return c.json({ ok: true });
587
+ });
588
+ ```
589
+
590
+ Use `isLimited` when you want to check the current state without counting the request itself — for example, to gate an expensive pre-check before the attempt is registered:
591
+
592
+ ```ts
593
+ if (await isLimited(key, opts)) {
594
+ return c.json({ error: "Too many requests" }, 429);
595
+ }
596
+ ```
597
+
598
+ Keys are automatically namespaced to the app (e.g. `rl:MyApp:submit:1.2.3.4`) when the Redis store is active, so they won't collide on a shared Redis instance.
599
+
600
+ ##### Store
601
+
602
+ The rate limit store defaults to `"redis"` when Redis is enabled (recommended for multi-instance deployments — limits are shared across all servers). Falls back to `"memory"` automatically when Redis is disabled. In-memory limits don't persist across restarts.
603
+
604
+ ---
605
+
606
+ #### Bot Protection
607
+
608
+ The built-in IP rate limiter is ineffective against bots that rotate IPs. The `botProtection` config adds two IP-rotation-resistant layers that run before the IP rate limit check.
609
+
610
+ ##### Fingerprint rate limiting
611
+
612
+ When `fingerprintRateLimit: true`, every request is also rate-limited by an HTTP fingerprint — a 12-char hash derived from `User-Agent`, `Accept-*`, `Connection`, and the presence/absence of browser-only headers (`sec-fetch-*`, `sec-ch-ua-*`, `origin`, `referer`, etc.).
613
+
614
+ Bots that rotate IPs but use the same HTTP client (e.g. Python `requests`, `curl`, a headless browser) produce the same fingerprint and share a rate-limit bucket regardless of their source IP. Real browser sessions produce a different fingerprint from CLI tools, so they don't interfere with each other.
615
+
616
+ ```ts
617
+ await createServer({
618
+ security: {
619
+ rateLimit: { windowMs: 60_000, max: 100 }, // applies to both IP and fingerprint buckets
620
+ botProtection: {
621
+ fingerprintRateLimit: true,
622
+ },
623
+ },
624
+ });
625
+ ```
626
+
627
+ The fingerprint bucket uses the same window and max as `security.rateLimit`, and is stored in the same backend as `auth.rateLimit.store` (Redis by default, shared across all instances).
628
+
629
+ ##### IP / CIDR blocklist
630
+
631
+ Block known datacenter ranges, proxy providers, or individual IPs outright. Matched requests receive a 403 before any other processing — no session lookup, no rate-limit increment.
632
+
633
+ ```ts
634
+ await createServer({
635
+ security: {
636
+ botProtection: {
637
+ blockList: [
638
+ "198.51.100.0/24", // IPv4 CIDR
639
+ "203.0.113.42", // exact IPv4
640
+ "2001:db8::1", // exact IPv6
641
+ ],
642
+ },
643
+ },
644
+ });
645
+ ```
646
+
647
+ Both options can be combined. The middleware order is: blocklist → IP rate limit → fingerprint rate limit.
648
+
649
+ ##### Apply `botProtection` to individual routes
650
+
651
+ `botProtection` is also exported for per-route use:
652
+
653
+ ```ts
654
+ import { botProtection } from "@lastshotlabs/bunshot";
655
+
656
+ router.use("/api/submit", botProtection({ blockList: ["198.51.100.0/24"] }));
657
+ ```
658
+
659
+ ---
660
+
661
+ #### Trusted Proxy
662
+
663
+ By default, Bunshot uses the socket-level IP address for all rate limiting and session metadata — the `X-Forwarded-For` header is **ignored entirely**. This prevents attackers from spoofing IPs to bypass rate limits.
664
+
665
+ If your app runs behind a reverse proxy (nginx, Cloudflare, AWS ALB), configure `security.trustProxy` so the framework reads the real client IP from the `X-Forwarded-For` chain:
666
+
667
+ ```ts
668
+ await createServer({
669
+ security: {
670
+ trustProxy: 1, // trust 1 proxy hop — use the second-to-last IP in X-Forwarded-For
671
+ // trustProxy: 2, // behind 2 proxies (e.g. Cloudflare → ALB → app)
672
+ // trustProxy: false, // default — use socket IP, ignore XFF entirely
673
+ },
674
+ });
675
+ ```
676
+
677
+ The number represents how many trusted proxy hops sit between your app and the internet. With `trustProxy: N`, the framework takes the Nth-from-right entry in the `X-Forwarded-For` chain, skipping the N trusted proxies.
678
+
679
+ All rate limiting (auth, general, bot protection) and session metadata (IP in `GET /auth/sessions`) use the centralized `getClientIp(c)` utility, which respects this setting. It's also exported for use in your own routes:
680
+
681
+ ```ts
682
+ import { getClientIp } from "@lastshotlabs/bunshot";
683
+
684
+ router.post("/api/action", async (c) => {
685
+ const ip = getClientIp(c); // respects trustProxy setting
686
+ // ...
687
+ });
688
+ ```
689
+
690
+ #### JWT Secret Validation
691
+
692
+ JWT secrets are validated on first use. The framework throws a clear error if:
693
+ - The environment variable (`JWT_SECRET_DEV` or `JWT_SECRET_PROD`) is missing
694
+ - The secret is shorter than 32 characters
695
+
696
+ Generate a strong secret:
697
+
698
+ ```bash
699
+ node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
700
+ ```
701
+
702
+ ---
703
+
704
+ #### Setting a password after social login
705
+
706
+ If a user signed up via Google or Apple and later wants to add a password, send an authenticated request to `POST /auth/set-password`:
707
+
708
+ ```ts
709
+ // Client (logged-in user)
710
+ await fetch("/auth/set-password", {
711
+ method: "POST",
712
+ headers: { "Content-Type": "application/json", "x-user-token": token },
713
+ body: JSON.stringify({ password: "mynewpassword" }),
714
+ });
715
+ ```
716
+
717
+ The built-in route hashes the password and calls `adapter.setPassword(userId, hash)`. If your adapter does not implement `setPassword`, the route returns `501 Not Implemented`.
718
+
719
+ To support it with a custom adapter:
720
+
721
+ ```ts
722
+ const myAdapter: AuthAdapter = {
723
+ findByEmail: ...,
724
+ create: ...,
725
+ async setPassword(userId, passwordHash) {
726
+ await db.update(users).set({ passwordHash }).where(eq(users.id, userId));
727
+ },
728
+ };
729
+ ```
730
+
731
+ #### CSRF Protection
732
+
733
+ Opt-in via `security.csrf` — protects cookie-authenticated browser clients against cross-site request forgery attacks. Mobile apps and SPAs using header-based auth (`x-user-token`) are not affected and do not need CSRF.
734
+
735
+ ```ts
736
+ await createServer({
737
+ security: {
738
+ csrf: {
739
+ enabled: true,
740
+ // exemptPaths: ["/webhooks/*"], // additional exempt paths
741
+ // checkOrigin: true, // validate Origin header (default: true)
742
+ },
743
+ },
744
+ });
745
+ ```
746
+
747
+ **How it works:**
748
+
749
+ 1. The first GET request sets a `csrf_token` cookie (non-HttpOnly, readable by JS)
750
+ 2. The token is HMAC-SHA256 signed with the JWT secret to prevent forgery
751
+ 3. For state-changing requests (POST/PUT/PATCH/DELETE), the client must send the cookie value back in the `x-csrf-token` header
752
+ 4. The middleware validates the signature and compares the header to the cookie using timing-safe comparison
753
+ 5. Requests without an auth cookie (`token`) skip validation — they are not vulnerable to CSRF
754
+
755
+ The CSRF cookie is refreshed on login, register, MFA verify, and OAuth exchange. It is cleared on logout.
756
+
757
+ **Client-side integration:**
758
+
759
+ ```js
760
+ function getCsrfToken() {
761
+ return document.cookie
762
+ .split("; ")
763
+ .find(row => row.startsWith("csrf_token="))
764
+ ?.split("=")[1];
765
+ }
766
+
767
+ // Include on all state-changing requests
768
+ fetch("/api/resource", {
769
+ method: "POST",
770
+ credentials: "include",
771
+ headers: {
772
+ "Content-Type": "application/json",
773
+ "X-CSRF-Token": getCsrfToken(),
774
+ },
775
+ body: JSON.stringify(data),
776
+ });
777
+
778
+ // After login, read the NEW csrf_token value (it's refreshed on auth state changes)
779
+ ```