X Enterprises

fastify-xauth-better

Production-ready Better Auth plugin for Fastify with multi-instance support, organizations, 2FA, audit logging, and email templates.

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

NameTypeDefaultRequiredDescription
configsXAuthBetterConfig[]YesArray of auth instance configs (must be non-empty)
prismaPrismaClientfastify.prismaNoPrisma client; falls back to fastify.prisma decorator

Instance Config (XAuthBetterConfig)

NameTypeDefaultRequiredDescription
namestringYesUnique identifier for this auth instance
secretstringYesAuth secret, minimum 32 characters
baseURLstringYesBase URL for auth callbacks (must be a valid URL)
basePathstring/api/authNoPath prefix for Better Auth routes
prefixstring/apiNoRoutes starting with this prefix are protected by auth middleware
excludedPathsArray[]NoPaths to skip auth middleware — string, RegExp, or { url, methods }
rolesstring[][]NoValid role names for this instance
appNamestringAppNoApplication name used in email templates
trustedOriginsstring[][]NoTrusted CORS origins
databaseProviderstringpostgresqlNoPrisma database provider (postgresql, mysql, sqlite)
requestPropertystringauthNoRequest property name for the raw session object
userPropertystringuserNoRequest property name for the user object
emailAndPasswordobject{ enabled: true }NoEmail/password auth settings
socialProvidersobject{}NoOAuth providers — google, facebook, github, microsoft
organizationsobject{ enabled: false }NoMulti-tenant organization support — orgIdHeader, orgIdFromUrl (see Organizations)
twoFactorobject{ enabled: false }No2FA settings — email, sms, totp
magicLinksobject{ enabled: false }NoPasswordless auth via magic links
bearerTokensobject{ enabled: true }NoAPI bearer token support
adminobject{ enabled: true }NoAdmin plugin — impersonation and user management
advancedobjectSee belowNoCookie, session, and rate limit settings
templatesobjectBuilt-in defaultsNoEmail template overrides
auditLogobject{ enabled: true }NoAudit logging configuration
extraOptionsobject{}NoPass-through to Better Auth config

Advanced Options

NameTypeDefaultDescription
advanced.cookiePrefixstring{name}_authCookie prefix — auto-generated from instance name
advanced.useSecureCookiesbooleantrueUse secure cookies — static secure-by-default value; set false explicitly for local development over plain HTTP
advanced.crossSubDomainCookiesbooleanfalseShare cookies across subdomains
advanced.session.expiresInnumber604800Session TTL in seconds (7 days)
advanced.session.updateAgenumber86400Session refresh interval in seconds (1 day)

Audit Log Options

NameTypeDefaultDescription
auditLog.enabledbooleantrueEnable audit logging
auditLog.eventsstring[]19-event default listSubscription filter over the 30-event vocabulary — events not listed are skipped
auditLog.retentionnumber365Retention in days
auditLog.captureIpbooleantrueCapture client IP
auditLog.captureUserAgentbooleantrueCapture user agent

Methods

fastify.xAuthBetter decorator

PropertyTypeDescription
get(name)(name: string) => XAuthBetterInstanceGet a specific auth instance by name
defaultXAuthBetterInstanceFirst registered instance
configsRecord<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:

Request Properties

When prefix is configured, the auth middleware sets:

PropertyDescription
request.userAuthenticated user object
request.authRaw session object { session, user }
request.organizationOrganization 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:

  1. URL pathorganizations.orgIdFromUrl is matched against request.url; the first capture group wins.
  2. HTTP header — the organizations.orgIdHeader request header (looked up case-insensitively).
  3. Session — the session's activeOrganizationId (requires requireAuth() or a prefix to have populated request.auth first).
  4. Route param — falls back to request.params.orgId when 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:

TemplateVariablesDescription
verificationuserName, url, appNameEmail verification link
passwordResetuserName, url, appNamePassword reset link
magicLinkuserName, url, appNamePasswordless sign-in link
twoFactorOTPuserName, code, appName2FA verification code
orgInviteuserName, orgName, inviterName, url, appNameOrg invitation
accountLinkeduserName, appNameAccount 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:

ErrorCause
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 stringsecret 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 firstSMS 2FA enabled without the Twilio plugin
xauthbetter: option `organizations.orgIdFromUrl` must be a RegExp or nullorgIdFromUrl 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 asDescription
DATABASE_URLYour own new PrismaClient()prisma optionPrisma connection string — the plugin never constructs a client
e.g. AUTH_SECRETconfigs[].secretAuth secret, min 32 chars — must be passed explicitly per instance
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETconfigs[].socialProviders.googleGoogle OAuth credentials (only if Google OAuth is enabled)
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRETconfigs[].socialProviders.githubGitHub 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
Copyright © 2026