@xenterprises/fastify-xauth-better
v2.1.2
Published
Production-ready Fastify plugin for Better Auth with multi-instance support, organizations, 2FA, audit logging, and email templates
Readme
@xenterprises/fastify-xauth-better
Production-ready Fastify plugin for Better Auth with multi-instance support, organizations, 2FA, audit logging, and email templates.
Install
npm install @xenterprises/fastify-xauth-better better-auth @prisma/clientQuick 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',
},
],
});
await fastify.listen({ port: 3000 });Options
Plugin Options
| Name | Type | Default | Required | Description |
|------|------|---------|----------|-------------|
| configs | XAuthBetterConfig[] | — | Yes | Array of auth instance configurations (must be non-empty) |
| prisma | PrismaClient | fastify.prisma | No | Prisma client instance. 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 valid URL) |
| basePath | string | /api/auth | No | Path prefix for Better Auth routes |
| prefix | string | /api | No | Routes starting with this prefix are protected |
| excludedPaths | Array | [] | No | Paths to skip auth middleware (strings, 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 origins for CORS |
| databaseProvider | string | postgresql | No | Prisma database provider (postgresql, mysql, sqlite) |
| requestProperty | string | auth | No | Property name for session on request |
| userProperty | string | user | No | Property name for user on request |
| 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 org support |
| 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, user management) |
| advanced | object | See defaults | No | Cookie, session, and rate limit settings |
| templates | object | See 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 in production | Use secure cookies |
| 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 events | Events to log |
| auditLog.retention | number | 365 | Retention period in days |
| auditLog.captureIp | boolean | true | Capture client IP |
| auditLog.captureUserAgent | boolean | true | Capture user agent |
Decorated Properties
fastify.xauthbetter
| Property | Type | Description |
|----------|------|-------------|
| get(name) | function | Get a specific auth instance by name |
| default | XAuthBetterInstance | First registered instance |
| configs | Record<string, XAuthBetterInstance> | All registered instances |
| pruneAuditLogs(options?) | function | Delete old audit log entries |
Instance API
Each instance returned by get(name) or default provides:
| Property | Type | Description |
|----------|------|-------------|
| auth | Auth | Raw Better Auth instance |
| config | object | Merged configuration |
| auditLog | AuditLogger | Audit logger (log(event, data)) |
| templateRenderer | TemplateRenderer | Email template renderer |
| getSession(request) | function | Get session from request headers/cookies |
| requireAuth() | function | Returns auth middleware |
| requireRole(roles) | function | Returns global role middleware |
| requireOrgRole(roles) | function | Returns org-scoped role middleware |
| requireOrg() | function | Returns org membership middleware |
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');Middleware Usage
Auth Protection
Routes under a configured prefix are automatically protected. You can also use middleware directly:
fastify.get('/api/admin/dashboard', {
preHandler: [fastify.xauthbetter.default.requireRole(['admin', 'superadmin'])],
}, async (request) => {
return { user: request.user };
});Organization Membership
fastify.get('/orgs/:orgId/projects', {
preHandler: [userAuth.requireOrg()],
}, async (request) => {
return { organization: request.organization };
});Organization Roles
fastify.put('/orgs/:orgId/settings', {
preHandler: [userAuth.requireOrgRole(['owner', 'admin'])],
}, async (request) => {
return { updated: true };
});Excluded Paths
{
excludedPaths: [
'/api/public', // string prefix
/^\/api\/webhooks/, // regex
{ url: '/api/health', methods: ['GET'] } // url + methods
]
}Organizations
{
organizations: {
enabled: true,
orgIdHeader: 'X-Organization-Id',
orgIdFromUrl: /^\/orgs\/([^\/]+)/, // extract from URL path
}
}Organization context resolution priority: URL path > HTTP header > session.
2FA Configuration
{
twoFactor: {
enabled: true,
email: true, // requires email service plugin
sms: true, // requires @xenterprises/fastify-xtwilio
totp: true, // authenticator app
}
}Audit Logging
Allowed Events
14 audit events are supported:
auth.login.success,auth.login.failed,auth.logoutauth.password.changed,auth.password.reset.requested,auth.password.reset.completedauth.2fa.enabled,auth.2fa.disabledauth.session.revokedauth.account.linked,auth.account.bannedauth.org.joined,auth.org.left,auth.org.role.changed
Manual Logging
const instance = fastify.xauthbetter.get('user');
await instance.auditLog.log('auth.login.success', {
userId: 'user_123',
metadata: { method: 'email' },
request,
});Pruning
// Delete logs older than 365 days
await fastify.xauthbetter.pruneAuditLogs({ olderThanDays: 365 });
// Dry run (count only, no deletion)
const { count } = await fastify.xauthbetter.pruneAuditLogs({
olderThanDays: 90,
dryRun: true,
});Email Templates
6 built-in templates with variable substitution:
| Template | Variables | Description |
|----------|-----------|-------------|
| verification | userName, url, appName | Email verification |
| passwordReset | userName, url, appName | Password reset link |
| magicLink | userName, url, appName | Passwordless sign-in |
| twoFactorOTP | userName, code, appName | 2FA verification code |
| orgInvite | userName, orgName, inviterName, url, appName | Organization invitation |
| accountLinked | userName, appName | Account linked notification |
Custom Templates
{
templates: {
verification: {
subject: 'Welcome {{userName}}!',
html: '<html>Custom template with {{url}}</html>',
},
}
}SendGrid Template Override
{
templates: {
verification: {
templateId: 'd-abc123xyz', // SendGrid dynamic template ID
},
}
}Email sending requires @xenterprises/fastify-xemail or email-outbox plugin.
Environment Variables
| Name | Required | Description |
|------|----------|-------------|
| AUTH_SECRET | Yes | Auth secret (min 32 chars) — pass via config, not read directly |
| DATABASE_URL | Yes | PostgreSQL connection string for Prisma |
| NODE_ENV | No | Set to production for secure cookies |
| GOOGLE_CLIENT_ID | No | Google OAuth client ID |
| GOOGLE_CLIENT_SECRET | No | Google OAuth client secret |
| GITHUB_CLIENT_ID | No | GitHub OAuth client ID |
| GITHUB_CLIENT_SECRET | No | GitHub OAuth client secret |
Errors
| Error | When |
|-------|------|
| xAuthBetter: "configs" must be a non-empty array | Missing or empty configs |
| xAuthBetter: Prisma client is required | No prisma in options or fastify.prisma |
| xAuthBetter: Config "name" is required | Instance config missing name |
| xAuthBetter: 'secret' is required and must be a string | Missing secret |
| xAuthBetter: 'secret' must be at least 32 characters long | Short secret |
| xAuthBetter: 'baseURL' must be a valid URL | Invalid baseURL |
| xAuthBetter: Duplicate instance name | Two configs with same name |
| xAuthBetter: Duplicate basePath | Two configs with same basePath |
| xAuthBetter: Duplicate cookiePrefix | Two configs with same cookie prefix |
| xAuthBetter: 2FA SMS is enabled but xTwilio plugin is not registered | SMS 2FA without Twilio plugin |
| Invalid audit event: {event} | Logging an unrecognized event |
Prisma Schema
Add these models to your schema (see prisma/schema.prisma for complete reference):
User— user accounts with email, name, roleSession— active sessions with tokens and expiryAccount— linked OAuth/credential accountsOrganization— multi-tenant organizationsMember— org membership with rolesInvitation— org invitationsAuthAuditLog— security audit trail
How It Works
The plugin creates one or more Better Auth instances, each with its own cookie namespace, basePath, and middleware scope. On registration:
- Each config is validated against a strict schema and merged with sensible defaults.
- A Better Auth instance is created with Prisma adapter, configured plugins (admin, bearer, 2FA, magic links, organizations), and email template integration.
- A catch-all Fastify route (
basePath/*) forwards requests to Better Auth's handler, converting between Fastify request/reply and Web API Request/Response. - If
prefixis set, anonRequesthook validates sessions for all matching routes (excludingbasePathand configuredexcludedPaths). - Everything is exposed via
fastify.xauthbetterdecorator for programmatic access.
License
UNLICENSED
