fastify-xauth-better
fastify-xauth-better
Production-ready Fastify plugin for Better Auth with multi-instance support, organizations, 2FA, audit logging, and email templates. Supports multiple simultaneous auth configurations (e.g., admin + user) each with independent cookie namespaces, route prefixes, and middleware.
Installation
npm install @xenterprises/fastify-xauth-better better-auth @prisma/client
Quick Start
import Fastify from "fastify";
import xAuthBetter from "@xenterprises/fastify-xauth-better";
import { PrismaClient } from "@prisma/client";
const fastify = Fastify();
const prisma = new PrismaClient();
await fastify.register(xAuthBetter, {
prisma,
configs: [
{
name: "user",
secret: process.env.AUTH_SECRET, // min 32 chars
baseURL: "http://localhost:3000",
basePath: "/api/auth",
prefix: "/api",
},
],
});
// Protected route — session validated automatically
fastify.get("/api/profile", async (request) => {
return { user: request.user };
});
await fastify.listen({ port: 3000 });
Options
Plugin Options
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
configs | XAuthBetterConfig[] | — | Yes | Array of auth instance configs (must be non-empty) |
prisma | PrismaClient | fastify.prisma | No | Prisma client; falls back to fastify.prisma decorator |
Instance Config (XAuthBetterConfig)
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name | string | — | Yes | Unique identifier for this auth instance |
secret | string | — | Yes | Auth secret, minimum 32 characters |
baseURL | string | — | Yes | Base URL for auth callbacks (must be a valid URL) |
basePath | string | /api/auth | No | Path prefix for Better Auth routes |
prefix | string | /api | No | Routes starting with this prefix are protected by auth middleware |
excludedPaths | Array | [] | No | Paths to skip auth middleware — string, RegExp, or { url, methods } |
roles | string[] | [] | No | Valid role names for this instance |
appName | string | App | No | Application name used in email templates |
trustedOrigins | string[] | [] | No | Trusted CORS origins |
databaseProvider | string | postgresql | No | Prisma database provider (postgresql, mysql, sqlite) |
requestProperty | string | auth | No | Request property name for the raw session object |
userProperty | string | user | No | Request property name for the user object |
emailAndPassword | object | { enabled: true } | No | Email/password auth settings |
socialProviders | object | {} | No | OAuth providers — google, facebook, github, microsoft |
organizations | object | { enabled: false } | No | Multi-tenant organization support — orgIdHeader, orgIdFromUrl (see Organizations) |
twoFactor | object | { enabled: false } | No | 2FA settings — email, sms, totp |
magicLinks | object | { enabled: false } | No | Passwordless auth via magic links |
bearerTokens | object | { enabled: true } | No | API bearer token support |
admin | object | { enabled: true } | No | Admin plugin — impersonation and user management |
advanced | object | See below | No | Cookie, session, and rate limit settings |
templates | object | Built-in defaults | No | Email template overrides |
auditLog | object | { enabled: true } | No | Audit logging configuration |
extraOptions | object | {} | No | Pass-through to Better Auth config |
Advanced Options
| Name | Type | Default | Description |
|---|---|---|---|
advanced.cookiePrefix | string | {name}_auth | Cookie prefix — auto-generated from instance name |
advanced.useSecureCookies | boolean | true | Use secure cookies — static secure-by-default value; set false explicitly for local development over plain HTTP |
advanced.crossSubDomainCookies | boolean | false | Share cookies across subdomains |
advanced.session.expiresIn | number | 604800 | Session TTL in seconds (7 days) |
advanced.session.updateAge | number | 86400 | Session refresh interval in seconds (1 day) |
Audit Log Options
| Name | Type | Default | Description |
|---|---|---|---|
auditLog.enabled | boolean | true | Enable audit logging |
auditLog.events | string[] | 19-event default list | Subscription filter over the 30-event vocabulary — events not listed are skipped |
auditLog.retention | number | 365 | Retention in days |
auditLog.captureIp | boolean | true | Capture client IP |
auditLog.captureUserAgent | boolean | true | Capture user agent |
Methods
fastify.xAuthBetter decorator
| Property | Type | Description |
|---|---|---|
get(name) | (name: string) => XAuthBetterInstance | Get a specific auth instance by name |
default | XAuthBetterInstance | First registered instance |
configs | Record<string, XAuthBetterInstance> | All registered instances |
pruneAuditLogs(options?) | (options?) => Promise<{ count: number, deleted: boolean }> | Delete old audit log entries |
Instance methods
Each instance returned by fastify.xAuthBetter.get(name) or .default exposes:
- requireAuth() — Returns an auth-required preHandler middleware
- requireRole(roles) — Returns a global role-based access control preHandler
- requireOrg() — Returns org-membership-required preHandler; populates
request.organization - requireOrgRole(roles) — Returns an org-scoped role preHandler (requires
requireOrg()first) - getSession(request) — Resolves session from headers/cookies without middleware
- auditLog.log(event, data) — Writes an audit event to
AuthAuditLog - pruneAuditLogs(options) — Deletes old audit log records
Request Properties
When prefix is configured, the auth middleware sets:
| Property | Description |
|---|---|
request.user | Authenticated user object |
request.auth | Raw session object { session, user } |
request.organization | Organization context (only when requireOrg() runs first) |
Multi-Instance Setup
await fastify.register(xAuthBetter, {
prisma,
configs: [
{
name: "admin",
secret: process.env.ADMIN_SECRET,
baseURL: "http://localhost:3000",
basePath: "/api/auth/admin",
prefix: "/api/admin",
roles: ["superadmin", "admin"],
},
{
name: "user",
secret: process.env.USER_SECRET,
baseURL: "http://localhost:3000",
basePath: "/api/auth/user",
prefix: "/api/user",
roles: ["contractor", "homeowner"],
},
],
});
const adminAuth = fastify.xAuthBetter.get("admin");
const userAuth = fastify.xAuthBetter.get("user");
Organizations
{
organizations: {
enabled: true,
orgIdHeader: "X-Organization-Id", // default
orgIdFromUrl: /^\/orgs\/([^\/]+)/, // RegExp — first capture group is the org ID (default: null)
}
}
When organizations.enabled is true, requireOrg() resolves the organization ID in this order:
- URL path —
organizations.orgIdFromUrlis matched againstrequest.url; the first capture group wins. - HTTP header — the
organizations.orgIdHeaderrequest header (looked up case-insensitively). - Session — the session's
activeOrganizationId(requiresrequireAuth()or aprefixto have populatedrequest.authfirst). - Route param — falls back to
request.params.orgIdwhen none of the above produce an ID.
When organizations are disabled (the default), requireOrg() uses request.params.orgId only.
If no org ID can be resolved, the middleware responds 400 Bad Request. The resolved ID is validated through Better Auth's organization API: 404 Not Found if the org doesn't exist or isn't accessible, 403 Forbidden if the authenticated user is not a member. On success, request.organization is set to the full org data (id, name, slug, logo, metadata, createdAt, members) plus the current user's org-scoped role. Membership is always validated server-side — a user cannot access another organization's data by switching the header or URL.
Email Templates
6 built-in templates with {{variable}} substitution:
| Template | Variables | Description |
|---|---|---|
verification | userName, url, appName | Email verification link |
passwordReset | userName, url, appName | Password reset link |
magicLink | userName, url, appName | Passwordless sign-in link |
twoFactorOTP | userName, code, appName | 2FA verification code |
orgInvite | userName, orgName, inviterName, url, appName | Org invitation |
accountLinked | userName, appName | Account linked notification |
Email delivery requires @xenterprises/fastify-xemail or the email-outbox plugin to be registered.
Error Reference
Registration fails fast with real Errors. All registration messages start with xauthbetter: and name the option with a usage example:
| Error | Cause |
|---|---|
xauthbetter: missing required option `configs` (non-empty array of auth instance configurations), e.g. `app.register(xAuthBetter, { configs: [...], prisma })` | configs missing or empty |
xauthbetter: missing required option `prisma` (PrismaClient instance), e.g. `app.register(xAuthBetter, { prisma, configs: [...] })` — or register a plugin that decorates `fastify.prisma` first | No prisma in options and no fastify.prisma decorator |
xauthbetter: missing required option `name` (string) for each auth instance, e.g. ... | Instance config missing name |
xauthbetter: missing required option `secret` (string, at least 32 characters) for each auth instance, e.g. ... | Missing secret |
xauthbetter: option `secret` must be at least 32 characters long — use a cryptographically random string | secret shorter than 32 chars |
xauthbetter: missing required option `baseURL` (valid URL string) for each auth instance, e.g. ... | Missing baseURL |
xauthbetter: option `baseURL` must be a valid URL, e.g. 'https://api.example.com' (got: ...) | Invalid baseURL |
xauthbetter: duplicate instance name "…" | Two configs share the same name |
xauthbetter: duplicate `basePath` "…" | Two configs share the same basePath |
xauthbetter: duplicate `advanced.cookiePrefix` "…" | Two configs share the same cookie prefix |
xauthbetter [name]: `twoFactor.sms` is enabled but the xTwilio plugin is not registered — register @xenterprises/fastify-xtwilio first | SMS 2FA enabled without the Twilio plugin |
xauthbetter: option `organizations.orgIdFromUrl` must be a RegExp or null | orgIdFromUrl is neither a RegExp nor null |
Invalid audit event: {event}. Allowed events: ... | auditLog.log() called with an event outside the 30-event vocabulary |
At runtime, middleware sends standard Fastify error bodies: 401 (no/invalid session), 403 (role or org membership failure), 400 (missing org id), 404 (organization not found), 500 (org context load failure). Middleware logs error.message only — never full error objects — so auth-library errors can't leak request context into logs.
Environment Variables
This plugin never reads process.env. All values — secrets, OAuth credentials, URLs — arrive via app.register(xAuthBetter, { ... }) options. Set env vars in your own config layer (e.g. @xenterprises/fastify-xconfig) and pass them in:
| Variable (consumer-set) | Passed in as | Description |
|---|---|---|
DATABASE_URL | Your own new PrismaClient() → prisma option | Prisma connection string — the plugin never constructs a client |
e.g. AUTH_SECRET | configs[].secret | Auth secret, min 32 chars — must be passed explicitly per instance |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | configs[].socialProviders.google | Google OAuth credentials (only if Google OAuth is enabled) |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | configs[].socialProviders.github | GitHub OAuth credentials (only if GitHub OAuth is enabled) |
Secure cookies are not driven by NODE_ENV: advanced.useSecureCookies defaults to true statically — set it to false explicitly for local development over plain HTTP. Note that Better Auth itself may consult env vars internally for its own defaults; explicit config values always take precedence.
How It Works
On registration each config is validated and merged with defaults, then a Better Auth instance is created with a Prisma adapter and the configured plugins (admin, bearer, 2FA, magic links, organizations). A catch-all Fastify route at basePath/* bridges Fastify request/reply to the Web API Request/Response format expected by Better Auth's handler. When prefix is set, an onRequest hook validates sessions for all matching routes (skipping basePath and any excludedPaths), then attaches request.user, request.auth, and optionally request.organization for downstream handlers. All instances are exposed via fastify.xAuthBetter for programmatic access to middleware factories, audit logging, and session utilities.
AI Context
package: "@xenterprises/fastify-xauth-better"
type: fastify-plugin
use-when: Production auth with Better Auth — multi-instance, organizations, 2FA, magic links, audit logging, social OAuth
decorator: fastify.xAuthBetter (get, default, configs, pruneAuditLogs)
request-decorators: request.user, request.auth, request.organization
requires: Prisma client with Better Auth schema, secret ≥ 32 chars per config
env: consumer-set only — the plugin never reads process.env; pass secrets/OAuth credentials via register options (DATABASE_URL feeds your own Prisma client)
multi-instance: each configs[] entry gets its own auth instance, route prefix, and cookie namespace
