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-toolkit/tree/main/examples/starter-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+ — 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 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 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.

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, 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 themWrite them
SetupOne registerNAuthFastifyRoutes() callA routes file you own
EndpointsEvery shipped route for the features you enabledOnly the ones you write
Customisingexclude a key, write that oneFull control everywhere
Keeping upNew endpoints arrive with upgradesYou add them yourself

The one register() call in the Bootstrap step is all there is:

src/index.ts
await fastify.register(
async (scope) => registerNAuthFastifyRoutes(scope, nauth),
{ prefix: '/auth' },
);

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.

src/index.ts
// 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.

src/index.ts
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.

Administrative routes are opt-in

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).

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

# 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 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​