npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.ts

Out 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 checkGET /health
  • WebSocket — multi-endpoint via ws.endpoints config, 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

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-token header

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') or getBunshotCtx(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 noeviction policy)
  • WebSocket: Bun native WebSocket — rooms, heartbeat, presence, message persistence; WsTransportAdapter for horizontal scaling
  • Metrics: Prometheus-compatible /metrics endpoint, 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-dir

The 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 README

Path 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 .db file, 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

  1. bun init -y — generates package.json, base tsconfig.json, and .gitignore
  2. The CLI patches package.json — sets module, scripts (dev, start), and adds @lastshotlabs/bunshot as a dependency
  3. Writes all source files and the .env template
  4. git init
  5. bun install

After that:

cd my-app
# fill in .env with your values
bun dev

Installation

Full framework

bun add @lastshotlabs/bunshot hono zod

Includes 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 zod

See 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 routesadmin.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 routesorganizations.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.ts

OpenAPI 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 routes

Multiple 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:

  1. First write wins — once a schema has a name, it cannot be renamed.
  2. modelSchemas files are imported before routes, so explicit calls inside them always take precedence over what createRoute would generate for the same object.
  3. registerSchema / registerSchemas take precedence over auto-discovery when they appear at module top level (they run at import time, before maybeAutoRegister inspects the export list).
  4. createRoute never 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" — the appConnection and mongoose exports 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 | | ----------------- | -------------------------------------------------------------------- | | _idid | 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 | undefinednull 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-secret

Choosing 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 unverified

Feature 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

  1. POST /auth/login → JWT set as HttpOnly cookie automatically
  2. All subsequent requests send the cookie — no extra code needed

API / non-browser clients

  1. POST /auth/login → read token from response body
  2. 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 session

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.

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 otpauth
Endpoints

| 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
  1. POST /auth/login with credentials → password OK + MFA enabled → { mfaRequired: true, mfaToken: "...", mfaMethods: ["totp"] } (no session created)
  2. POST /auth/mfa/verify with { 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 403 response 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) when required: true immediately 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
  1. POST /auth/mfa/email-otp/enable → sends code to email → returns { setupToken }
  2. POST /auth/mfa/email-otp/verify-setup with { 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
  1. POST /auth/login{ mfaRequired: true, mfaToken, mfaMethods: ["emailOtp"] } — code is auto-sent to user's email
  2. POST /auth/mfa/verify with { mfaToken, code } → creates session
  3. If the code didn't arrive: POST /auth/mfa/resend with { mfaToken } (max 3 resends, capped at 3x challenge TTL)
Disabling email OTP
  • If TOTP is also enabled: requires a TOTP code in the code field
  • If email OTP is the only method: requires the account password in the password field
  • 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/server

If 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
  1. POST /auth/mfa/webauthn/register-options → returns { options, registrationToken }
  2. Client passes options to navigator.credentials.create() — browser prompts user to tap/scan key
  3. POST /auth/mfa/webauthn/register with { registrationToken, attestationResponse, name? } → stores credential → returns recovery codes

Credentials are registered with residentKey: "required" and userVerification: "required", making them usable as discoverable passkeys. This enables passwordless login via allowPasswordlessLogin without requiring users to re-register.

Login flow with WebAuthn
  1. POST /auth/login{ mfaRequired: true, mfaToken, mfaMethods: ["webauthn"], webauthnOptions: {...} }
  2. Client passes webauthnOptions to navigator.credentials.get() — browser prompts for key
  3. POST /auth/mfa/verify with { 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