Skip to main content

Quick Start: Fastify

Add authentication to a Fastify application using nauth-toolkit's framework-agnostic core. By the end of this guide you'll have signup, login, logout, token refresh, and a protected profile endpoint working.

Sample Application

A complete working example is available at github.com/noorixorg/nauth/tree/main/fastify — Fastify + TypeORM + PostgreSQL with Google OAuth and TOTP MFA preconfigured.

Live Demo

See nauth-toolkit in action at demo.nauth.dev — a working demo app showcasing signup, login, MFA, social auth, and session management.

Prerequisites

  • Node.js 22+ (older versions may work but are untested)
  • PostgreSQL or MySQL database running
  • A Fastify application (or npm create fastify@latest)

Installation

This example uses PostgreSQL. For MySQL, replace @nauth-toolkit/database-typeorm-postgres with @nauth-toolkit/database-typeorm-mysql and pg with mysql2.

npm install @nauth-toolkit/core @nauth-toolkit/database-typeorm-postgres @nauth-toolkit/storage-database @nauth-toolkit/email-console @nauth-toolkit/sms-console @fastify/cookie @fastify/cors dotenv typeorm pg fastify

Configuration

Create the auth configuration file. DatabaseStorageAdapter is passed null repositories here — NAuth.create() injects them from your DataSource automatically.

src/config/auth.config.ts
import { NAuthConfig } from '@nauth-toolkit/core';
import { DatabaseStorageAdapter } from '@nauth-toolkit/storage-database';
import { ConsoleEmailProvider } from '@nauth-toolkit/email-console';
import { ConsoleSMSProvider } from '@nauth-toolkit/sms-console';

export const authConfig: NAuthConfig = {
tablePrefix: 'nauth_',

// DatabaseStorageAdapter stores transient state (rate limits, sessions, locks)
// in the same PostgreSQL database — no Redis required.
// NAuth.create() injects the DataSource repositories automatically.
// For multi-server production deployments, use createRedisStorageAdapter() instead.
storageAdapter: new DatabaseStorageAdapter(null, null),

jwt: {
algorithm: 'HS256',
accessToken: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_ACCESS_TOKEN_EXPIRES_IN || '1h',
},
refreshToken: {
secret: process.env.JWT_REFRESH_SECRET as string,
expiresIn: process.env.JWT_REFRESH_TOKEN_EXPIRES_IN || '30d',
rotation: true,
},
},

password: {
minLength: 8,
requireUppercase: true,
requireNumbers: true,
requireSpecialChars: true,
},

signup: {
enabled: true,
// Set to 'email', 'phone', or 'both' to require verification via the Challenge System
verificationMethod: 'none',
},

// Cookie-based token delivery — requires @fastify/cookie plugin
tokenDelivery: {
method: 'cookies',
cookieOptions: {
// On localhost (HTTP) use lax + insecure. For production HTTPS cross-site
// deployments set COOKIE_SECURE=true, COOKIE_SAME_SITE=none, COOKIE_DOMAIN=.yourdomain.com
secure: process.env.COOKIE_SECURE === 'true',
sameSite: (process.env.COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'lax',
},
},

security: {
csrf: {
cookieName: 'nauth_csrf_token',
headerName: 'x-csrf-token',
},
},

// Console providers log to stdout — replace with real providers for production
emailProvider: new ConsoleEmailProvider(),
smsProvider: new ConsoleSMSProvider(),
} satisfies NAuthConfig;

Bootstrap

Hook registration order is critical — clientInfo must be the first preHandler hook because it initialises the AsyncLocalStorage context that all subsequent hooks and handlers depend on.

The tokenDelivery middleware is a response interceptor — register it on the onSend hook rather than preHandler.

src/index.ts
import 'dotenv/config';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyCors from '@fastify/cors';
import { DataSource } from 'typeorm';
import { NAuth, FastifyAdapter, NAuthInstance } from '@nauth-toolkit/core';
import { getNAuthEntities, getNAuthTransientStorageEntities } from '@nauth-toolkit/database-typeorm-postgres';
import { authConfig } from './config/auth.config';
import { registerAuthRoutes } from './routes/auth.routes';
import { errorHandler } from './utils/error-handler';

type FastifyPreHandler = (request: unknown, reply: unknown) => Promise<void>;
type TypedNAuth = NAuthInstance<FastifyPreHandler, FastifyPreHandler>;

