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.
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.
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
- 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 cookie-parser cors dotenv typeorm pg
yarn add @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
pnpm add @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
bun add @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.
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.
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 them | Write them | |
|---|---|---|
| Setup | One registerNAuthExpressRoutes() 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 three lines in the Bootstrap step are all there is:
const authRouter = express.Router();
registerNAuthExpressRoutes(authRouter, nauth);
app.use('/auth', authRouter);
Skip registerNAuthExpressRoutes() entirely and build the router yourself.
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.
import { Router, Request, Response, NextFunction, RequestHandler } from 'express';
import { NAuthInstance, ExpressMiddlewareType, UserResponseDTO, IUser } from '@nauth-toolkit/core';
export function createAuthRoutes(
nauth: NAuthInstance<ExpressMiddlewareType, RequestHandler>,
): Router {
const router = Router();
const { authService } = nauth;
router.post('/signup', nauth.helpers.public(), async (req: Request, res: Response, next: NextFunction) => {
try {
res.status(201).json(await authService.signup(req.body));
} catch (err) {
next(err);
}
});
router.post('/login', nauth.helpers.public(), async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(await authService.login(req.body));
} catch (err) {
next(err);
}
});
router.get('/profile', nauth.helpers.requireAuth(), async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(UserResponseDTO.fromEntity(nauth.helpers.getCurrentUser() as IUser));
} catch (err) {
next(err);
}
});
return router;
}
app.use('/auth', createAuthRoutes(nauth));
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 on a second router, differing only in how tokens travel. This needs
tokenDelivery.method: 'hybrid'; the toolkit refuses at startup otherwise.
// 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.
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.
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).
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
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
- Express 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