A comprehensive Better Auth integration for NestJS with Fastify adapter.
- Features
- Installation
- Quick Start
- Decorators Reference
- Hook System
- AuthService API
- Type Inference
- Configuration
- Multi-Context Support
- Utility Functions
- Request Extension
- Testing
- Requirements
- Contributing
- License
- π Seamless Integration - Drop-in Better Auth support for NestJS + Fastify
- π― Decorator-based - Intuitive decorators for authentication & authorization
- π¦ Plugin Support - Full support for Better Auth plugins (Admin, Organization, API Key, Bearer, etc.)
- π Multi-Context - Works with HTTP, GraphQL, and WebSocket
- πͺ Hook System - NestJS-native hooks for auth lifecycle events
- π¨ Type-Safe - Full TypeScript support with type inference from your auth config
- β‘ Performance - Optimized with lazy loading for optional dependencies
- π i18n Ready - Customizable error messages for internationalization
# npm
npm install @sapix/nestjs-better-auth-fastify better-auth
# pnpm
pnpm add @sapix/nestjs-better-auth-fastify better-auth
# yarn
yarn add @sapix/nestjs-better-auth-fastify better-authInstall these based on your needs:
# For GraphQL support
pnpm add @nestjs/graphql graphql
# For WebSocket support
pnpm add @nestjs/websockets @nestjs/platform-socket.io// src/auth/auth.config.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '../db';
export const auth = betterAuth({
basePath: '/api/auth',
database: drizzleAdapter(db, { provider: 'postgresql' }),
emailAndPassword: { enabled: true },
// Add more plugins as needed
});
// Export type for type inference
export type Auth = typeof auth;// src/app.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from '@sapix/nestjs-better-auth-fastify';
import { auth } from './auth/auth.config';
@Module({
imports: [
AuthModule.forRoot({
auth,
// basePath is optional - reads from auth.options.basePath by default
}),
],
})
export class AppModule {}// src/user/user.controller.ts
import { Controller, Get, Post } from '@nestjs/common';
import {
AllowAnonymous,
Session,
CurrentUser,
Roles,
UserSession,
} from '@sapix/nestjs-better-auth-fastify';
@Controller('user')
export class UserController {
// All routes are protected by default
@Get('profile')
getProfile(@Session() session: UserSession) {
return session;
}
// Public route - no authentication required
@Get('public')
@AllowAnonymous()
getPublicData() {
return { message: 'This is public' };
}
// Role-based access control
@Get('admin')
@Roles(['admin'])
getAdminData(@CurrentUser() user: UserSession['user']) {
return { message: `Hello admin ${user.name}` };
}
}| Decorator | Description | Example |
|---|---|---|
@AllowAnonymous() |
Skip authentication check | Public endpoints |
@OptionalAuth() |
Auth optional, session injected if present | Mixed-access endpoints |
@Roles(['admin']) |
Require specific roles | Admin-only routes |
@Permissions(['read']) |
Require specific permissions | Permission-based access |
@RequireFreshSession() |
Require recently authenticated session | Sensitive operations |
// OR logic (default): user needs ANY of the roles
@Roles(['admin', 'moderator'])
// AND logic: user needs ALL roles
@Roles(['admin', 'verified'], { mode: 'all' })
// Custom error message
@Roles(['admin'], { message: 'Administrator access required' })
// Permission-based (same options available)
@Permissions(['user:read', 'user:write'], { mode: 'any' })
@Permissions(['read:posts', 'write:posts', 'delete:posts'], { mode: 'all' })// Use default freshAge (from auth config, defaults to 1 day)
@RequireFreshSession()
@Post('change-password')
changePassword() {}
// Custom freshAge (5 minutes = 300 seconds)
@RequireFreshSession({ maxAge: 300 })
@Post('enable-2fa')
enable2FA() {}
// Custom error message
@RequireFreshSession({ message: 'Please re-authenticate to continue' })
@Delete('account')
deleteAccount() {}Requires
admin()plugin frombetter-auth/plugins
import { admin } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [admin()],
});| Decorator | Description |
|---|---|
@AdminOnly() |
Admin role required |
@BanCheck() |
Real-time ban check (Better Auth only checks at session creation) |
@DisallowImpersonation() |
Block impersonated sessions |
@SecureAdminOnly() |
Combined: Admin + Fresh + No Impersonation |
// High-security admin operation
@SecureAdminOnly()
@Delete('admin/users/:id')
deleteUser() {
// Only real admins with fresh sessions can execute
}
// Real-time ban check - useful for users banned after session creation
@BanCheck()
@Post('comments')
createComment() {}
// Prevent impersonated sessions from sensitive operations
@DisallowImpersonation()
@Post('transfer-funds')
transferFunds() {}
// Custom error message
@AdminOnly('Administrator privileges required')
@Get('admin/dashboard')
getDashboard() {}Requires
bearer()plugin frombetter-auth/plugins
import { bearer } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [bearer()],
});// Enable Bearer token authentication
@BearerAuth()
@Get('api/mobile/data')
getMobileData() {}Client usage:
curl -H "Authorization: Bearer <session-token>" /api/mobile/dataRequires
apiKey()plugin frombetter-auth/plugins
import { apiKey } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [apiKey()],
});// API Key only
@ApiKeyAuth()
@Get('api/external')
externalApi(@ApiKey() apiKey: ApiKeyValidation['key']) {
return { keyId: apiKey.id, permissions: apiKey.permissions };
}
// API Key or Session (flexible mode)
@ApiKeyAuth({ allowSession: true })
@Get('api/flexible')
flexibleApi() {}
// With permission requirements
@ApiKeyAuth({
permissions: {
permissions: { files: ['read', 'write'] },
message: 'Requires files read/write permissions',
},
})
@Post('api/files')
uploadFile() {}Client usage:
curl -H "x-api-key: <api-key>" /api/externalNote: API keys must be sent via dedicated headers (default:
x-api-key). Custom headers can be configured via Better Auth'sapiKeypluginapiKeyHeadersoption. Do NOT useAuthorization: Bearerfor API keys - that's reserved for session tokens.
Requires
organization()plugin frombetter-auth/plugins
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
organization({
roles: {
owner: { inherit: ['admin'] },
admin: { inherit: ['member'] },
member: { permissions: ['read'] },
},
}),
],
});| Decorator | Description |
|---|---|
@OrgRequired() |
Require organization context |
@OrgRoles(['owner']) |
Require organization roles |
@OrgPermission({...}) |
Require organization permissions |
// Require organization context
@OrgRequired()
@Get('org/dashboard')
getOrgDashboard(@CurrentOrg() org: Organization) {
return { name: org.name };
}
// Require owner or admin role
@OrgRoles(['owner', 'admin'])
@Put('org/settings')
updateOrgSettings() {}
// Multiple roles with AND logic
@OrgRoles(['admin', 'billing'], { mode: 'all' })
@Post('org/billing')
manageBilling() {}
// Fine-grained permission check
@OrgPermission({ resource: 'member', action: 'create' })
@Post('org/members')
inviteMember() {}
// Multiple actions with AND logic
@OrgPermission({ resource: 'member', action: ['read', 'update'], mode: 'all' })
@Put('org/members/:id')
updateMember() {}
// Custom error message
@OrgPermission({
resource: 'invite',
action: 'create',
message: 'You do not have permission to invite members',
})
@Post('org/invitations')
createInvitation() {}Client usage (must include organization ID):
curl -H "x-organization-id: <org-id>" /org/dashboard| Decorator | Description | Type |
|---|---|---|
@Session() |
Full session object | UserSession |
@CurrentUser() |
Current user | UserSession['user'] |
@UserProperty('id') |
Specific user property | string |
@ApiKey() |
API Key info | ApiKeyValidation['key'] |
@CurrentOrg() |
Current organization | Organization |
@OrgMember() |
Organization membership | OrganizationMember |
@IsImpersonating() |
Impersonation status | boolean |
@ImpersonatedBy() |
Impersonator admin ID | string | null |
@Get('me')
getMe(
@CurrentUser() user: UserSession['user'],
@UserProperty('email') email: string,
@UserProperty('id') userId: string,
@IsImpersonating() isImpersonating: boolean,
@ImpersonatedBy() adminId: string | null,
) {
return { user, email, userId, isImpersonating, adminId };
}
@OrgRequired()
@Get('org/context')
getOrgContext(
@CurrentOrg() org: Organization,
@OrgMember() member: OrganizationMember,
) {
return { org, member };
}The hook system allows you to execute custom logic before and after Better Auth processes authentication requests.
// src/hooks/sign-up.hook.ts
import { Injectable } from '@nestjs/common';
import {
Hook,
BeforeHook,
AfterHook,
AuthHookContext,
} from '@sapix/nestjs-better-auth-fastify';
@Hook()
@Injectable()
export class SignUpHook {
constructor(
private readonly emailService: EmailService,
private readonly crmService: CrmService,
) {}
// Validate before sign-up
@BeforeHook('/sign-up/email')
async validateBeforeSignUp(ctx: AuthHookContext) {
const { email } = ctx.body as { email: string };
if (email.endsWith('@blocked-domain.com')) {
throw new Error('This email domain is not allowed');
}
}
// Send welcome email after sign-up
@AfterHook('/sign-up/email')
async sendWelcomeEmail(ctx: AuthHookContext) {
const user = ctx.context?.user;
if (user) {
await this.emailService.sendWelcome(user.email);
await this.crmService.createContact(user);
}
}
// Log all auth requests (no path = matches all routes)
@BeforeHook()
async logAuthRequest(ctx: AuthHookContext) {
console.log('Auth request:', ctx.path);
}
}// src/app.module.ts
@Module({
imports: [AuthModule.forRoot({ auth })],
providers: [SignUpHook], // Register hook provider
})
export class AppModule {}| Path | Description |
|---|---|
/sign-up/email |
Email sign-up |
/sign-in/email |
Email sign-in |
/sign-out |
Sign out |
/forget-password |
Forgot password |
/reset-password |
Reset password |
/verify-email |
Email verification |
AuthService provides programmatic access to Better Auth functionality.
import { Injectable } from '@nestjs/common';
import { AuthService, UserSession } from '@sapix/nestjs-better-auth-fastify';
import type { Auth } from './auth/auth.config';
@Injectable()
export class MyService {
constructor(private readonly authService: AuthService<Auth>) {}
async someMethod(request: FastifyRequest) {
// Get session from request
const session = await this.authService.getSessionFromRequest(request);
// Validate session (throws UnauthorizedException if invalid)
const validSession = await this.authService.validateSession(request);
// Check roles
if (this.authService.hasRole(session, ['admin'])) {
// User is admin
}
// Check permissions
if (this.authService.hasPermission(session, ['user:read', 'user:write'], 'all')) {
// User has all required permissions
}
// Check session freshness
if (!this.authService.isSessionFresh(session)) {
// Require re-authentication
}
// Access Better Auth API directly
const accounts = await this.authService.api.listUserAccounts({
headers: getWebHeadersFromRequest(request),
});
}
}// Revoke a specific session
await this.authService.revokeSession(sessionToken, request);
// Revoke all user sessions
await this.authService.revokeAllSessions(request);
// List all user sessions
const sessions = await this.authService.listUserSessions(request);// Check if user is banned
if (this.authService.isUserBanned(session.user)) {
throw new ForbiddenException('User is banned');
}
// Check impersonation status
if (this.authService.isImpersonating(session)) {
const adminId = this.authService.getImpersonatedBy(session);
// Log for audit
}const result = await this.authService.verifyApiKey(apiKey);
if (result.valid) {
console.log('Key belongs to user:', result.key?.userId);
console.log('Permissions:', result.key?.permissions);
}
// With permission requirements
const result = await this.authService.verifyApiKey(apiKey, {
files: ['read', 'write'],
});// Get active organization
const org = await this.authService.getActiveOrganization(request);
// Check organization permission
const hasPermission = await this.authService.hasOrgPermission(request, {
resource: 'member',
action: 'create',
});const jwt = await this.authService.getJwtToken(request);
if (jwt) {
// Use JWT for service-to-service communication
}// Get the complete Better Auth instance
const authInstance = this.authService.instance;
// Get the configured basePath
const basePath = this.authService.basePath;The library supports full type inference from your Better Auth configuration.
import { AuthService } from '@sapix/nestjs-better-auth-fastify';
import type { Auth } from './auth/auth.config';
@Injectable()
export class MyService {
constructor(private readonly authService: AuthService<Auth>) {}
async getUser(request: FastifyRequest) {
// Session type is automatically inferred from your auth config
const session = await this.authService.getSessionFromRequest(request);
// session.user includes all fields from your auth config
}
}
// Get types directly (compile-time only)
type Session = typeof authService.$Infer.Session;
type User = typeof authService.$Infer.User;import { InferSession, InferUser } from '@sapix/nestjs-better-auth-fastify';
import type { Auth } from './auth/auth.config';
type MySession = InferSession<Auth>;
type MyUser = InferUser<Auth>;interface CustomUser extends BaseUser {
role: string;
permissions: string[];
department: string;
}
@Get('profile')
getProfile(@Session() session: UserSession<CustomUser>) {
return session.user.department; // Type-safe
}AuthModule.forRoot({
// Required: Better Auth instance
auth,
// Optional: Authentication route prefix
// Defaults to auth.options.basePath or '/api/auth'
basePath: '/api/auth',
// Optional: Disable global AuthGuard
// Set true to manually apply guards on specific routes
disableGlobalGuard: false,
// Optional: Enable debug logging
debug: false,
// Optional: Custom middleware wrapping the auth handler
// Useful for ORM contexts (e.g., MikroORM RequestContext)
middleware: async (req, reply, next) => {
await next();
},
// Optional: Custom error messages (useful for i18n)
errorMessages: {
unauthorized: 'Please log in first',
forbidden: 'Insufficient permissions',
sessionNotFresh: 'Please re-login to perform this action',
userBanned: 'Your account has been banned',
orgRequired: 'Please select an organization first',
orgRoleRequired: 'Insufficient organization role permissions',
orgPermissionRequired: 'You do not have permission for this operation',
apiKeyRequired: 'Valid API Key required',
apiKeyInvalidPermissions: 'API Key has insufficient permissions',
},
// Optional: Custom organization role permissions
// Override the default role-permission mapping
orgRolePermissions: {
owner: { organization: 'all', member: 'all' },
admin: { organization: ['read', 'update'], member: ['read', 'create'] },
member: { organization: ['read'] },
},
});// Using useFactory
AuthModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
auth: createAuth(config.get('AUTH_SECRET')),
basePath: config.get('AUTH_BASE_PATH'),
}),
inject: [ConfigService],
});
// Using useClass
AuthModule.forRootAsync({
useClass: AuthConfigService,
});
// Using useExisting
AuthModule.forRootAsync({
imports: [ConfigModule],
useExisting: ConfigService,
});If you prefer to apply auth on specific routes only:
AuthModule.forRoot({
auth,
disableGlobalGuard: true,
});Then use @UseGuards(AuthGuard) on specific routes:
import { UseGuards } from '@nestjs/common';
import { AuthGuard } from '@sapix/nestjs-better-auth-fastify';
@Controller('protected')
@UseGuards(AuthGuard)
export class ProtectedController {
@Get()
getData() {
return { protected: true };
}
}Works out of the box with Fastify HTTP adapter.
// Install dependencies
pnpm add @nestjs/graphql graphql
// Decorators work the same way in resolvers
@Resolver()
export class UserResolver {
@Query(() => User)
@Roles(['admin'])
async users(@CurrentUser() user: UserSession['user']) {
return this.userService.findAll();
}
}// Install dependencies
pnpm add @nestjs/websockets @nestjs/platform-socket.io
// Decorators work in gateways
@WebSocketGateway()
export class EventsGateway {
@SubscribeMessage('events')
handleEvent(@Session() session: UserSession) {
return { user: session.user };
}
}The library exports utility functions for working with Fastify and Web standard APIs:
import {
toWebHeaders,
toWebRequest,
getHeadersFromRequest,
getWebHeadersFromRequest,
writeWebResponseToReply,
normalizeBasePath,
} from '@sapix/nestjs-better-auth-fastify';
// Convert Fastify headers to Web standard Headers
const webHeaders = toWebHeaders(request.headers);
// Get Web standard Headers from Fastify Request
const headers = getWebHeadersFromRequest(request);
// Build Web standard Request from Fastify Request
const webRequest = toWebRequest(request);
// Write Web Response to Fastify Reply
await writeWebResponseToReply(response, reply);
// Normalize basePath (ensures starts with /, no trailing /)
const path = normalizeBasePath('api/auth/'); // '/api/auth'The library extends FastifyRequest with auth-related properties:
declare module 'fastify' {
interface FastifyRequest {
session: UserSession | null;
user: UserSession['user'] | null;
apiKey?: ApiKeyValidation['key'] | null;
organization?: Organization | null;
organizationMember?: OrganizationMember | null;
isImpersonating?: boolean;
impersonatedBy?: string | null;
}
}Access directly in route handlers:
@Get('profile')
getProfile(@Req() request: FastifyRequest) {
return {
user: request.user,
session: request.session,
org: request.organization,
isImpersonating: request.isImpersonating,
};
}import { Test } from '@nestjs/testing';
import { AuthModule, AuthService, AUTH_MODULE_OPTIONS } from '@sapix/nestjs-better-auth-fastify';
const module = await Test.createTestingModule({
imports: [AuthModule.forRoot({ auth, disableGlobalGuard: true })],
}).compile();
const authService = module.get(AuthService);const mockAuthService = {
getSessionFromRequest: jest.fn().mockResolvedValue(mockSession),
validateSession: jest.fn().mockResolvedValue(mockSession),
hasRole: jest.fn().mockReturnValue(true),
hasPermission: jest.fn().mockReturnValue(true),
isSessionFresh: jest.fn().mockReturnValue(true),
isUserBanned: jest.fn().mockReturnValue(false),
isImpersonating: jest.fn().mockReturnValue(false),
};
const module = await Test.createTestingModule({
providers: [
MyService,
{ provide: AuthService, useValue: mockAuthService },
],
}).compile();- Node.js >= 18.0.0
- NestJS >= 10.0.0
- Fastify >= 4.0.0
- Better Auth >= 1.0.0
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
MIT
Made with β€οΈ for the NestJS community
