Skip to main content

Quick Start: Express

Add authentication to an Express 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-express — Express + 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
  • An Express application

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 cookie-parser cors dotenv typeorm pg

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 cookie-parser middleware
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​

Middleware order is critical — clientInfo must be registered first because it initialises the AsyncLocalStorage context that all subsequent middleware and handlers depend on.

src/index.ts
import 'dotenv/config';
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import { DataSource } from 'typeorm';
import { NAuth, ExpressAdapter, ExpressMiddlewareType, registerNAuthExpressRoutes } 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 ExpressAdapter(),
});

// ── Express App ───────────────────────────────────────────────────────────────

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());

// In production, replace `origin: true` with an explicit list of allowed origins.
app.use(
cors({
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 Middleware (ORDER MATTERS) ──────────────────────────────────────────
app.use(nauth.middleware.clientInfo as ExpressMiddlewareType); // MUST BE FIRST — initializes AsyncLocalStorage
app.use(nauth.middleware.csrf as ExpressMiddlewareType); // CSRF validation
app.use(nauth.middleware.auth as ExpressMiddlewareType); // JWT validation (sets user on context)
app.use(nauth.middleware.tokenDelivery as ExpressMiddlewareType); // Response interceptor for cookie delivery

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

app.get('/health', (_req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));

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

const authRouter = express.Router();
registerNAuthExpressRoutes(authRouter, nauth);
app.use('/auth', authRouter);

// ── Error Handler (MUST BE LAST) ──────────────────────────────────────────────
app.use(errorHandler);

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

const port = parseInt(process.env.PORT || '3000', 10);
app.listen(port, '0.0.0.0', () => {
// server listening
});
}

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

Auth Routes​

The registerNAuthExpressRoutes() call in the Bootstrap step registers every shipped endpoint on a router you own — sign-up, sign-in, refresh, challenges, profile, MFA, social linking, sessions and device trust. There is no routes file to write.

The router is mounted by you, so the paths land wherever you app.use() it. These match the paths 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 registerNAuthExpressRoutes() 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 three lines in the Bootstrap step are all there is:

src/index.ts
const authRouter = express.Router();
registerNAuthExpressRoutes(authRouter, nauth);
app.use('/auth', authRouter);

Serving web and mobile from one backend​

groups selects which bundles to mount — Route Groups lists what each one contains.

Register the same routes on a second router, differing only in how tokens travel. This needs tokenDelivery.method: 'hybrid'; the toolkit refuses at startup otherwise.

src/index.ts
// Web: httpOnly cookies, CSRF.
const webRouter = express.Router();
registerNAuthExpressRoutes(webRouter, nauth, { delivery: 'cookies' });
app.use('/auth', webRouter);

// Native and mobile clients: tokens in the response body, no CSRF.
const mobileRouter = express.Router();
registerNAuthExpressRoutes(mobileRouter, nauth, {
delivery: 'json',
groups: ['core', 'profile', 'mfa', 'social', 'device'],
});
app.use('/mobile/auth', mobileRouter);

Customising one route​

Exclude its key and write only that route with nauth.helpers. Everything else stays mounted.

src/index.ts
import type { RequestHandler } from 'express';

registerNAuthExpressRoutes(authRouter, nauth, { delivery: 'cookies', exclude: ['login'] });

authRouter.post('/login', nauth.helpers.public() as RequestHandler, async (req, res, next) => {
try {
// your own logic here, then delegate
res.json(await nauth.authService.login(req.body));
} catch (err) {
next(err);
}
});

NAuth.create() returns NAuthInstance<unknown, unknown>, so the helpers need a cast to your framework's handler type — or type the instance at creation, as the "Write your own" tab above does.

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. 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 last with app.use(errorHandler).

src/utils/error-handler.ts
import { Request, Response, NextFunction } from 'express';
import { NAuthException, getHttpStatusForErrorCode } from '@nauth-toolkit/core';

/**
* Express error handler middleware
*
* Maps NAuthException to structured HTTP error responses.
* Must be registered LAST with app.use(errorHandler).
*/
export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction): void {
if (err instanceof NAuthException) {
const statusCode = getHttpStatusForErrorCode(err.code);
res.status(statusCode).json({ ...err.toJSON(), path: req.path });
return;
}

console.error('[Error]', err);
res.status(500).json({
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
timestamp: new Date().toISOString(),
path: req.path,
});
}

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​