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.
A complete working example is available at github.com/noorixorg/nauth-toolkit/tree/main/examples/starter-fastify — Fastify + TypeORM + PostgreSQL with Google OAuth and TOTP MFA preconfigured.
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+ — Node 24 recommended (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
- Yarn
- pnpm
- Bun
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
yarn add @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
pnpm add @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
bun add @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.
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 RedisStorageAdapter from @nauth-toolkit/storage-redis 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,
},
},
logger: {
instance: {
log: (msg: string) => console.log(`[NAuth] ${msg}`),
error: (msg: string, trace?: string) => console.error(`[NAuth ERROR] ${msg}`, trace || ''),
warn: (msg: string) => console.warn(`[NAuth WARN] ${msg}`),
debug: (msg: string) => console.debug(`[NAuth DEBUG] ${msg}`),
verbose: (msg: string) => console.log(`[NAuth VERBOSE] ${msg}`),
},
enablePiiRedaction: true, // Redact emails, IPs, tokens
},
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.
import 'dotenv/config';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyCors from '@fastify/cors';
import { DataSource } from 'typeorm';
import { NAuth, FastifyAdapter, FastifyMiddlewareType, registerNAuthFastifyRoutes } from '@nauth-toolkit/core';
import { getNAuthEntities, getNAuthTransientStorageEntities } from '@nauth-toolkit/database-typeorm-postgres';
import { authConfig } from './config/auth.config';
import { errorHandler } from './utils/error-handler';
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 FastifyMiddlewareType); // MUST BE FIRST — initializes AsyncLocalStorage
fastify.addHook('preHandler', nauth.middleware.csrf as FastifyMiddlewareType); // CSRF validation
fastify.addHook('preHandler', nauth.middleware.auth as FastifyMiddlewareType); // 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 fastify.register(async (scope) => registerNAuthFastifyRoutes(scope, nauth), { prefix: '/auth' });
// ── 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
The registerNAuthFastifyRoutes() call in the Bootstrap step registers every shipped endpoint
— sign-up, sign-in, refresh, challenges, profile, MFA, social linking, sessions and device
trust. There is no routes file to write.
Fastify takes the prefix in register(), not in the mount options — registering inside an
encapsulated scope is how the prefix is applied. These paths match what the
frontend SDK expects by default.
Two ways to get routes
Mounting is optional. Everything the toolkit does is available either way — the shipped routes are thin wrappers over the same services you would call yourself.
| Mount them | Write them | |
|---|---|---|
| Setup | One registerNAuthFastifyRoutes() call | A routes file you own |
| Endpoints | Every shipped route for the features you enabled | Only the ones you write |
| Customising | exclude a key, write that one | Full control everywhere |
| Keeping up | New endpoints arrive with upgrades | You add them yourself |
- Mount them
- Write your own
The one register() call in the Bootstrap step is all there is:
await fastify.register(
async (scope) => registerNAuthFastifyRoutes(scope, nauth),
{ prefix: '/auth' },
);
Skip registerNAuthFastifyRoutes() entirely and declare the routes yourself. Wrap each handler
in nauth.adapter.wrapRouteHandler() so the request context is restored — Fastify runs handlers
outside the hook chain that established it.
import { FastifyInstance } from 'fastify';
import { NAuthInstance, FastifyMiddlewareType, UserResponseDTO, IUser } from '@nauth-toolkit/core';
type TypedNAuth = NAuthInstance<FastifyMiddlewareType, FastifyMiddlewareType>;
export async function registerAuthRoutes(fastify: FastifyInstance, nauth: TypedNAuth): Promise<void> {
const { authService } = nauth;
fastify.post(
'/auth/signup',
{ preHandler: [nauth.helpers.public()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.status(201).json(await authService.signup(req.body as never));
}) as never,
);
fastify.post(
'/auth/login',
{ preHandler: [nauth.helpers.public()] },
nauth.adapter.wrapRouteHandler(async (req, res) => {
res.json(await authService.login(req.body as never));
}) as never,
);
fastify.get(
'/auth/profile',
{ preHandler: [nauth.helpers.requireAuth()] },
nauth.adapter.wrapRouteHandler(async (_req, res) => {
res.json(UserResponseDTO.fromEntity(nauth.helpers.getCurrentUser() as IUser));
}) as never,
);
}
The casts are why the mounted routes exist: wrapRouteHandler() returns a framework-neutral
handler that Fastify's generic signatures do not accept directly. registerNAuthFastifyRoutes()
owns that conversion internally.
Basic Auth Flows covers every endpoint in this form.
Serving web and mobile from one backend
groups selects which bundles to mount — Route Groups lists what each one contains.
Register the same routes in a second scope, differing only in how tokens travel. This needs
tokenDelivery.method: 'hybrid'; the toolkit refuses at startup otherwise.
// Web: httpOnly cookies, CSRF.
await fastify.register(
async (scope) => registerNAuthFastifyRoutes(scope, nauth, { delivery: 'cookies' }),
{ prefix: '/auth' },
);
// Native and mobile clients: tokens in the response body, no CSRF.
await fastify.register(
async (scope) =>
registerNAuthFastifyRoutes(scope, nauth, {
delivery: 'json',
groups: ['core', 'profile', 'mfa', 'social', 'device'],
}),
{ prefix: '/mobile/auth' },
);
Customising one route
Exclude its key and declare only that route in the same scope. Everything else stays mounted.
await fastify.register(async (scope) => {
registerNAuthFastifyRoutes(scope, nauth, { delivery: 'cookies', exclude: ['login'] });
scope.post('/login', { preHandler: [nauth.helpers.public() as never] }, async (request, reply) => {
// your own logic here, then delegate
return reply.send(await nauth.authService.login(request.body as never));
});
}, { prefix: '/auth' });
nauth.helpers.public() marks a route as not requiring authentication;
nauth.helpers.requireAuth() enforces it, and nauth.helpers.getCurrentUser() returns the
validated user inside a protected handler. The cast is needed because NAuth.create() returns
NAuthInstance<unknown, unknown> — the same reason the mounted routes exist. An unknown key in
exclude throws at startup, so a typo cannot silently leave the shipped route in place.
The admin group is never mounted by default, and mounting it requires an authorization
provider — the toolkit ships no role model. See Authorization.
Error Handler
Map NAuthException errors to HTTP status codes and return a structured JSON response. Register this with fastify.setErrorHandler(errorHandler).
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
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
# Cookies — lax + insecure is correct for HTTP localhost.
# For HTTPS cross-site production set COOKIE_SECURE=true and COOKIE_SAME_SITE=none.
COOKIE_SECURE=false
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 '{"identifier": "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: { method: '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
- Yarn
- pnpm
- Bun
npm install @nauth-toolkit/social-google
yarn add @nauth-toolkit/social-google
pnpm add @nauth-toolkit/social-google
bun add @nauth-toolkit/social-google
Social providers auto-register when configured. Add to your authConfig:
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
- Yarn
- pnpm
- Bun
npm install @nauth-toolkit/mfa-totp
yarn add @nauth-toolkit/mfa-totp
pnpm add @nauth-toolkit/mfa-totp
bun add @nauth-toolkit/mfa-totp
MFA providers also auto-register when configured. Add to your authConfig:
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.
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
- Fastify Starter App — The code this guide is based on, with Google OAuth and MFA
- Configuration — Full configuration reference with all available options
- Challenge System — Understand multi-step authentication flows (MFA, verification)
- Authentication Routes — Complete route implementations for all auth flows
- Token Delivery — Switch between cookie and JSON token delivery
- React Frontend — Connect a React frontend to this backend