@lastshotlabs/bunshot
v0.2.0
Published
Batteries-included Bun + Hono API framework — auth, sessions, rate limiting, WebSocket, queues, and OpenAPI docs out of the box
Maintainers
Readme
Bunshot by Last Shot Labs
Bun + Hono API framework with a composable plugin system. Ships with a full auth plugin (@lastshotlabs/bunshot-auth), WebSocket rooms, BullMQ workers, Prometheus metrics, rate limiting, response caching, idempotency, and auto-generated OpenAPI docs. Add your own routes, workers, models, and plugins on top.
Quick Start
Get a server running in under a minute. The in-memory config means no databases, no setup — just routes.
bun add @lastshotlabs/bunshot hono zod// src/index.ts
import { createServer } from '@lastshotlabs/bunshot';
await createServer({
routesDir: import.meta.dir + '/routes',
db: { auth: 'memory', mongo: false, redis: false, sessions: 'memory', cache: 'memory' },
});// src/routes/hello.ts
import { z } from 'zod';
import { createRoute, createRouter } from '@lastshotlabs/bunshot';
export const router = createRouter();
router.openapi(
createRoute({
method: 'get',
path: '/hello',
responses: {
200: {
content: { 'application/json': { schema: z.object({ message: z.string() }) } },
description: 'Hello',
},
},
}),
c => c.json({ message: 'Hello world!' }, 200),
);bun run src/index.tsOut of the box you get:
- Auth routes — register, login, logout,
/auth/me, sessions (POST /auth/register, etc.) - OpenAPI docs — interactive Scalar UI at
/docs, raw spec at/openapi.json - Health check —
GET /health - WebSocket — multi-endpoint via
ws.endpointsconfig, with room subscriptions, presence, and heartbeat
No databases required for the above. Swap "memory" for "redis" / "mongo" / "sqlite" when you're ready to persist data.
Stack
Packages
| Package | Description | Backends |
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| @lastshotlabs/bunshot | Main framework — createApp, createServer, route auto-discovery, OpenAPI, WebSocket, BullMQ, metrics, caching | — |
| @lastshotlabs/bunshot-core | Plugin interface (BunshotPlugin), event bus (BunshotEventBus), auth boundary contracts | — |
| @lastshotlabs/bunshot-auth | Auth plugin — JWT, sessions, OAuth, MFA, WebAuthn, SAML | Memory, SQLite, MongoDB, Postgres |
| @lastshotlabs/bunshot-admin | Admin routes, AdminAccessProvider and ManagedUserProvider seams | — |
| @lastshotlabs/bunshot-community | Forum — containers, threads, replies, reactions, moderation, notifications | Memory, SQLite, MongoDB, Postgres |
| @lastshotlabs/bunshot-push | Web Push (VAPID) delivery — subscribe/unsubscribe, bus-driven delivery | Memory, SQLite, MongoDB, Postgres |
| @lastshotlabs/bunshot-permissions | Field-level grants, role-based permissions | Memory, SQLite, MongoDB, Postgres |
| @lastshotlabs/bunshot-webhooks | Outbound webhook delivery and management | Memory, SQLite, MongoDB, Postgres |
| @lastshotlabs/bunshot-mail | Email — provider/queue/renderer adapter seams | — |
| @lastshotlabs/bunshot-bullmq | BullMQ queue integration | — |
| @lastshotlabs/bunshot-postgres | Postgres adapter | — |
@lastshotlabs/bunshot depends on sub-packages and re-exports everything — importing from the main package always works.
Runtime & build
- Runtime: Bun 1.0+ (native SQLite, native test runner, native TypeScript)
- Framework: Hono + @hono/zod-openapi
- Docs UI: Scalar
- Validation: Zod v4
Data & storage
- Adapter parity: All stateful plugins (auth, community, push, permissions, webhooks) support Memory, SQLite, MongoDB, and Postgres backends
- Sessions / cache: ioredis (Redis), MongoDB, SQLite, or in-memory —
db.sessions/db.cache - Auth tokens: JWT via jose — HttpOnly cookie +
x-user-tokenheader
BunshotContext
createApp() returns { app, ctx }. The BunshotContext is instance-scoped runtime state:
- Attached to the Hono app via WeakMap — available in handlers via
c.get('bunshotCtx')orgetBunshotCtx(c) - Holds infrastructure (redis, mongo, sqlite, signing), resolved config, WS state, plugin state
ctx.clear()resets all stores (test isolation);ctx.destroy()tears down connections (shutdown)
Test utilities
Test helpers are exported from /testing subpath exports, not from main entry points:
import { clearCommunityMemoryStore } from '@lastshotlabs/bunshot-community/testing';
import { createCookieJar, createTestFullServer } from '@lastshotlabs/bunshot/testing';Test state reset is handled by the global tests/setup.ts preload (via runAllClearCallbacks() + config unlocks). Production code must never import from /testing.
Infrastructure
- Queues: BullMQ — worker auto-discovery, DLQ, cron workers (requires Redis with
noevictionpolicy) - WebSocket: Bun native WebSocket — rooms, heartbeat, presence, message persistence;
WsTransportAdapterfor horizontal scaling - Metrics: Prometheus-compatible
/metricsendpoint, request counters, histograms, BullMQ queue depth gauges
CLI — Scaffold a New Project
bunx @lastshotlabs/bunshot "My App"You can also pass a custom directory name:
bunx @lastshotlabs/bunshot "My App" my-app-dirThe CLI is interactive when run in a TTY. In non-TTY environments (CI, piped input) it falls back to numbered prompts.
What gets created
my-app/
src/
index.ts # entry point — calls createServer(appConfig)
config/index.ts # centralized app configuration (auth, security, db, etc.)
lib/constants.ts # APP_NAME, APP_VERSION, USER_ROLES
routes/ # add your route files here
workers/ # BullMQ workers (auto-discovered)
queues/ # queue definitions
ws/ # WebSocket handlers
services/ # business logic
middleware/ # custom middleware
models/ # data models
tsconfig.json # pre-configured with path aliases
.env # environment variables template (tailored to your db choices)
README.md # project READMEPath aliases set up in tsconfig.json: @config/*, @lib/*, @middleware/*, @models/*, @queues/*, @routes/*, @scripts/*, @services/*, @workers/*, @service-facades/*, @constants/*.
Database setup
The first prompt asks how to configure database stores:
- Full stack — MongoDB + Redis (production-ready). Choose single connection (auth + app share one server) or separate (auth on its own cluster).
- SQLite — Single
.dbfile, no external services. - Memory — Ephemeral maps, great for prototyping and tests.
- Custom — Choose MongoDB mode, Redis, and individual stores (auth, sessions, cache, OAuth state) independently.
The generated .env is tailored to the selected stores — only the relevant variables are included.
Auth security posture
After database setup, the CLI prompts for an auth security posture. Choose a preset or configure features step by step:
Presets:
- Web app / SaaS — CSRF protection, refresh tokens, bot-fingerprint rate limiting. Includes commented-out stubs for email verification, password reset, and MFA.
- Internal / admin — MFA required for all users, strict password policy (14+ chars + special chars), low login rate limits (5/15min), tight session cap (2 sessions), no refresh tokens.
- Mobile / API only — No CSRF, open CORS (
"*"), long-lived refresh tokens with rotation grace window. - Dev / prototype — Minimal password policy (1+ chars, no complexity), very high rate limits (10,000/min), bearer auth disabled.
Step by step — choose individual features:
- Password policy — Relaxed (8 chars), Strong (12+ chars + special), or Minimal (dev only)
- Email verification — Yes / No
- Password reset — Yes / No
- Refresh tokens — Yes / No
- MFA — None / Optional (users opt in) / Required (all users)
- CSRF protection — Yes / No
- OAuth providers — Google, GitHub, Apple, Microsoft (multi-select; pick "None (done)" to finish)
The selected posture is printed in the end-of-run summary and written directly into src/config/index.ts.
What runs after scaffolding
bun init -y— generatespackage.json, basetsconfig.json, and.gitignore- The CLI patches
package.json— setsmodule,scripts(dev,start), and adds@lastshotlabs/bunshotas a dependency - Writes all source files and the
.envtemplate git initbun install
After that:
cd my-app
# fill in .env with your values
bun devInstallation
Full framework
bun add @lastshotlabs/bunshot hono zodIncludes the full framework, @lastshotlabs/bunshot-auth, and @lastshotlabs/bunshot-core as dependencies. All public APIs are re-exported from the main package.
Requirements: Bun 1.0+ · TypeScript 5.0+
Auth plugin only
Use @lastshotlabs/bunshot-auth standalone with any Hono app — no full Bunshot framework required:
bun add @lastshotlabs/bunshot-auth @lastshotlabs/bunshot-core hono zodSee the bunshot-auth README for setup.
TypeScript path aliases
The CLI scaffold pre-configures tsconfig.json with path aliases. If you're adding Bunshot to an existing project, add these to your tsconfig.json:
{
"compilerOptions": {
"paths": {
"@lib/*": ["./src/lib/*"],
"@models/*": ["./src/models/*"],
"@routes/*": ["./src/routes/*"],
"@middleware/*": ["./src/middleware/*"],
"@services/*": ["./src/services/*"],
"@workers/*": ["./src/workers/*"],
"@config/*": ["./src/config/*"]
}
}
}Full Configuration Example
For production apps, break config into its own file. Here's a real-world setup with MongoDB, Redis, OAuth, and email verification:
// src/config/index.ts
import path from 'path';
import {
type AppMeta,
type AuthConfig,
type CreateServerConfig,
type DbConfig,
type ModelSchemasConfig,
type SecurityConfig,
} from '@lastshotlabs/bunshot';
const app: AppMeta = {
name: 'My App',
version: '1.0.0',
};
const db: DbConfig = {
mongo: 'single', // "single" | "separate" | false
redis: true, // false to skip Redis
sessions: 'redis', // "redis" | "mongo" | "sqlite" | "memory"
cache: 'memory', // default store for cacheResponse
auth: 'mongo', // "mongo" | "sqlite" | "memory"
oauthState: 'memory', // where to store OAuth state tokens
};
const auth: AuthConfig = {
roles: ['admin', 'user'],
defaultRole: 'user',
primaryField: 'email',
rateLimit: { store: 'redis' },
emailVerification: {
required: true,
tokenExpiry: 60 * 60, // 1 hour
onSend: async (email, token) => {
// send verification email using any provider (Resend, SES, etc.)
},
},
oauth: {
postRedirect: 'http://localhost:5175/oauth/callback',
providers: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/google/callback`,
},
apple: {
clientId: process.env.APPLE_CLIENT_ID!,
teamId: process.env.APPLE_TEAM_ID!,
keyId: process.env.APPLE_KEY_ID!,
privateKey: process.env.APPLE_PRIVATE_KEY!,
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/apple/callback`,
},
microsoft: {
tenantId: process.env.MICROSOFT_TENANT_ID!,
clientId: process.env.MICROSOFT_CLIENT_ID!,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/microsoft/callback`,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/github/callback`,
},
},
},
};
const security: SecurityConfig = {
cors: ['*', 'http://localhost:5173'],
botProtection: { fingerprintRateLimit: true },
};
const modelSchemas: ModelSchemasConfig = {
registration: 'auto',
paths: [path.join(import.meta.dir, '../schemas/*.ts')],
};
export const appConfig: CreateServerConfig = {
app,
routesDir: path.join(import.meta.dir, '../routes'),
workersDir: path.join(import.meta.dir, '../workers'),
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
db,
auth,
security,
modelSchemas,
middleware: [
/* your global middleware here */
],
};Every field above is optional except routesDir. See the Configuration section for the full reference.
Built-in endpoints
Core auth — mounted whenever auth is configured:
| Endpoint | Description |
| ---------------------------------- | ------------------------------------------------------------ |
| POST /auth/register | Create account |
| POST /auth/login | Login — returns JWT in body + sets HttpOnly cookie |
| POST /auth/logout | Revoke current session |
| GET /auth/me | Current user profile (requires auth) |
| PATCH /auth/me | Update own profile / user metadata (requires auth) |
| POST /auth/set-password | Set or change password (requires auth) |
| GET /auth/sessions | List active sessions with IP, UA, timestamps (requires auth) |
| DELETE /auth/sessions/:sessionId | Revoke a specific session (requires auth) |
| POST /auth/reauth/verify | Verify a step-up challenge (requires auth) |
Opt-in auth routes — each mounts only when its feature is configured:
| Endpoint | Requires config |
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| POST /auth/verify-email | auth.emailVerification |
| POST /auth/resend-verification | auth.emailVerification |
| POST /auth/forgot-password | auth.passwordReset |
| POST /auth/reset-password | auth.passwordReset |
| POST /auth/refresh | auth.refreshTokens |
| POST /auth/magic-link/request | auth.magicLink |
| POST /auth/magic-link/verify | auth.magicLink |
| DELETE /auth/me | auth.accountDeletion |
| POST /auth/cancel-deletion | auth.accountDeletion with queued: true + gracePeriod |
| POST /auth/mfa/setup · verify-setup · verify · resend · recovery-codes · methods | auth.mfa |
| POST /auth/mfa/email-otp/enable · verify-setup · DELETE | auth.mfa.emailOtp |
| POST /auth/mfa/webauthn/register-options · register · GET credentials · DELETE | auth.mfa.webauthn |
| GET /auth/{provider} · callback · link · DELETE link | auth.oauth.providers |
| POST /auth/oauth/exchange | auth.oauth |
| POST /auth/oauth/reauth/exchange | auth.oauth.reauth |
Admin routes — admin.api: true:
| Endpoint | Description |
| --------------------------------------------------- | ------------------------------ |
| GET /admin/users | List + search + paginate users |
| GET /admin/users/:id · PATCH · DELETE | User detail, update, delete |
| POST /admin/users/:id/roles | Set roles |
| POST /admin/users/:id/suspend · unsuspend | Account suspension |
| DELETE /admin/users/:id/sessions · GET sessions | Session management |
Organization routes — organizations.managementRoutes: true:
| Endpoint | Description |
| --------------------------------------------------------------- | -------------------------- |
| GET /orgs · POST | List / create orgs |
| GET /orgs/:id · PATCH · DELETE | Org detail, update, delete |
| GET /orgs/:id/members · POST · PATCH .../roles · DELETE | Membership management |
| GET /users/:id/orgs | User's orgs |
| POST /orgs/:id/invitations · GET · DELETE · POST accept | Invitation flow |
Framework:
| Endpoint | Description |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| GET /health | Health check |
| GET /docs | Scalar API docs UI |
| GET /openapi.json | OpenAPI spec |
| WS ws.endpoints | WebSocket — per-endpoint via ws: { endpoints: { ... } } config; cookie or ?token= query param auth |
| GET /metrics | Prometheus scrape endpoint (metrics.enabled: true) |
| GET /jobs · /jobs/:queue · /jobs/:queue/:id | Job status (jobs.statusEndpoint: true) |
Minimal SQLite setup
No MongoDB. No Redis. Everything on SQLite — one .db file. Good for small apps, internal tools, and VPS deploys where you want zero external services.
// src/config/index.ts
import path from 'path';
import type { CreateServerConfig } from '@lastshotlabs/bunshot';
export const appConfig: CreateServerConfig = {
app: { name: 'My App', version: '1.0.0' },
routesDir: path.join(import.meta.dir, '../routes'),
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
db: {
auth: 'sqlite',
sqlite: path.join(import.meta.dir, '../../data.db'), // created automatically
mongo: false,
redis: false,
sessions: 'sqlite',
cache: 'sqlite',
},
auth: {
roles: ['admin', 'user'],
defaultRole: 'user',
primaryField: 'email',
},
};Start periodic cleanup of expired rows for long-running servers:
// src/index.ts
import { createServer, startSqliteCleanup } from '@lastshotlabs/bunshot';
import { appConfig } from './config';
startSqliteCleanup(); // sweeps expired sessions/cache rows every hour
await createServer(appConfig);Production config with all services
MongoDB, Redis, OAuth, email verification, rate limiting, MFA, and metrics.
// src/config/index.ts
import path from 'path';
import type { CreateServerConfig } from '@lastshotlabs/bunshot';
export const appConfig: CreateServerConfig = {
app: { name: 'My App', version: '1.0.0' },
routesDir: path.join(import.meta.dir, '../routes'),
workersDir: path.join(import.meta.dir, '../workers'),
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
db: {
mongo: 'single',
redis: true,
sessions: 'redis',
cache: 'redis',
auth: 'mongo',
},
auth: {
roles: ['admin', 'user'],
defaultRole: 'user',
primaryField: 'email',
rateLimit: { store: 'redis' },
emailVerification: {
required: true,
tokenExpiry: 3600,
onSend: async (email, token) => {
// await mailer.send({ to: email, subject: "Verify your email", token });
},
},
passwordReset: {
tokenExpiry: 3600,
onSend: async (email, token) => {
// await mailer.send({ to: email, subject: "Reset your password", token });
},
},
refreshTokens: {
accessTokenExpiry: 900,
refreshTokenExpiry: 2_592_000,
rotationGraceSeconds: 30,
},
mfa: {
emailOtp: {
onSend: async (email, otp) => {
// await mailer.send({ to: email, subject: "Your login code", otp });
},
},
},
oauth: {
postRedirect: process.env.OAUTH_REDIRECT_URL!,
providers: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: `${process.env.API_BASE_URL}/auth/google/callback`,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: `${process.env.API_BASE_URL}/auth/github/callback`,
},
},
},
},
security: {
cors: [process.env.APP_URL!],
botProtection: { fingerprintRateLimit: true },
},
metrics: {
enabled: true,
auth: 'userAuth',
queues: ['email', 'reports'],
},
};Multi-tenant SaaS
Tenant resolved from subdomain, org-based auth, role-per-org membership.
// src/config/index.ts
import path from 'path';
import type { CreateServerConfig } from '@lastshotlabs/bunshot';
export const appConfig: CreateServerConfig = {
app: { name: 'My SaaS', version: '1.0.0' },
routesDir: path.join(import.meta.dir, '../routes'),
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
db: {
mongo: 'single',
redis: true,
sessions: 'redis',
cache: 'redis',
auth: 'mongo',
},
auth: {
roles: ['owner', 'admin', 'member'],
defaultRole: 'member',
primaryField: 'email',
rateLimit: { store: 'redis' },
emailVerification: { required: true, tokenExpiry: 3600, onSend: async () => {} },
},
tenancy: {
resolution: 'subdomain',
onResolve: async tenantId => {
// tenantId is the resolved subdomain, e.g. "acme" from "acme.myapp.com"
// validate/load tenant — return null to reject
return tenantId ? {} : null;
},
exemptPaths: ['/auth/login', '/auth/register', '/health'],
},
security: {
cors: [process.env.APP_URL!],
},
};Bunshot with the community plugin
Add @lastshotlabs/bunshot-community for forum features: containers, threads, replies, reactions, and moderation.
// src/config/index.ts
import path from 'path';
import type { CreateServerConfig } from '@lastshotlabs/bunshot';
import { createCommunityPlugin } from '@lastshotlabs/bunshot-community';
import { createMongoAdapter } from '@lastshotlabs/bunshot-community/adapters/mongo';
const communityPlugin = createCommunityPlugin({
adapter: createMongoAdapter(),
containerCreation: 'admin',
autoModerationHook: async content => {
// return "block" | "flag" | "allow"
return 'allow';
},
});
export const appConfig: CreateServerConfig = {
app: { name: 'My App', version: '1.0.0' },
routesDir: path.join(import.meta.dir, '../routes'),
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
db: {
mongo: 'single',
redis: true,
sessions: 'redis',
cache: 'redis',
auth: 'mongo',
},
auth: {
roles: ['admin', 'moderator', 'user'],
defaultRole: 'user',
primaryField: 'email',
},
plugins: [communityPlugin],
};Adding Routes
Drop a file in your routes/ directory that exports a router — see the Quick Start example above. Routes are auto-discovered via glob — no registration needed. Subdirectories are supported, so you can organise by feature:
routes/
products.ts
ingredients/
list.ts
detail.tsOpenAPI Schema Registration
Import createRoute from @lastshotlabs/bunshot (not from @hono/zod-openapi). The wrapper automatically registers every unnamed request body and response schema as a named entry in components/schemas. Schemas you already named via registerSchema are never overwritten.
Every Zod schema that appears in your OpenAPI spec ends up as a named entry in components/schemas — either auto-named by the framework or explicitly named by you. There are four registration methods, each suited to a different scenario.
Method 1 — Route-level auto-registration (via createRoute)
The most common case. When you define a route with createRoute, every unnamed request body and response schema is automatically registered under a name derived from the HTTP method and path.
Naming convention
| Route | Part | Generated name |
| ----------------------- | ------------ | --------------------------------- |
| POST /products | request body | CreateProductsRequest |
| POST /products | 201 response | CreateProductsResponse |
| GET /products/{id} | 200 response | GetProductsByIdResponse |
| DELETE /products/{id} | 404 response | DeleteProductsByIdNotFoundError |
| PATCH /products/{id} | request body | UpdateProductsByIdRequest |
HTTP methods → verbs: GET → Get, POST → Create, PUT → Replace, PATCH → Update, DELETE → Delete.
Status codes → suffixes: 200/201/204 → Response, 400 → BadRequestError, 401 → UnauthorizedError, 403 → ForbiddenError, 404 → NotFoundError, 409 → ConflictError, 422 → ValidationError, 429 → RateLimitError, 500 → InternalError, 501 → NotImplementedError, 503 → UnavailableError. Unknown codes fall back to the number.
Limitation: if the same Zod object is used in two different routes, each route names it after itself — you get two identical inline shapes instead of one shared $ref. Use Method 2 or 3 to fix this.
Method 2 — Directory / glob auto-discovery (via modelSchemas)
Use this when you have schemas shared across multiple routes. Point modelSchemas at one or more directories and Bunshot imports every .ts file before routes are loaded. Any exported Zod schema is registered automatically — same object referenced in multiple routes → same $ref in the spec.
Naming: export name with the trailing Schema suffix stripped (LedgerItemSchema → "LedgerItem"). Already-registered schemas are never overwritten.
// src/schemas/ledgerItem.ts
import { z } from 'zod';
export const LedgerItemSchema = z.object({ id: z.string(), name: z.string(), amount: z.number() });
// → auto-registered as "LedgerItem"// src/config/index.ts
await createServer({
routesDir: import.meta.dir + '/routes',
modelSchemas: import.meta.dir + '/schemas', // string shorthand — registration: "auto"
});// src/routes/ledger.ts AND src/routes/ledgerDetail.ts
import { LedgerItemSchema } from '@schemas/ledgerItem';
// same Zod object instance
createRoute({
responses: { 200: { content: { 'application/json': { schema: LedgerItemSchema } } } },
});
// → $ref: "#/components/schemas/LedgerItem" in both routesMultiple directories and glob patterns
modelSchemas: [
import.meta.dir + '/schemas', // dedicated schemas dir
import.meta.dir + '/models', // co-located with DB models
import.meta.dir + '/services/**/*.schema.ts', // selective glob
];Full config object — use when you need to set registration or mix paths and globs:
modelSchemas: {
paths: [import.meta.dir + "/schemas", import.meta.dir + "/models"],
registration: "auto", // default — auto-registers exports with suffix stripping
}registration: "explicit" — files are imported but nothing is auto-registered. Registration is left entirely to registerSchema / registerSchemas calls inside each file. Use this when you want zero magic and full name control:
modelSchemas: { paths: import.meta.dir + "/schemas", registration: "explicit" }Method 3 — Batch explicit registration (via registerSchemas)
registerSchemas lets you name a group of schemas all at once. Object keys become the components/schemas names; the same object is returned so you can destructure and export normally. No suffix stripping — names are taken as-is.
// src/schemas/index.ts
import { z } from 'zod';
import { registerSchemas } from '@lastshotlabs/bunshot';
export const { LedgerItem, Product, ErrorResponse } = registerSchemas({
LedgerItem: z.object({ id: z.string(), name: z.string(), amount: z.number() }),
Product: z.object({ id: z.string(), price: z.number() }),
ErrorResponse: z.object({ error: z.string() }),
});Pair with registration: "explicit" in modelSchemas so the file is imported before routes, or call it inline at the top of any route file — route files are auto-discovered so the top-level call runs before the spec is served.
Method 4 — Single explicit registration (via registerSchema)
registerSchema("Name", schema) registers one schema and returns it unchanged. Useful for a single shared type (e.g. a common error envelope) or to override the name auto-discovery would generate.
// src/schemas/errors.ts
import { z } from 'zod';
import { registerSchema } from '@lastshotlabs/bunshot';
export const ErrorResponse = registerSchema('ErrorResponse', z.object({ error: z.string() }));Registration is idempotent — calling registerSchema on an already-registered schema is a no-op. This means you can safely call it in files that are also covered by modelSchemas auto-discovery: whichever runs first wins, and the other is silently skipped.
Priority and interaction
All four methods write to the same process-global registry. The rules are simple:
- First write wins — once a schema has a name, it cannot be renamed.
modelSchemasfiles are imported before routes, so explicit calls inside them always take precedence over whatcreateRoutewould generate for the same object.registerSchema/registerSchemastake precedence over auto-discovery when they appear at module top level (they run at import time, beforemaybeAutoRegisterinspects the export list).createRoutenever overwrites a schema already in the registry — it only fills gaps.
Decision guide:
| Situation | Use |
| ------------------------------------------------------------ | ------------------------------------------ |
| Route-specific, one-off schema | createRoute auto-registration (Method 1) |
| Shared across routes, happy with suffix-stripped export name | modelSchemas auto-discovery (Method 2) |
| Shared across routes, want explicit names or batch control | registerSchemas (Method 3) |
| Single shared schema or custom name override | registerSchema (Method 4) |
Protected routes
Use withSecurity to declare security schemes on a route without breaking c.req.valid() type inference. (Inlining security directly in createRoute({...}) causes TypeScript to collapse the handler's input types to never.)
import { createRoute, withSecurity } from "@lastshotlabs/bunshot";
router.openapi(
withSecurity(
createRoute({ method: "get", path: "/me", ... }),
{ cookieAuth: [] },
{ userToken: [] }
),
async (c) => {
const userId = c.get("authUserId"); // fully typed
}
);Pass each security scheme as a separate object argument. The security scheme names (cookieAuth, userToken, bearerAuth) are registered globally by createApp.
Load order: By default, routes load in filesystem order. If a route needs to be registered before another (e.g. for Hono's first-match-wins routing), export a priority number — lower values load first. Routes without a priority load last.
// routes/tenants.ts — must match before generic routes
export const priority = 1;
export const router = createRouter();
// ...MongoDB Connections
MongoDB and Redis connect automatically inside createServer / createApp. Control the behavior via the db config object:
Single database (default)
Both auth and app data share one server. Uses MONGO_* env vars.
await createServer({
// ...
db: { mongo: 'single', redis: true }, // these are the defaults — can omit db entirely
// app, auth, security are all optional with sensible defaults
});Separate auth database
Auth users live on a dedicated server (MONGO_AUTH_* env vars), app data on its own server (MONGO_* env vars). Useful when multiple tenant apps share one auth cluster.
await createServer({
// ...
db: { mongo: 'separate' },
});Manual connections
Set mongo: false and/or redis: false to skip auto-connect and manage connections yourself:
import {
connectAppMongo,
connectAuthMongo,
connectRedis,
createServer,
} from '@lastshotlabs/bunshot';
await connectAuthMongo();
await connectAppMongo();
await connectRedis();
await createServer({
// ...
db: { mongo: false, redis: false },
});AuthUser and all built-in auth routes always use authConnection. Your app models use appConnection (see Adding Models below).
Adding Models
Import appConnection and register models on it. This ensures your models use the correct connection whether you're on a single DB or a separate tenant DB.
appConnection is a lazy proxy — calling .model() at the top level works fine even before connectMongo() has been called. Mongoose buffers any queries until the connection is established.
// src/models/Product.ts
import { Schema } from 'mongoose';
import type { HydratedDocument } from 'mongoose';
import { appConnection } from '@lastshotlabs/bunshot';
interface IProduct {
name: string;
price: number;
}
export type ProductDocument = HydratedDocument<IProduct>;
const ProductSchema = new Schema<IProduct>(
{
name: { type: String, required: true },
price: { type: Number, required: true },
},
{ timestamps: true },
);
export const Product = appConnection.model<IProduct>('Product', ProductSchema);Note: Import types (
HydratedDocument,Schema, etc.) directly from"mongoose"— theappConnectionandmongooseexports from bunshot are runtime proxies and cannot be used as TypeScript namespaces.
Zod as Single Source of Truth
If you use Zod schemas for your OpenAPI spec (via createRoute or modelSchemas), you can derive your Mongoose schemas and DTO mappers from those same Zod definitions — so each entity is defined once.
zodToMongoose — Zod → Mongoose SchemaDefinition
Converts a Zod object schema into a Mongoose field definition. Business fields are auto-converted; DB-specific concerns (ObjectId refs, type overrides, subdocuments) are declared via config. The id field is automatically excluded since Mongoose provides _id.
import { type HydratedDocument, Schema } from 'mongoose';
import { appConnection, zodToMongoose } from '@lastshotlabs/bunshot';
import { ProductSchema } from '../schemas/product';
// your Zod schema
import type { ProductDto } from '../schemas/product';
// DB interface derives from Zod DTO type
interface IProduct extends Omit<ProductDto, 'id' | 'categoryId'> {
user: Types.ObjectId;
category: Types.ObjectId;
}
const ProductMongoSchema = new Schema<IProduct>(
zodToMongoose(ProductSchema, {
dbFields: {
user: { type: Schema.Types.ObjectId, ref: 'UserProfile', required: true },
},
refs: {
categoryId: { dbField: 'category', ref: 'Category' },
},
typeOverrides: {
createdAt: { type: Date, required: true },
},
}) as Record<string, unknown>,
{ timestamps: true },
);
export type ProductDocument = HydratedDocument<IProduct>;
export const Product = appConnection.model<IProduct>('Product', ProductMongoSchema);Config options:
| Option | Description |
| --------------- | ---------------------------------------------------------------------------------------------------------- |
| dbFields | Fields that exist only in the DB, not in the API schema (e.g., user ObjectId ref) |
| refs | API fields that map to ObjectId refs: { accountId: { dbField: "account", ref: "Account" } } |
| typeOverrides | Override the auto-converted Mongoose type for a field (e.g., Zod z.string() for dates → Mongoose Date) |
| subdocSchemas | Subdocument array fields: { items: mongooseSubSchema } |
Auto-conversion mapping:
| Zod type | Mongoose type |
| ----------------------------- | -------------------- |
| z.string() | String |
| z.number() | Number |
| z.boolean() | Boolean |
| z.date() | Date |
| z.enum([...]) | String with enum |
| .nullable() / .optional() | required: false |
createDtoMapper — Zod → toDto mapper
Creates a generic toDto function from a Zod schema. The schema defines which fields exist in the DTO; the config declares how to transform DB-specific types.
import { createDtoMapper } from '@lastshotlabs/bunshot';
import { type ProductDto, ProductSchema } from '../schemas/product';
const toDto = createDtoMapper<ProductDto>(ProductSchema, {
refs: { category: 'categoryId' }, // ObjectId ref → string, with rename
dates: ['createdAt'], // Date → ISO string
});
// Use it
const product = await Product.findOne({ _id: id });
return product ? toDto(product) : null;Auto-handled transforms:
| Transform | Description |
| ----------------- | -------------------------------------------------------------------- |
| _id → id | Always converted via .toString() |
| refs | ObjectId fields → string (.toString()), with DB→API field renaming |
| dates | Date objects → ISO strings (.toISOString()) |
| subdocs | Array fields mapped with a sub-mapper (for nested documents) |
| nullable/optional | undefined → null coercion (based on Zod schema) |
| everything else | Passthrough |
Subdocument example:
const itemToDto = createDtoMapper<TemplateItemDto>(TemplateItemSchema);
const toDto = createDtoMapper<TemplateDto>(TemplateSchema, {
subdocs: { items: itemToDto },
});Authentication
Bunshot ships a complete auth system: credential login, OAuth social providers (Google, Apple, Microsoft, GitHub, LinkedIn, Twitter/X, GitLab, Slack, Bitbucket), multi-factor authentication (TOTP, email OTP, WebAuthn), magic link / passwordless login, session management, roles, groups, organizations, and security hardening (CSRF, rate limiting, account lockout, credential stuffing detection, bot protection). Everything is opt-in — add only what your app needs.
How it works
Auth has two independent layers you configure separately:
| Layer | What it stores | Configured via | Default |
| ----------------- | ------------------------------ | --------------------------- | ---------------------------- |
| Auth adapter | Users, passwords, roles | auth.adapter or db.auth | MongoDB (mongoAuthAdapter) |
| Session store | Active sessions (JWT metadata) | db.sessions | Redis |
When a user logs in, the auth adapter verifies their credentials and the session store creates a record for the new session. The JWT embeds a sessionId claim that ties the token to that record — revoking a session immediately invalidates its token even before the JWT expires.
Minimum working setup
Enable auth by passing an auth block. The routes are mounted automatically.
await createServer({
routesDir: import.meta.dir + '/routes',
plugins: [
createAuthPlugin({
auth: {
roles: ['admin', 'user'],
defaultRole: 'user',
},
}),
],
});This mounts POST /auth/register, POST /auth/login, POST /auth/logout, GET /auth/me, and the session management endpoints. Users are stored in MongoDB, sessions in Redis.
Environment variables required:
MONGO_URI_DEV=mongodb://localhost:27017/myapp
REDIS_URL_DEV=redis://localhost:6379
JWT_SECRET_DEV=at-least-32-characters-long-secretChoosing a store
| Setup | db.auth | db.sessions | When to use |
| ----------------------- | ---------- | ------------- | ---------------------------------- |
| Default (Mongo + Redis) | "mongo" | "redis" | Production with both services |
| Mongo only | "mongo" | "mongo" | When Redis is unavailable |
| SQLite | "sqlite" | "sqlite" | Lightweight deploys, embedded DBs |
| Memory | "memory" | "memory" | Tests, local dev — lost on restart |
// MongoDB sessions instead of Redis
await createServer({
db: { redis: false, sessions: 'mongo' },
plugins: [
createAuthPlugin({
/* ... */
}),
],
});
// SQLite — single file, no external services
await createServer({
db: {
mongo: false,
redis: false,
sqlite: import.meta.dir + '/data.db',
auth: 'sqlite',
sessions: 'sqlite',
},
plugins: [
createAuthPlugin({
/* ... */
}),
],
});
// In-memory — great for tests
await createServer({
db: { mongo: false, redis: false, auth: 'memory', sessions: 'memory' },
plugins: [
createAuthPlugin({
/* ... */
}),
],
});Protecting routes
import { requireRole, requireVerifiedEmail, userAuth } from '@lastshotlabs/bunshot';
router.use('/me', userAuth); // 401 if not logged in
router.use('/admin', userAuth, requireRole('admin')); // 403 if wrong role
router.use('/content', userAuth, requireRole('admin', 'editor')); // either role passes
router.use('/settings', userAuth, requireVerifiedEmail); // 403 if email unverifiedFeature map
Everything beyond basic credential login is opt-in:
| Feature | How to enable | Docs |
| ------------------------------------ | --------------------------------------- | ----------------------------------- |
| Email verification | auth.emailVerification | Auth Flow |
| Password reset | auth.passwordReset | Auth Flow |
| Magic link / passwordless | auth.magicLink | Auth Flow |
| Refresh tokens | auth.refreshTokens | Auth Flow |
| MFA (TOTP, email OTP, WebAuthn) | auth.mfa | Auth Flow |
| Social login (OAuth) | auth.oauth.providers | Social Login |
| Roles & groups | auth.roles, groups | Roles |
| Organizations | organizations | Auth Flow |
| CSRF protection | security.csrf | Auth Flow |
| Bot protection | security.botProtection | Auth Flow |
| Account lockout | auth.lockout | Auth Flow |
| Credential stuffing detection | auth.credentialStuffing | Auth Flow |
| Registration enumeration concealment | auth.concealRegistration | Auth Flow |
| Password history (prevent reuse) | auth.passwordPolicy.preventReuse | Auth Flow |
| Reauth / step-up challenges | createReauthChallenge | Auth Flow |
| Security event emission | security.events | Auth Flow |
| Data encryption at rest | BUNSHOT_DATA_ENCRYPTION_KEY | Auth Flow |
| Built-in email templates | email.templates | Auth Flow |
| Admin API | admin.api | Auth Flow |
| Account deletion | auth.accountDeletion | Auth Flow |
| Account suspension | setSuspended / getSuspended | Auth Flow |
| Audit logging | auditLog middleware / logAuditEntry | Auth Flow |
| Captcha enforcement | requireCaptcha / verifyCaptcha | Auth Flow |
| SAML 2.0 | auth.saml | Auth Flow |
| OIDC (OpenID Connect) | auth.oidc | Auth Flow |
| SCIM 2.0 provisioning | auth.scim | Auth Flow |
| M2M / service auth | createM2MClient / bearer tokens | Auth Flow |
| Custom user store | auth.adapter | Auth Flow |
Custom auth adapter
The default adapter stores users in MongoDB. Pass auth.adapter to use any other store — Postgres, SQLite, an external identity provider, etc. Only implement the methods your app uses:
import type { AuthAdapter } from '@lastshotlabs/bunshot';
const myAdapter: AuthAdapter = {
async findByEmail(email) {
const user = await db.query('SELECT id, passwordHash FROM users WHERE email = $1', [email]);
return user ?? null;
},
async create(email, passwordHash) {
const [user] = await db.query(
'INSERT INTO users (email, passwordHash) VALUES ($1, $2) RETURNING id',
[email, passwordHash],
);
return { id: user.id };
},
async getRoles(userId) {
const user = await db.query('SELECT roles FROM users WHERE id = $1', [userId]);
return user?.roles ?? [];
},
async setRoles(userId, roles) {
await db.query('UPDATE users SET roles = $2 WHERE id = $1', [userId, roles]);
},
};
await createServer({
plugins: [
createAuthPlugin({
auth: { adapter: myAdapter, roles: ['admin', 'user'], defaultRole: 'user' },
}),
],
});The full adapter interface and all optional methods (OAuth, MFA, tenant roles, groups, etc.) are covered in Auth Flow.
Auth Flow
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.
Browser clients
POST /auth/login→ JWT set as HttpOnly cookie automatically- All subsequent requests send the cookie — no extra code needed
API / non-browser clients
POST /auth/login→ readtokenfrom response body- Send
x-user-token: <token>header on every request
Current user
GET /auth/me → { userId, email?, emailVerified?, googleLinked? }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.
Session management
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.
GET /auth/sessions → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
DELETE /auth/sessions/:sessionId → revoke a specific session (other sessions unaffected)
POST /auth/logout → revoke only the current sessionSession 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.
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.
Sliding sessions
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.
Refresh Tokens
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.
await createServer({
plugins: [
createAuthPlugin({
auth: {
refreshTokens: {
accessTokenExpiry: 900, // seconds, default: 900 (15 min)
refreshTokenExpiry: 2_592_000, // seconds, default: 2_592_000 (30 days)
rotationGraceSeconds: 30, // default: 30 — old token still works briefly after rotation
},
},
}),
],
});When not configured, the existing 7-day JWT behavior is unchanged — fully backward compatible.
Endpoints
| Endpoint | Purpose |
| --------------------- | ----------------------------------------------------------- |
| POST /auth/login | Returns token + refreshToken |
| POST /auth/register | Returns token + refreshToken |
| POST /auth/refresh | Rotates refresh token, returns new token + refreshToken |
Rotation with grace window
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.
Cookie behavior
The refresh token is set as an HttpOnly cookie (refresh_token) alongside the existing session cookie. For non-browser clients, it is accepted via the x-refresh-token header or in the request body. The refresh token is not returned in the JSON response body — it is only delivered via the HttpOnly cookie to prevent accidental exposure in logs or client-side code.
MFA / TOTP
Enable multi-factor authentication with TOTP (Google Authenticator, Authy, etc.):
await createServer({
plugins: [
createAuthPlugin({
auth: {
mfa: {
issuer: 'My App', // shown in authenticator apps (default: app name)
algorithm: 'SHA1', // default, most compatible
digits: 6, // default
period: 30, // seconds, default
recoveryCodes: 10, // number of recovery codes, default: 10
challengeTtlSeconds: 300, // MFA challenge window, default: 5 min
},
},
}),
],
});Requires otpauth peer dependency:
bun add otpauthEndpoints
| Endpoint | Auth | Purpose |
| ------------------------------- | -------------------- | ------------------------------------------------ |
| POST /auth/mfa/setup | userAuth | Generate TOTP secret + otpauth URI (for QR code) |
| POST /auth/mfa/verify-setup | userAuth | Confirm with TOTP code, returns recovery codes |
| POST /auth/mfa/verify | none (uses mfaToken) | Complete login after password verified |
| DELETE /auth/mfa | userAuth | Disable all MFA (requires TOTP code) |
| POST /auth/mfa/recovery-codes | userAuth | Regenerate codes (requires TOTP code) |
| GET /auth/mfa/methods | userAuth | Get enabled MFA methods |
Login flow with MFA enabled
POST /auth/loginwith credentials → password OK + MFA enabled →{ mfaRequired: true, mfaToken: "...", mfaMethods: ["totp"] }(no session created)POST /auth/mfa/verifywith{ mfaToken, code }→ verifies TOTP or recovery code → creates session → returns normal token response
The verify endpoint accepts an optional method field ("totp", "emailOtp", or "webauthn") to target a specific verification method. When omitted, methods are tried automatically based on what was provided (webauthnResponse → webauthn, code → emailOtp if active else TOTP).
OAuth logins skip MFA — the OAuth provider is treated as the second factor.
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.
Enforcing MFA for all users
By default MFA is opt-in — individual users choose whether to enable it. Set required: true to enforce MFA at the app level:
await createServer({
plugins: [
createAuthPlugin({
auth: {
mfa: {
issuer: 'My App',
required: true, // all authenticated users must complete MFA setup
},
},
}),
],
});When required is true:
- Authenticated users who have not completed MFA setup receive a
403response on all non-auth endpoints:{ "error": "MFA setup required", "code": "MFA_SETUP_REQUIRED" } - 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/. - Unauthenticated requests pass through normally — the middleware only gates users who are logged in but lack MFA.
- 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.
- Disabling MFA (via
DELETE /auth/mfa) whenrequired: trueimmediately blocks the user from service endpoints until they re-enable it.
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.
Per-route usage: The requireMfaSetup middleware is also exported for apps that want manual, per-route enforcement instead of global:
import { requireMfaSetup, userAuth } from '@lastshotlabs/bunshot';
router.get('/dashboard', userAuth, requireMfaSetup, c => {
return c.json({ message: 'Welcome' });
});Email OTP
An alternative to TOTP that sends a one-time code to the user's email. Users can enable TOTP, email OTP, or both.
await createServer({
plugins: [
createAuthPlugin({
auth: {
mfa: {
challengeTtlSeconds: 300,
emailOtp: {
onSend: async (email, code) => {
await sendEmail(email, `Your login code: ${code}`);
},
codeLength: 6, // default
},
},
},
}),
],
});Endpoints
| Endpoint | Auth | Purpose |
| --------------------------------------- | -------------------- | ------------------------------------------- |
| POST /auth/mfa/email-otp/enable | userAuth | Send verification code to email |
| POST /auth/mfa/email-otp/verify-setup | userAuth | Confirm code, enable email OTP |
| DELETE /auth/mfa/email-otp | userAuth | Disable email OTP |
| POST /auth/mfa/resend | none (uses mfaToken) | Resend email OTP code (max 3 per challenge) |
Setup flow
POST /auth/mfa/email-otp/enable→ sends code to email → returns{ setupToken }POST /auth/mfa/email-otp/verify-setupwith{ setupToken, code }→ enables email OTP → returns recovery codes
This two-step flow ensures the onSend callback actually delivers emails before MFA is activated, preventing lockout from misconfigured email providers.
Login flow with email OTP
POST /auth/login→{ mfaRequired: true, mfaToken, mfaMethods: ["emailOtp"] }— code is auto-sent to user's emailPOST /auth/mfa/verifywith{ mfaToken, code }→ creates session- If the code didn't arrive:
POST /auth/mfa/resendwith{ mfaToken }(max 3 resends, capped at 3x challenge TTL)
Disabling email OTP
- If TOTP is also enabled: requires a TOTP code in the
codefield - If email OTP is the only method: requires the account password in the
passwordfield - Disabling the last MFA method turns off MFA entirely
WebAuthn / Security Keys
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.
await createServer({
plugins: [
createAuthPlugin({
auth: {
mfa: {
webauthn: {
rpId: 'example.com', // Relying Party ID — your domain
origin: 'https://example.com', // Expected origin(s)
rpName: 'My App', // Display name (default: app name)
userVerification: 'preferred', // "required" | "preferred" | "discouraged"
timeout: 60000, // Ceremony timeout in ms (default: 60000)
strictSignCount: false, // Reject when sign count goes backward (default: false — warn only)
},
},
},
}),
],
});Requires @simplewebauthn/server peer dependency:
bun add @simplewebauthn/serverIf mfa.webauthn is configured but the dependency is missing, the server fails fast at startup with a clear error message.
Endpoints
| Endpoint | Auth | Purpose |
| ----------------------------------------------------- | -------- | ------------------------------------------------------------------ |
| POST /auth/mfa/webauthn/register-options | userAuth | Generate registration options for navigator.credentials.create() |
| POST /auth/mfa/webauthn/register | userAuth | Verify attestation and store credential |
| GET /auth/mfa/webauthn/credentials | userAuth | List registered security keys |
| DELETE /auth/mfa/webauthn/credentials/:credentialId | userAuth | Remove a single key |
| DELETE /auth/mfa/webauthn | userAuth | Disable WebAuthn entirely |
Registration flow
POST /auth/mfa/webauthn/register-options→ returns{ options, registrationToken }- Client passes
optionstonavigator.credentials.create()— browser prompts user to tap/scan key POST /auth/mfa/webauthn/registerwith{ registrationToken, attestationResponse, name? }→ stores credential → returns recovery codes
Credentials are registered with
residentKey: "required"anduserVerification: "required", making them usable as discoverable passkeys. This enables passwordless login viaallowPasswordlessLoginwithout requiring users to re-register.
Login flow with WebAuthn
POST /auth/login→{ mfaRequired: true, mfaToken, mfaMethods: ["webauthn"], webauthnOptions: {...} }- Client passes
webauthnOptionstonavigator.credentials.get()— browser prompts for key POST /auth/mfa/verifywith{ mfaToken, webauthnResponse: {...} }→ creates session
The webauthnOptions object follows the WebAuthn spec — pass it directly to navigator.credentials.get(). The webauthnResponse is the full result from the browser API.
Credential removal
- Removing a spare key (other keys or MFA methods still active): no extra verification needed
- Removing the last credential of the last MFA method: requires TOTP code or password
DELETE /auth/mfa/webauthn(disable all): always requires verification
Sign count validation
WebAuthn authenti