async function main(): Promise<void> {
// ── Database ─────────────────────────────────────────────────────────────────
// getNAuthTransientStorageEntities() adds the tables used by DatabaseStorageAdapter
// (nauth_rate_limits, nauth_storage_locks) so no Redis is needed.

const dataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
username: process.env.DB_USERNAME as string,
password: process.env.DB_PASSWORD as string,
database: process.env.DB_DATABASE ?? 'myapp',
entities: [...getNAuthEntities(), ...getNAuthTransientStorageEntities()],
logging: false,
});

await dataSource.initialize();

// ── NAuth ─────────────────────────────────────────────────────────────────────

const nauth = await NAuth.create({
config: authConfig,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dataSource: dataSource as any,
adapter: new FastifyAdapter(),
});

// ── Fastify App ───────────────────────────────────────────────────────────────

const fastify = Fastify({ logger: false });

await fastify.register(fastifyCookie);
await fastify.register(fastifyCors, {
// In production, replace `origin: true` with an explicit list of allowed origins.
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Device-Id', 'x-csrf-token', 'x-device-token'],
});

// ── NAuth Hooks (ORDER MATTERS) ───────────────────────────────────────────────
fastify.addHook('preHandler', nauth.middleware.clientInfo as FastifyPreHandler); // MUST BE FIRST — initializes AsyncLocalStorage
fastify.addHook('preHandler', nauth.middleware.csrf as FastifyPreHandler); // CSRF validation
fastify.addHook('preHandler', nauth.middleware.auth as FastifyPreHandler); // JWT validation (sets user on context)
// tokenDelivery is a response interceptor — use onSend, not preHandler
fastify.addHook(
'onSend',
nauth.middleware.tokenDelivery as (request: unknown, reply: unknown, payload: unknown) => Promise<unknown>,
);

// ── Health Check ──────────────────────────────────────────────────────────────

fastify.get('/health', async (_request, reply) => {
return reply.send({ status: 'ok', timestamp: new Date().toISOString() });
});

// ── Routes ────────────────────────────────────────────────────────────────────

await registerAuthRoutes(fastify, nauth as TypedNAuth);

// ── Error Handler (MUST BE LAST) ──────────────────────────────────────────────
fastify.setErrorHandler(errorHandler);

// ── Start ─────────────────────────────────────────────────────────────────────

const port = parseInt(process.env.PORT || '3000', 10);
await fastify.listen({ port, host: '0.0.0.0' });
// server listening
}

main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});

Auth Routes

Fastify routes use two nauth-specific patterns:

  • { preHandler: [nauth.helpers.public()] } — passes a helper directly in the route options object
  • nauth.adapter.wrapRouteHandler(async (req, res) => { ... }) — wraps your handler so the nauth request/response abstraction (req.body, req.query, req.cookies, res.json()) maps correctly to Fastify's native request/reply objects
src/routes/auth.routes.ts
import { FastifyInstance } from 'fastify';
import { NAuthInstance, IUser, UserResponseDTO } from '@nauth-toolkit/core';

type FastifyPreHandler = (request: unknown, reply: unknown) => Promise<void>;
type TypedNAuth = NAuthInstance<FastifyPreHandler, FastifyPreHandler>;

export async function registerAuthRoutes(fastify: FastifyInstance, nauth: TypedNAuth): Promise<void> {
const { authService } = nauth;

// ── Primary Auth Flow ────────────────────────────────────────────────────────

fastify.post(
'/auth/signup',
{ preHandler: [nauth.helpers.public()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.status(201).json(await authService.signup(req.body as any));
}) as any,
);

fastify.post(
'/auth/login',
{ preHandler: [nauth.helpers.public()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await authService.login(req.body as any));
}) as any,
);

fastify.post(
'/auth/respond-challenge',
{ preHandler: [nauth.helpers.public()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await authService.respondToChallenge(req.body as any));
}) as any,
);

fastify.post(
'/auth/refresh',
{ preHandler: [nauth.helpers.public()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
// Cookies mode: fall back to cookie if body token is absent or empty
const token =
(req.body as { refreshToken?: string })?.refreshToken?.trim() || req.cookies?.['nauth_refresh_token'];
res.json(await authService.refreshToken({ refreshToken: token }));
}) as any,
);

// ── Logout ───────────────────────────────────────────────────────────────────
// GET avoids CSRF issues for session destruction; csrf: false bypasses the CSRF check

fastify.get(
'/auth/logout',
{ preHandler: [nauth.helpers.requireAuth({ csrf: false })] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await authService.logout(req.query as any));
}) as any,
);

// ── User Profile ─────────────────────────────────────────────────────────────

fastify.get(
'/auth/profile',
{ preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (_req, res) => {
const user = nauth.helpers.getCurrentUser() as IUser;
res.json(UserResponseDTO.fromEntity(user));
}) as any,
);

fastify.put(
'/auth/profile',
{ preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await authService.updateUserAttributes(req.body as any));
}) as any,
);

fastify.post(
'/auth/change-password',
{ preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await authService.changePassword(req.body as any));
}) as any,
);

// ── Account Recovery ─────────────────────────────────────────────────────────

fastify.post(
'/auth/forgot-password',
{ preHandler: [nauth.helpers.public()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(
await authService.forgotPassword({
...(req.body as object),
baseUrl: process.env.FRONTEND_BASE_URL || 'http://localhost:5173',
} as any),
);
}) as any,
);

fastify.post(
'/auth/forgot-password/confirm',
{ preHandler: [nauth.helpers.public()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await authService.confirmForgotPassword(req.body as any));
}) as any,
);
}
Route paths vs Express

Unlike Express (where routes are mounted under a prefix with app.use('/auth', router)), Fastify routes are registered on the root instance with the full path included (e.g., /auth/signup). This is why registerAuthRoutes registers /auth/signup directly rather than just /signup.


Error Handler

Map NAuthException errors to HTTP status codes and return a structured JSON response. Register this with fastify.setErrorHandler(errorHandler).

src/utils/error-handler.ts
import { FastifyError, FastifyRequest, FastifyReply } from 'fastify';
import { NAuthException, getHttpStatusForErrorCode } from '@nauth-toolkit/core';

/**
* Fastify error handler
*
* Maps NAuthException to structured HTTP error responses.
* Must be registered with fastify.setErrorHandler(errorHandler).
*/
export async function errorHandler(
err: FastifyError | Error,
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
if (err instanceof NAuthException) {
const statusCode = getHttpStatusForErrorCode(err.code);
await reply.status(statusCode).send({
...err.toJSON(),
path: request.url.split('?')[0],
});
return;
}

console.error('[Error]', err);
await reply.status(500).send({
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
timestamp: new Date().toISOString(),
path: request.url.split('?')[0],
});
}

Environment Variables

.env
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE=myapp

JWT_SECRET=your-access-token-secret-min-32-chars
JWT_REFRESH_SECRET=your-refresh-token-secret-min-32-chars

# Cookie settings (for production)
COOKIE_SECURE=true
COOKIE_SAME_SITE=lax

Verify the Backend

Start your application and test the endpoints:

# Signup
curl -c cookies.txt -X POST http://localhost:3000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "MyPassword1!"}'

# Login
curl -c cookies.txt -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "MyPassword1!"}'

# Profile (cookies are sent automatically by curl with -b)
curl -b cookies.txt http://localhost:3000/auth/profile

With tokenDelivery: 'cookies', auth tokens are set as httpOnly cookies. The profile endpoint is protected — requests without a valid session cookie return 401.


Adding Features

Social Login (Google)

npm install @nauth-toolkit/social-google

Social providers auto-register when configured. Add to your authConfig:

src/config/auth.config.ts
export const authConfig: NAuthConfig = {
// ...
social: {
redirect: {
frontendBaseUrl: process.env.FRONTEND_BASE_URL || 'http://localhost:5173',
allowAbsoluteReturnTo: true,
allowedReturnToOrigins: ['http://localhost:5173'],
},
google: {
enabled: true,
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackUrl: `${process.env.API_BASE_URL || 'http://localhost:3000'}/auth/social/google/callback`,
scopes: ['openid', 'email', 'profile'],
autoLink: true,
allowSignup: true,
},
},
};

See the Social Login guide for the full route implementation.

MFA (TOTP)

npm install @nauth-toolkit/mfa-totp

MFA providers also auto-register when configured. Add to your authConfig:

src/config/auth.config.ts
import { MFAMethod } from '@nauth-toolkit/core';

export const authConfig: NAuthConfig = {
// ...
mfa: {
enabled: true,
enforcement: 'OPTIONAL',
allowedMethods: [MFAMethod.TOTP],
issuer: 'My App',
totp: { digits: 6, stepSeconds: 30, algorithm: 'sha1', window: 1 },
},
};

See the MFA guide for setup and verification route implementations.

Complete Route Examples

This quick start covers the essential routes. For complete implementations including MFA management, session management, social linking, admin endpoints, and more, see the Authentication Routes guide.


What's Next