awesome-node-auth
v1.2.2
Published
Database-agnostic JWT authentication and communication bus for Node.js
Maintainers
Readme
awesome-node-auth
A production-ready, database-agnostic JWT authentication and communication bus for Node.js written in TypeScript. It establishes a 360-degree communication and access control layer compatible with any Node.js framework (NestJS, Next.js, Express, Fastify, etc.) and any database through a simple interface pattern.
awesome-node-auth is the simple answer to the management complexity and enterprise subscriptions often required for best-practice authentication. Solutions like Supertokens are extremely complex, paid if managed, and limited or hard to maintain if self-hosted. Supabase is heavy, packed with features you're forced to carry along even if you don't need them, and similarly limited when self-hosted. awesome-node-auth gives you the same enterprise-grade features without the architectural bloat or vendor lock-in of cloud platforms.
Installation
npm install awesome-node-authQuick Start
import express from 'express';
import { AuthConfigurator } from 'awesome-node-auth';
import { myUserStore } from './my-user-store'; // Your IUserStore implementation
const app = express();
app.use(express.json());
const auth = new AuthConfigurator(
{
accessTokenSecret: process.env.ACCESS_TOKEN_SECRET!,
refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET!,
accessTokenExpiresIn: '15m',
refreshTokenExpiresIn: '7d',
},
myUserStore
);
// Mount the auth router at /auth
app.use('/auth', auth.router());
// Protect routes
app.get('/protected', auth.middleware(), (req, res) => {
res.json({ user: req.user });
});
app.listen(3000);Features
- 🔐 JWT Authentication – Access & refresh token pair with HttpOnly cookies or bearer tokens
- 🏠 Local Strategy – Email/password auth with bcrypt hashing and password reset
- 🔄 OAuth 2.0 – Google, GitHub, or any custom provider via
GenericOAuthStrategy - 🪄 Magic Links – Passwordless email login; first magic-link counts as email verification
- 📱 SMS OTP – Phone number verification via one-time codes
- 🔑 TOTP 2FA – Time-based OTP compatible with Google Authenticator and Authy
- 🔒 Flexible 2FA –
require2FAworks with any channel (TOTP, SMS, magic-link), including OAuth - 🔗 Account Linking – Link multiple OAuth providers; conflict resolution via
IPendingLinkStore - 🗃️ Database Agnostic – Implement one interface (
IUserStore) for any database - 🧩 Strategy Pattern – Plug in only the auth methods your app needs
- 🛡️ Middleware – JWT verification middleware (cookie or
Authorization: Bearer) - 🚀 Express Router – Drop-in
/authrouter with all endpoints pre-wired - 📝 Register Endpoint – Optional
POST /auth/registerviaonRegistercallback - 👤 Rich
/meProfile – Returns profile, metadata, roles, and permissions - 🧹 Session Cleanup – Optional
POST /auth/sessions/cleanupfor cron-based expiry - 🔒 CSRF Protection – Double-submit cookie pattern, opt-in via
csrf.enabled - 🏷️ Custom JWT Claims – Inject project-specific data via
buildTokenPayload - 📋 User Metadata – Arbitrary per-user key/value store via
IUserMetadataStore - 🛡️ Roles & Permissions – RBAC with tenant awareness via
IRolesPermissionsStore - 📅 Session Management – Device-aware session listing & revocation via
ISessionStore - 🏢 Multi-Tenancy – Isolated multi-tenant apps via
ITenantStore - 🗑️ Account Deletion –
DELETE /auth/accountself-service removal with full cleanup - 🔗 Account Linking – Link Local/Oauth accounts belonging to the same user
- 📧 Email Verification –
none/lazy(configurable grace period) /strictmodes - 📡 Event-Driven Tools –
AuthEventBus, telemetry, SSE, outgoing/inbound webhooks - 🔑 API Keys – M2M bcrypt-hashed keys with scopes, expiry, IP allowlist, audit log
- 📖 OpenAPI / Swagger UI – Auto-generated specs for auth, admin, and tools routers
- 🪝 Inbound/Outbound Webhooks management - Easy webhook implementation
- ⚙️ Integrated Admin UI - Integrate with AdminJS for Auth-related management
Database Integration — Implementing IUserStore
The library is completely database-agnostic. The only coupling point to your database is the
IUserStore interface. Implement it once for your DB and pass the instance to AuthConfigurator.
Interface contract
import { IUserStore, BaseUser } from 'awesome-node-auth';
export class MyUserStore implements IUserStore {
// ---- Required: core CRUD ---------------------------------------------------
/** Find a user by email address (used for login, magic link, password reset). */
async findByEmail(email: string): Promise<BaseUser | null> { /* ... */ }
/** Find a user by primary key (used for token refresh, 2FA, SMS). */
async findById(id: string): Promise<BaseUser | null> { /* ... */ }
/** Create a new user (used by OAuth strategies when user doesn't exist yet). */
async create(data: Partial<BaseUser>): Promise<BaseUser> { /* ... */ }
// ---- Required: token field updates ----------------------------------------
async updateRefreshToken(userId: string, token: string | null, expiry: Date | null): Promise<void> { /* ... */ }
async updateResetToken(userId: string, token: string | null, expiry: Date | null): Promise<void> { /* ... */ }
async updatePassword(userId: string, hashedPassword: string): Promise<void> { /* ... */ }
async updateTotpSecret(userId: string, secret: string | null): Promise<void> { /* ... */ }
async updateMagicLinkToken(userId: string, token: string | null, expiry: Date | null): Promise<void> { /* ... */ }
async updateSmsCode(userId: string, code: string | null, expiry: Date | null): Promise<void> { /* ... */ }
// ---- Optional: token look-ups (required for specific features) ------------
/**
* Required for: POST /auth/reset-password
* Find a user whose `resetToken` field matches the given token.
*/
async findByResetToken(token: string): Promise<BaseUser | null> { /* ... */ }
/**
* Required for: POST /auth/magic-link/verify
* Find a user whose `magicLinkToken` field matches the given token.
*/
async findByMagicLinkToken(token: string): Promise<BaseUser | null> { /* ... */ }
/**
* Optional but recommended for OAuth strategies.
* Look up a user by the OAuth provider name and the provider's opaque user ID
* (stored in `BaseUser.providerAccountId`). Use this instead of (or in addition
* to) `findByEmail` in `findOrCreateUser` to prevent account-takeover attacks.
*/
async findByProviderAccount(provider: string, providerAccountId: string): Promise<BaseUser | null> { /* ... */ }
}In-memory store (testing/prototyping):
import { InMemoryUserStore } from './examples/in-memory-user-store';
const userStore = new InMemoryUserStore();
const auth = new AuthConfigurator(config, userStore);SQLite with better-sqlite3:
import Database from 'better-sqlite3';
import { SqliteUserStore } from './examples/sqlite-user-store.example';
const db = new Database('app.db');
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
const userStore = new SqliteUserStore(db); // creates the `users` table automatically
const auth = new AuthConfigurator(config, userStore);MySQL / MariaDB with mysql2:
import mysql from 'mysql2/promise';
import { MySqlUserStore } from './examples/mysql-user-store.example';
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
const userStore = new MySqlUserStore(pool);
await userStore.init(); // creates the `users` table automatically
const auth = new AuthConfigurator(config, userStore);MongoDB with the mongodb driver:
import { MongoClient } from 'mongodb';
import { MongoDbUserStore } from './examples/mongodb-user-store.example';
const client = new MongoClient(process.env.MONGODB_URI!);
await client.connect();
const userStore = new MongoDbUserStore(client.db('myapp'));
await userStore.init(); // creates indexes automatically
const auth = new AuthConfigurator(config, userStore);PostgreSQL (example skeleton):
import { Pool } from 'pg';
import { IUserStore, BaseUser } from 'awesome-node-auth';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export class PgUserStore implements IUserStore {
async findByEmail(email: string) {
const { rows } = await pool.query('SELECT * FROM users WHERE email=$1', [email]);
return rows[0] ?? null;
}
async findById(id: string) {
const { rows } = await pool.query('SELECT * FROM users WHERE id=$1', [id]);
return rows[0] ?? null;
}
// ... implement remaining methods
}Framework Integration
NestJS
See examples/nestjs-integration.example.ts for a full working example that includes:
AuthModule.forRoot()— NestJS DynamicModule wrappingAuthConfiguratorJwtAuthGuard— NestJSCanActivateguard backed byauth.middleware()@CurrentUser()— parameter decorator that extractsreq.userAuthController— catch-all controller that forwards/auth/*traffic toauth.router()
// app.module.ts
import { AuthModule } from './auth.module';
import { MyUserStore } from './my-user-store';
@Module({
imports: [
AuthModule.forRoot({
config: authConfig,
userStore: new MyUserStore(),
}),
],
})
export class AppModule {}
// Protect a route
@Controller('profile')
export class ProfileController {
@Get()
@UseGuards(JwtAuthGuard)
getProfile(@CurrentUser() user: BaseUser) {
return user;
}
}Next.js
See examples/nextjs-integration.example.ts for a full working example that covers both the App Router (Next.js 13+) and the legacy Pages Router.
Pages Router (simplest approach):
// pages/api/auth/[...auth].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { getAuth } from '../../lib/auth';
export const config = { api: { bodyParser: false } };
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const router = getAuth().router();
req.url = req.url!.replace(/^\/api\/auth/, '') || '/';
router(req as any, res as any, () => res.status(404).end());
}Protecting a Server Component (App Router):
// app/dashboard/page.tsx
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { TokenService } from 'awesome-node-auth';
import { authConfig } from '../../lib/auth';
export default async function DashboardPage() {
const token = cookies().get('access_token')?.value;
if (!token) redirect('/login');
const payload = new TokenService().verifyAccessToken(token, authConfig);
if (!payload) redirect('/login');
return <div>Welcome, {payload.email}!</div>;
}Auth Router Endpoints
When you mount auth.router(), the following endpoints are available:
| Method | Path | Description |
|--------|------|-------------|
| POST | /auth/register | Register a new user (optional — requires onRegister in RouterOptions) |
| POST | /auth/login | Login with email/password |
| POST | /auth/logout | Logout and clear cookies |
| POST | /auth/refresh | Refresh access token |
| GET | /auth/me | Get current user's rich profile (protected) |
| POST | /auth/forgot-password | Send password reset email |
| POST | /auth/reset-password | Reset password with token |
| POST | /auth/change-password | Change password (authenticated, requires currentPassword + newPassword) |
| POST | /auth/send-verification-email | Send email verification link (authenticated) |
| GET | /auth/verify-email?token=... | Verify email address from link |
| POST | /auth/change-email/request | Request email change — sends verification to newEmail (authenticated) |
| POST | /auth/change-email/confirm | Confirm email change with token |
| POST | /auth/2fa/setup | Get TOTP secret + QR code (protected) |
| POST | /auth/2fa/verify-setup | Verify TOTP code and enable 2FA (protected) |
| POST | /auth/2fa/verify | Complete TOTP 2FA login |
| POST | /auth/2fa/disable | Disable 2FA (protected; blocked when user.require2FA or system require2FA policy is set) |
| POST | /auth/magic-link/send | Send magic link — direct login (mode='login', default) or 2FA challenge (mode='2fa', requires tempToken) |
| POST | /auth/magic-link/verify | Verify magic link — direct login (mode='login', default, marks email as verified on first use) or 2FA completion (mode='2fa', requires tempToken) |
| POST | /auth/sms/send | Send SMS code — direct login (mode='login', default, accepts userId or email) or 2FA challenge (mode='2fa', requires tempToken) |
| POST | /auth/sms/verify | Verify SMS code — direct login (mode='login', default) or 2FA completion (mode='2fa', requires tempToken) |
| POST | /auth/sessions/cleanup | Delete expired sessions (optional — requires sessionStore.deleteExpiredSessions) |
| DELETE | /auth/account | Authenticated self-service account deletion — revokes all sessions, removes RBAC roles, tenant memberships, metadata, and deletes the user record |
| GET | /auth/oauth/google | Initiate Google OAuth |
| GET | /auth/oauth/google/callback | Google OAuth callback |
| GET | /auth/oauth/github | Initiate GitHub OAuth |
| GET | /auth/oauth/github/callback | GitHub OAuth callback |
| GET | /auth/oauth/:name | Initiate OAuth for any custom provider (optional — requires oauthStrategies) |
| GET | /auth/oauth/:name/callback | Callback for custom provider (optional — requires oauthStrategies) |
| GET | /auth/linked-accounts | List OAuth accounts linked to the current user (protected) (optional — requires linkedAccountsStore) |
| DELETE | /auth/linked-accounts/:provider/:providerAccountId | Unlink a provider account (protected) (optional — requires linkedAccountsStore) |
| POST | /auth/link-request | Initiate email-based account link — sends a verification email to target address (protected) (optional — requires linkedAccountsStore + IUserStore.updateAccountLinkToken) |
| POST | /auth/link-verify | Complete account link — validates the token and records the new linked account; set loginAfterLinking: true in the body to receive a session immediately after linking (optional — requires linkedAccountsStore + IUserStore.findByAccountLinkToken) |
CORS & Multi-Frontend Support
When your frontend and backend run on different domains (e.g., api.yourapp.com and app.yourapp.com), or when you have multiple frontends connecting to the same backend, you must configure CORS properly.
The awesome-node-auth router can handle CORS headers automatically if you provide the cors option in RouterOptions. This is the recommended approach for auth routes because it automatically handles the Vary: Origin header and resolves the correct siteUrl dynamically for password resets and magic links.
import { createAuthRouter } from 'awesome-node-auth';
app.use('/auth', createAuthRouter(userStore, config, {
cors: {
origins: ['https://app.yourapp.com', 'https://admin.yourapp.com'],
}
}));Dynamic Email Links (siteUrl)
When the router receives a request from an allowed origin, it dynamically sets that origin as the base URL for any emails sent during that request (like magic links or password resets). This ensures users are redirected back to the exact frontend they initiated the request from.
The config.email.siteUrl acts as a fallback for requests that don't pass an Origin header (like server-to-server calls).
Cross-Origin Cookies & CSRF
If your frontend and backend share the same parent domain (e.g., ui.example.com and api.example.com), browsers treat them as same-site. Set cookieOptions.domain: '.example.com' and cookieOptions.sameSite: 'lax'.
If they are on completely different domains:
- You must use
cookieOptions.sameSite: 'none'andcookieOptions.secure: true. - You must disable CSRF protection (
csrf.enabled: false) since the double-submit pattern relies on reading cookies from JS, which is impossible across different domains due toSameSite=Nonerules. - You should rely on strict CORS origins to protect against CSRF attacks.
Configuration
import { AuthConfig } from 'awesome-node-auth';
const config: AuthConfig = {
// Required
accessTokenSecret: process.env.ACCESS_TOKEN_SECRET!,
refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET!,
// Token lifetimes (default: 15m / 7d)
accessTokenExpiresIn: '15m',
refreshTokenExpiresIn: '7d',
// Cookie options
cookieOptions: {
secure: true, // HTTPS only (recommended in production)
sameSite: 'lax',
domain: 'yourdomain.com',
// Restrict the refresh-token cookie to the refresh endpoint for extra security:
refreshTokenPath: '/auth/refresh',
},
// CSRF protection (double-submit cookie pattern) — see "CSRF Protection" section
csrf: {
enabled: true, // default: false
},
// bcrypt salt rounds (default: 12)
bcryptSaltRounds: 12,
// Email — see "Mailer Configuration" section below
email: {
siteUrl: 'https://yourapp.com',
mailer: {
endpoint: process.env.MAILER_ENDPOINT!, // HTTP POST endpoint
apiKey: process.env.MAILER_API_KEY!,
from: '[email protected]',
fromName: 'My App',
provider: 'mailgun', // optional — forwarded to your mailer API
defaultLang: 'en', // 'en' or 'it'
},
},
// SMS (for OTP verification codes)
sms: {
endpoint: 'https://sms.example.com/sendsms',
apiKey: process.env.SMS_API_KEY!,
username: process.env.SMS_USERNAME!,
password: process.env.SMS_PASSWORD!,
codeExpiresInMinutes: 10,
},
// OAuth
oauth: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackUrl: 'https://yourapp.com/auth/oauth/google/callback',
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
callbackUrl: 'https://yourapp.com/auth/oauth/github/callback',
},
},
// 2FA app name shown in authenticator apps
twoFactor: {
appName: 'My App',
},
};Mailer Configuration
The library ships a built-in HTTP mailer transport (MailerService) that sends transactional
emails (password reset, magic links, welcome) via an HTTP POST to any configurable endpoint — no
SMTP required. Built-in templates are available in English (en) and Italian (it).
Option A — Built-in HTTP mailer transport (recommended)
Configure email.mailer in AuthConfig. The library will automatically send emails using the
built-in templates whenever a reset link or magic link needs to go out.
import { AuthConfig, MailerConfig } from 'awesome-node-auth';
const config: AuthConfig = {
// ...jwt secrets, cookies...
email: {
siteUrl: 'https://yourapp.com',
mailer: {
/** Full URL of your mailer API endpoint. Receives a JSON POST. */
endpoint: process.env.MAILER_ENDPOINT!, // e.g. 'https://api.mailgun.net/v3/...'
/** API key sent as the X-API-Key request header. */
apiKey: process.env.MAILER_API_KEY!,
/** Sender address. */
from: '[email protected]',
/** Sender display name (optional). */
fromName: 'My App',
/**
* Email provider identifier forwarded to your mailer API (optional).
* Useful when your proxy supports multiple providers (e.g. 'mailgun', 'sendgrid').
*/
provider: 'mailgun',
/**
* Default language for built-in templates.
* Supported: 'en' (default) | 'it'
* Can be overridden per-request by passing emailLang in the request body.
*/
defaultLang: 'en',
},
},
};The mailer sends a POST request to endpoint with the following JSON body:
{
"to": "[email protected]",
"from": "[email protected]",
"fromName": "My App",
"provider": "mailgun",
"subject": "Reset your password",
"html": "<p>Click the link...</p>",
"text": "Click the link..."
}and the header X-API-Key: <apiKey>.
Note:
providerandfromNameare only included when set inMailerConfig; they are omitted from the payload when not configured.
Your mailer API (Mailgun, Resend, SendGrid, a custom proxy, etc.) only needs to accept this JSON
shape and forward it to the email provider. The content-type is application/json.
Per-request language override
For POST /auth/forgot-password and POST /auth/magic-link/send, pass emailLang in the request
body to override defaultLang for a single request:
{ "email": "[email protected]", "emailLang": "it" }Using MailerService directly
import { MailerService } from 'awesome-node-auth';
const mailer = new MailerService({
endpoint: 'https://mailer.example.com/send',
apiKey: 'key-xxx',
from: '[email protected]',
defaultLang: 'it',
});
await mailer.sendPasswordReset(to, token, resetLink, 'it');
await mailer.sendMagicLink(to, token, magicLink);
await mailer.sendWelcome(to, { loginUrl: 'https://yourapp.com/login', tempPassword: 'Temp@123' }, 'it');Option B — Custom callbacks
If you prefer full control, provide callback functions instead of (or in addition to) mailer.
Callbacks always take precedence over the mailer transport.
email: {
siteUrl: 'https://yourapp.com',
sendPasswordReset: async (to, token, link, lang) => {
await myEmailClient.send({ to, subject: 'Reset your password', html: `...${link}...` });
},
sendMagicLink: async (to, token, link, lang) => {
await myEmailClient.send({ to, subject: 'Your sign-in link', html: `...${link}...` });
},
sendWelcome: async (to, data, lang) => {
await myEmailClient.send({ to, subject: 'Welcome!', html: `...${data.loginUrl}...` });
},
},OAuth Strategies
OAuth strategies are abstract—extend them to implement your own user lookup logic.
The profile object passed to findOrCreateUser now includes an emailVerified boolean (available from Google; derived from the primary-email entry for GitHub). Always store the provider's opaque user ID in providerAccountId and use findByProviderAccount for safe lookups — do not rely solely on email matching, which is vulnerable to account-takeover attacks.
import { GoogleStrategy, BaseUser, AuthConfig, AuthError } from 'awesome-node-auth';
class MyGoogleStrategy extends GoogleStrategy<BaseUser> {
constructor(config: AuthConfig, private userStore: MyUserStore) {
super(config);
}
async findOrCreateUser(profile: {
id: string;
email: string;
emailVerified?: boolean;
name?: string;
picture?: string;
}) {
// 1. Precise match — same provider + same provider ID (no email guessing)
if (this.userStore.findByProviderAccount) {
const existing = await this.userStore.findByProviderAccount('google', profile.id);
if (existing) return existing;
}
// 2. Email collision with a different account → signal conflict so the
// OAuth callback can redirect to a "link accounts" page.
const byEmail = await this.userStore.findByEmail(profile.email);
if (byEmail) {
throw new AuthError(
'An account with this email already exists. Please log in with your original method to link accounts.',
'OAUTH_ACCOUNT_CONFLICT',
409,
);
}
// 3. Brand-new user
return this.userStore.create({
email: profile.email,
loginProvider: 'google',
providerAccountId: profile.id,
isEmailVerified: profile.emailVerified ?? false,
firstName: profile.name?.split(' ')[0],
lastName: profile.name?.split(' ').slice(1).join(' ') || null,
});
}
}
// Pass to router
app.use('/auth', auth.router({
googleStrategy: new MyGoogleStrategy(config, userStore),
githubStrategy: new MyGithubStrategy(config, userStore),
}));When findOrCreateUser throws an AuthError with code 'OAUTH_ACCOUNT_CONFLICT', the built-in OAuth callback automatically redirects to:
{siteUrl}/auth/account-conflict?provider=google&code=OAUTH_ACCOUNT_CONFLICT&email=user%40example.comWhen you also attach { email, providerAccountId } to the thrown AuthError's data field and provide a pendingLinkStore in RouterOptions, the library stashes the conflicting provider details automatically so the front-end can drive the full conflict-resolution flow without any custom server routes:
// Inside findOrCreateUser — throw with data payload
throw new AuthError(
'Email already registered with a different provider',
'OAUTH_ACCOUNT_CONFLICT',
409,
{ email: profile.email, providerAccountId: profile.id },
);Handle the /auth/account-conflict route in your frontend to prompt the user to verify ownership of the existing account (e.g. enter password, magic link), then call POST /auth/link-verify with loginAfterLinking: true to complete linking and receive a new session.
Security note: Never auto-link two accounts just because they share an email. Always require the user to prove ownership of the existing account first (e.g. by entering their password) before creating the link.
Native conflict linking with IPendingLinkStore
Provide an IPendingLinkStore to let the library manage stashing natively — no custom /conflict-link-* routes needed:
import { IPendingLinkStore } from 'awesome-node-auth';
class RedisPendingLinkStore implements IPendingLinkStore {
async stash(email: string, provider: string, providerAccountId: string): Promise<void> {
await redis.set(`pending:${email}:${provider}`, providerAccountId, 'EX', 3600);
}
async retrieve(email: string, provider: string): Promise<{ providerAccountId: string } | null> {
const id = await redis.get(`pending:${email}:${provider}`);
return id ? { providerAccountId: id } : null;
}
async remove(email: string, provider: string): Promise<void> {
await redis.del(`pending:${email}:${provider}`);
}
}
app.use('/auth', createAuthRouter(userStore, config, {
googleStrategy: new MyGoogleStrategy(config, userStore),
linkedAccountsStore: new MyLinkedAccountsStore(),
pendingLinkStore: new RedisPendingLinkStore(),
}));End-to-end unauthenticated conflict-linking flow:
- User tries to sign in with Google;
findOrCreateUserdetects the email already belongs to an existing account and throwsAuthError('...', 'OAUTH_ACCOUNT_CONFLICT', 409, { email, providerAccountId }). - Library calls
pendingLinkStore.stash(email, 'google', providerAccountId)and redirects the browser to{siteUrl}/auth/account-conflict?provider=google&email=user%40example.com. - Frontend prompts the user to verify ownership (e.g. sends a magic link / password check). Once verified, the front-end has a
linkTokenfromPOST /auth/link-request. - Frontend calls
POST /auth/link-verifywith{ token, loginAfterLinking: true }. The library retrieves the stashedproviderAccountId, links the account, clears the stash, and returns a full session.
// Step 3 — authenticated (or unauthenticated) user initiates the link
// (the link-request email is sent to the email from the conflict redirect)
await fetch('/auth/link-request', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailFromConflictRedirect, provider: 'google' }),
});
// Step 4 — complete link and get a session in one call
const res = await fetch('/auth/link-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: tokenFromEmail, loginAfterLinking: true }),
});
// → tokens are set as cookies (or returned in body with X-Auth-Strategy: bearer)
// → pendingLinkStore.retrieve() fetched the real providerAccountId automatically2FA enforcement for OAuth logins
When a user who has 2FA enabled (or for whom require2FA is set) logs in via OAuth, the library
does not issue full tokens immediately. Instead, the callback redirects to:
{siteUrl}/auth/2fa?tempToken=<encoded-temp-token>&methods=totp,sms,magic-linkYour frontend should present the appropriate 2FA challenge here. The user then completes 2FA via the
existing /auth/2fa/verify, /auth/sms/verify, or /auth/magic-link/verify?mode=2fa endpoints as
normal.
Adding a custom OAuth provider with GenericOAuthStrategy
Use GenericOAuthStrategy to integrate any OAuth 2.0 provider that follows the standard
Authorization Code flow with a JSON user-info endpoint — no need to write boilerplate:
import { GenericOAuthStrategy, GenericOAuthProviderConfig, BaseUser } from 'awesome-node-auth';
const discordConfig: GenericOAuthProviderConfig = {
name: 'discord',
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
callbackUrl: 'https://yourapp.com/auth/oauth/discord/callback',
authorizationUrl: 'https://discord.com/api/oauth2/authorize',
tokenUrl: 'https://discord.com/api/oauth2/token',
userInfoUrl: 'https://discord.com/api/users/@me',
scope: 'identify email',
// Optional: map provider-specific field names to the standard profile shape
mapProfile: (raw) => ({
id: String(raw['id']),
email: String(raw['email']),
name: String(raw['username']),
}),
};
class DiscordStrategy extends GenericOAuthStrategy<BaseUser> {
constructor(private userStore: MyUserStore) {
super(discordConfig);
}
async findOrCreateUser(profile: { id: string; email: string; name?: string }): Promise<BaseUser> {
const existing = await this.userStore.findByProviderAccount?.('discord', profile.id);
if (existing) return existing;
return this.userStore.create({ email: profile.email, loginProvider: 'discord', providerAccountId: profile.id });
}
}
// Pass via oauthStrategies — the router mounts:
// GET /auth/oauth/discord → redirect to Discord
// GET /auth/oauth/discord/callback → handle callback
app.use('/auth', createAuthRouter(userStore, config, {
oauthStrategies: [new DiscordStrategy(userStore)],
}));Flexible account linking with ILinkedAccountsStore
When you provide a linkedAccountsStore, each OAuth login automatically records a link entry so
users can connect multiple providers to a single account. The following endpoints become available:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /auth/linked-accounts | List all OAuth accounts linked to the authenticated user |
| DELETE | /auth/linked-accounts/:provider/:providerAccountId | Unlink a specific provider account |
| POST | /auth/link-request | Initiate explicit email-based link (authenticated) |
| POST | /auth/link-verify | Complete the link with the token from the email |
import { ILinkedAccountsStore, LinkedAccount } from 'awesome-node-auth';
class MyLinkedAccountsStore implements ILinkedAccountsStore {
async getLinkedAccounts(userId: string): Promise<LinkedAccount[]> {
return db('linked_accounts').where({ userId });
}
async linkAccount(userId: string, account: LinkedAccount): Promise<void> {
await db('linked_accounts').insert({ userId, ...account }).onConflict().ignore();
}
async unlinkAccount(userId: string, provider: string, providerAccountId: string): Promise<void> {
await db('linked_accounts').where({ userId, provider, providerAccountId }).delete();
}
async findUserByProviderAccount(provider: string, providerAccountId: string): Promise<{ userId: string } | null> {
const row = await db('linked_accounts').where({ provider, providerAccountId }).first();
return row ? { userId: row.userId } : null;
}
}
app.use('/auth', createAuthRouter(userStore, config, {
googleStrategy: new MyGoogleStrategy(config, userStore),
linkedAccountsStore: new MyLinkedAccountsStore(),
}));Explicit email-based linking (link-request / link-verify)
To let an authenticated user attach a secondary email address (or any provider) without going through a full OAuth redirect:
// Step 1 — authenticated user initiates the link
// POST /auth/link-request
// Authorization: Bearer <accessToken>
// Body: { email: "[email protected]", provider?: "email" }
await fetch('/auth/link-request', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', provider: 'email' }),
});
// → a 1-hour verification token is generated and sent to [email protected]
// Step 2 — user clicks the link in the email; token is passed back
// POST /auth/link-verify (public, no auth required)
// Body: { token: "<token-from-email>", loginAfterLinking?: true }
await fetch('/auth/link-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: tokenFromLink }),
});
// → linkedAccountsStore.linkAccount() is called; account appears in GET /auth/linked-accounts
// Optional: pass loginAfterLinking: true to receive a session immediately
await fetch('/auth/link-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: tokenFromLink, loginAfterLinking: true }),
});
// → tokens set as cookies (or in body for X-Auth-Strategy: bearer); user is now logged inRequired IUserStore methods (add alongside your existing store):
// Store a pending link token (called by link-request)
async updateAccountLinkToken(
userId: string,
pendingEmail: string | null,
pendingProvider: string | null,
token: string | null,
expiry: Date | null,
): Promise<void>
// Look up user by their pending link token (called by link-verify)
async findByAccountLinkToken(token: string): Promise<User | null>Disabling 2FA (POST /auth/2fa/disable) is blocked when:
- The user record has
require2FA: true, or - The system-wide
require2FAsetting istrue(requiressettingsStoreinRouterOptions).
This lets users self-manage 2FA freely unless the administrator or the user's own profile mandates it.
Using Services Directly
Access the underlying services for custom flows:
const auth = new AuthConfigurator(config, userStore);
// Hash passwords
const hash = await auth.passwordService.hash('mypassword');
const valid = await auth.passwordService.compare('mypassword', hash);
// Generate/verify tokens
const tokens = auth.tokenService.generateTokenPair({ sub: userId, email }, config);
const payload = auth.tokenService.verifyAccessToken(token, config);
// Get a local strategy instance
const localStrategy = auth.strategy('local');
const user = await localStrategy.authenticate({ email, password }, config);Using Strategies Independently
import {
MagicLinkStrategy,
SmsStrategy,
TotpStrategy,
LocalStrategy,
PasswordService,
} from 'awesome-node-auth';
// Magic Links
const magicLink = new MagicLinkStrategy();
await magicLink.sendMagicLink(email, userStore, config);
const user = await magicLink.verify(token, userStore);
// SMS OTP
const sms = new SmsStrategy();
await sms.sendCode(phone, userId, userStore, config);
const valid = await sms.verify(userId, code, userStore);
// TOTP 2FA
const totpStrategy = new TotpStrategy();
const { secret, otpauthUrl, qrCode } = totpStrategy.generateSecret(email, 'MyApp');
const qrDataUrl = await qrCode; // data:image/png;base64,...
const isValid = await totpStrategy.verify(token, secret);BaseUser Model
interface BaseUser {
id: string;
email: string;
password?: string;
role?: string;
/** First name (optional — stored as a profile field). */
firstName?: string | null;
/** Last name / surname (optional — stored as a profile field). */
lastName?: string | null;
/**
* Authentication provider used to create / link this account.
* Defaults to `'local'` when not set.
* Examples: `'local'` | `'google'` | `'github'` | `'magic-link'` | `'sms'`
*/
loginProvider?: string | null;
refreshToken?: string | null;
refreshTokenExpiry?: Date | null;
resetToken?: string | null;
resetTokenExpiry?: Date | null;
/**
* TOTP secret stored after the user completes the 2FA setup flow
* (`POST /auth/2fa/verify-setup`). `null` means the user has not yet paired
* an authenticator app.
*/
totpSecret?: string | null;
/**
* `true` once the user has successfully called `POST /auth/2fa/verify-setup`.
* Reset to `false` by `POST /auth/2fa/disable`.
*
* > **Note:** simply calling `POST /auth/2fa/setup` does **not** enable 2FA.
* > The user must scan the QR code in their authenticator app and then call
* > `POST /auth/2fa/verify-setup` with the 6-digit code to confirm pairing.
*/
isTotpEnabled?: boolean;
isEmailVerified?: boolean;
magicLinkToken?: string | null;
magicLinkTokenExpiry?: Date | null;
smsCode?: string | null;
smsCodeExpiry?: Date | null;
phoneNumber?: string | null;
require2FA?: boolean;
// Email verification
emailVerificationToken?: string | null;
emailVerificationTokenExpiry?: Date | null;
/**
* Deadline for lazy email-verification mode.
* After this date login is blocked until the email is confirmed.
* Set at registration time (e.g. `createdAt + 7d`). Leave null for
* a permanent grace period.
*/
emailVerificationDeadline?: Date | null;
// Change email
pendingEmail?: string | null;
emailChangeToken?: string | null;
emailChangeTokenExpiry?: Date | null;
// Account linking (email-based link-request / link-verify flow)
accountLinkToken?: string | null;
accountLinkTokenExpiry?: Date | null;
accountLinkPendingEmail?: string | null;
accountLinkPendingProvider?: string | null;
/**
* The unique user ID returned by the OAuth provider (e.g. Google `sub`,
* GitHub numeric ID). Use together with `loginProvider` and
* `IUserStore.findByProviderAccount` for safe OAuth account linking.
*/
providerAccountId?: string | null;
/**
* Timestamp of the user's last successful login. Useful for purging inactive
* users or for auditing purposes.
*/
lastLogin?: Date | null;
}GET /me — Rich User Profile
GET /auth/me (protected) fetches the full user record from the store and returns a safe, structured profile. Sensitive internal fields (password, refreshToken, totpSecret, resetToken, etc.) are never exposed.
Default response
{
"id": "abc123",
"email": "[email protected]",
"role": "user",
"loginProvider": "local",
"isEmailVerified": true,
"isTotpEnabled": false
}With metadataStore and rbacStore
Pass optional stores to auth.router() to enrich the profile automatically:
app.use('/auth', auth.router({
metadataStore: myMetadataStore, // adds "metadata" field
rbacStore: myRbacStore, // adds "roles" and "permissions" fields
}));Response with both stores:
{
"id": "abc123",
"email": "[email protected]",
"role": "user",
"loginProvider": "google",
"isEmailVerified": true,
"isTotpEnabled": true,
"metadata": { "plan": "pro", "onboarded": true },
"roles": ["editor", "viewer"],
"permissions": ["posts:read", "posts:write"]
}Storing firstName, lastName and loginProvider
Add these optional fields to your user schema and populate them when creating users:
// On OAuth sign-up, set loginProvider to the provider name
await userStore.create({
email: profile.email,
firstName: profile.name?.split(' ')[0],
lastName: profile.name?.split(' ').slice(1).join(' ') || null,
loginProvider: 'google',
});
// On local registration
await userStore.create({
email: req.body.email,
password: hashedPassword,
firstName: req.body.firstName,
lastName: req.body.lastName,
loginProvider: 'local',
});User Registration
POST /auth/register is optional — it is only mounted when you provide an onRegister callback in RouterOptions. This lets you opt out of self-registration entirely for projects where it is not needed.
import { PasswordService } from 'awesome-node-auth';
const passwordService = new PasswordService();
app.use('/auth', auth.router({
onRegister: async (data, config) => {
// Validate input (add your own checks here)
if (!data['email'] || !data['password']) {
throw new AuthError('email and password are required', 'VALIDATION_ERROR', 400);
}
const hash = await passwordService.hash(
data['password'] as string,
config.bcryptSaltRounds,
);
return userStore.create({
email: data['email'] as string,
password: hash,
firstName: data['firstName'] as string | undefined,
lastName: data['lastName'] as string | undefined,
loginProvider: 'local',
});
},
}));Request body — any JSON object; data is the raw req.body.
Response on success (201):
{ "success": true, "userId": "abc123" }After creating the user, if config.email.sendWelcome or config.email.mailer is configured, a welcome email is sent automatically.
Tip: Omit
onRegisterentirely for admin-only or invite-only systems where users should not be able to sign up themselves.
Session Cleanup (Cron)
When using ISessionStore, expired session records accumulate in your database over time. The optional POST /auth/sessions/cleanup endpoint lets you purge them on a schedule.
1. Implement deleteExpiredSessions in your store
export class MySessionStore implements ISessionStore {
// ... other methods ...
/** Delete sessions whose expiresAt is in the past. Returns the count deleted. */
async deleteExpiredSessions(): Promise<number> {
const result = await db('sessions').where('expiresAt', '<', new Date()).delete();
return result; // number of deleted rows
}
}2. Mount the endpoint
app.use('/auth', auth.router({
sessionStore: mySessionStore, // must implement deleteExpiredSessions
}));3. Call it from a cron job
// Example: node-cron (runs every day at midnight)
import cron from 'node-cron';
cron.schedule('0 0 * * *', async () => {
const res = await fetch('https://yourapp.com/auth/sessions/cleanup', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.CLEANUP_SECRET}` },
});
const { deleted } = await res.json();
console.log(`Cleaned up ${deleted} expired sessions`);
});Security: Protect this endpoint with a rate limiter or a secret header in production to prevent abuse.
CSRF Protection
The library supports the double-submit cookie pattern for CSRF defence, which is particularly important when sameSite: 'none' is used (e.g. cross-origin setups) or for defence-in-depth alongside sameSite: 'lax'.
How it works
- When CSRF is enabled, the library sets a non-
HttpOnlycookie calledcsrf-tokenalongside the JWT cookies after every login/refresh. - Client-side JavaScript must read this cookie and send its value in the
X-CSRF-Tokenheader on every authenticated request. createAuthMiddlewarevalidates that the header value matches the cookie value. If they don't match, the request is rejected with 403 CSRF_INVALID.
Enabling CSRF
const config: AuthConfig = {
accessTokenSecret: '...',
refreshTokenSecret: '...',
csrf: {
enabled: true, // default: false
},
cookieOptions: {
secure: true,
sameSite: 'none', // cross-origin scenario
},
};Client-side integration
// Helper: read a cookie by name
function getCookie(name: string): string | undefined {
return document.cookie
.split('; ')
.find(row => row.startsWith(name + '='))
?.split('=')[1];
}
// Add header to every authenticated request
async function authFetch(url: string, options: RequestInit = {}) {
const csrfToken = getCookie('csrf-token');
return fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}),
},
});
}
// Usage
await authFetch('/api/profile');
await authFetch('/auth/logout', { method: 'POST' });Note: CSRF protection is only meaningful for cookie-based authentication. If you use
Authorization: Bearerheaders instead of cookies, you do not need CSRF protection.
Bearer Token Strategy
By default the library uses HttpOnly cookies to deliver tokens (recommended for browser-based apps). For API clients, mobile apps, or environments that cannot use cookies, you can switch to bearer tokens on a per-request basis — no configuration change required.
How it works
- Send the
X-Auth-Strategy: bearerheader with the login request. - The server returns the tokens in the JSON response body instead of setting cookies.
- Store the tokens however is appropriate for your client (e.g. in memory for SPAs; secure storage for mobile apps). Avoid
localStorage— it is vulnerable to XSS. - For every authenticated request send the access token in the
Authorization: Bearerheader. - To refresh,
POST /auth/refreshwith{ refreshToken }in the JSON body and theX-Auth-Strategy: bearerheader — new tokens are returned in the body.
Login (bearer)
const res = await fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Strategy': 'bearer',
},
body: JSON.stringify({ email, password }),
});
const { accessToken, refreshToken } = await res.json();
// Store tokens securely (in-memory variable, not localStorage)Authenticated requests (bearer)
await fetch('/api/profile', {
headers: { Authorization: `Bearer ${accessToken}` },
});Refresh (bearer)
const res = await fetch('/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Strategy': 'bearer',
},
body: JSON.stringify({ refreshToken }),
});
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = await res.json();The X-Auth-Strategy: bearer header is respected by all token-issuing endpoints: POST /auth/login, POST /auth/refresh, POST /auth/2fa/verify, POST /auth/magic-link/verify, and POST /auth/sms/verify.
Cookie users are unaffected — if the
X-Auth-Strategy: bearerheader is absent, the library behaves exactly as before (HttpOnly cookies, optional CSRF protection).
Flutter / Android / iOS
See examples/flutter-integration.example.dart for a complete, copy-paste-ready Flutter client covering:
- Login with bearer token delivery (
X-Auth-Strategy: bearer) - Secure token storage via
flutter_secure_storage(Keychain on iOS, EncryptedSharedPreferences on Android) - Automatic access-token refresh with retry interceptor
- OAuth login (Google, GitHub, any provider) via
flutter_web_auth_2— opens system browser (CustomTabs on Android, SFSafariViewController on iOS), intercepts the redirect back to the custom URL scheme - OAuth + 2FA: extracts
tempToken+methodsfrom the 2FA redirect URL and completes via bearer-mode 2FA verify endpoint - TOTP and SMS 2FA challenges
- Magic-link (passwordless) flow
- Change password, change email (with confirmation deep-link)
- Email verification (send + deep-link confirm)
- Account linking (
POST /auth/link-request+POST /auth/link-verifyvia deep-link) - List and unlink linked accounts
- Account deletion
- Admin REST API calls
- Example widgets:
LoginPage(with OAuth buttons),TwoFactorPage,ProfilePage,LinkedAccountsPage
Deep-link setup notes for both Android (AndroidManifest.xml) and iOS (Info.plist) are included in the example file.
Error Handling
The library throws AuthError for authentication failures:
import { AuthError } from 'awesome-node-auth';
try {
await localStrategy.authenticate({ email, password }, config);
} catch (err) {
if (err instanceof AuthError) {
console.log(err.code); // e.g. 'INVALID_CREDENTIALS'
console.log(err.statusCode); // e.g. 401
console.log(err.message); // e.g. 'Invalid credentials'
console.log(err.data); // optional structured payload (e.g. { email, providerAccountId } for OAUTH_ACCOUNT_CONFLICT)
}
}The optional data field carries additional context. For example, when findOrCreateUser throws OAUTH_ACCOUNT_CONFLICT, you can attach the conflicting account's details so the router can stash them via IPendingLinkStore:
throw new AuthError(
'Email already registered with a different provider',
'OAUTH_ACCOUNT_CONFLICT',
409,
{ email: profile.email, providerAccountId: profile.id },
);Any unhandled errors thrown inside route handlers are caught by a global error middleware registered on the auth router. They are logged with console.error('[awesome-node-auth] Unhandled router error: ...') and return a generic 500 Internal server error response so that stack traces are never leaked to clients.
Custom Strategies
Extend BaseAuthStrategy to create custom authentication strategies:
import { BaseAuthStrategy, AuthConfig } from 'awesome-node-auth';
class ApiKeyStrategy extends BaseAuthStrategy<{ apiKey: string }, MyUser> {
name = 'api-key';
async authenticate(input: { apiKey: string }, config: AuthConfig): Promise<MyUser> {
const user = await myStore.findByApiKey(input.apiKey);
if (!user) throw new AuthError('Invalid API key', 'INVALID_API_KEY', 401);
return user;
}
}Email Verification
The email-verification flow reuses the same token infrastructure as password reset and is available out of the box once you implement the three optional store methods.
Verification modes
AuthConfig.emailVerificationMode controls how strictly email verification is enforced on login:
| Mode | Behaviour | Error code |
|------|-----------|------------|
| 'none' | Never required (default) | — |
| 'lazy' | Login allowed until user.emailVerificationDeadline expires | EMAIL_VERIFICATION_REQUIRED (403) |
| 'strict' | Login blocked immediately if email is unverified | EMAIL_NOT_VERIFIED (403) |
// Strict — block unverified users immediately
const config: AuthConfig = {
emailVerificationMode: 'strict',
// ...
};
// Lazy — allow login for 7 days, then require verification
const config: AuthConfig = {
emailVerificationMode: 'lazy',
// ...
};
// Set the deadline when creating the user (lazy mode only)
await userStore.create({
email,
password: hash,
emailVerificationDeadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});Backward compatibility:
requireEmailVerification: truestill works and is equivalent toemailVerificationMode: 'strict'. The admin ⚙️ Control panel also exposesemailVerificationModeso you can change the global policy at runtime without redeploying.
IUserStore additions
// Required to support /auth/send-verification-email and /auth/verify-email
updateEmailVerificationToken(userId, token, expiry): Promise<void>
updateEmailVerified(userId, isVerified): Promise<void>
findByEmailVerificationToken(token): Promise<U | null>AuthConfig email callbacks
email: {
// Called when a verification email is needed (takes precedence over mailer)
sendVerificationEmail: async (to, token, link, lang?) => { /* ... */ },
// Called after a successful email change (notifies the old address)
sendEmailChanged: async (to, newEmail, lang?) => { /* ... */ },
}Flow
- After registration, call
POST /auth/send-verification-email(authenticated) — the library generates a 24-hour token, callsupdateEmailVerificationToken, then firessendVerificationEmail. - The user clicks the link in their inbox; the link points to
GET /auth/verify-email?token=<token>— the library callsupdateEmailVerified(userId, true)and clears the token.
// Example: send on registration
app.post('/register', async (req, res) => {
const user = await userStore.create({ email: req.body.email, password: hashedPw });
// Log them in
const tokens = tokenService.generateTokenPair({ sub: user.id, email: user.email }, config);
tokenService.setTokenCookies(res, tokens, config);
// Trigger verification email (the library does this automatically via the auth router)
// or call the endpoint directly:
await fetch('/auth/send-verification-email', {
method: 'POST',
headers: { Cookie: `accessToken=${tokens.accessToken}` },
});
res.json({ success: true });
});Change Password
POST /auth/change-password — authenticated — lets users update their password without going through the forgot-password flow.
Request body:
{ "currentPassword": "OldP@ss1", "newPassword": "NewP@ss2" }The endpoint verifies currentPassword against the stored bcrypt hash before applying the change. It returns 401 if the current password is wrong, or 400 for OAuth accounts that have no password set.
// Client example (fetch)
await fetch('/auth/change-password', {
method: 'POST',
credentials: 'include', // include HttpOnly cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: 'old', newPassword: 'new' }),
});No extra IUserStore methods are needed — updatePassword is already a required method.
Change Email
The change-email flow sends a confirmation link to the new address before committing the update, preventing account hijacking.
IUserStore additions
// Required to support /auth/change-email/request and /auth/change-email/confirm
updateEmailChangeToken(userId, pendingEmail, token, expiry): Promise<void>
updateEmail(userId, newEmail): Promise<void>
findByEmailChangeToken(token): Promise<U | null>Flow
- Authenticated user calls
POST /auth/change-email/requestwith{ "newEmail": "[email protected]" }.- The library checks the new address is not already in use.
- A 1-hour token is generated, stored via
updateEmailChangeToken, and a verification email is sent to the new address.
- User clicks the link; it points to
POST /auth/change-email/confirmwith{ "token": "..." }.- The library calls
updateEmail(commits the change) and sends an email-changed notification to the old address viasendEmailChanged.
- The library calls
// 1. Request change
await fetch('/auth/change-email/request', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newEmail: '[email protected]' }),
});
// 2. Confirm (called from the link in the email)
await fetch('/auth/change-email/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: tokenFromLink }),
});Explicit Account Linking (link-request / link-verify)
An authenticated user can link a secondary email address (or tag it with any provider label) without going through a full OAuth redirect flow. The verification is email-based — the same pattern as change-email.
IUserStore additions
// Store a pending link token (called by POST /auth/link-request)
updateAccountLinkToken(
userId: string,
pendingEmail: string | null,
pendingProvider: string | null,
token: string | null,
expiry: Date | null,
): Promise<void>
// Look up user by their pending link token (called by POST /auth/link-verify)
findByAccountLinkToken(token: string): Promise<U | null>Flow
- Authenticated user calls
POST /auth/link-requestwith{ "email": "[email protected]", "provider": "email" }.- A 1-hour token is generated, stored via
updateAccountLinkToken, and a verification email is sent to the target address viasendVerificationEmail.
- A 1-hour token is generated, stored via
- User clicks the link (or the frontend extracts the
?token=param and posts it).POST /auth/link-verifywith{ "token": "..." }validates the token and callslinkedAccountsStore.linkAccount().- The token is cleared; the new account appears in
GET /auth/linked-accounts. - Pass
"loginAfterLinking": trueto also receive a full session immediately.
// 1. Request link (authenticated)
await fetch('/auth/link-request', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', provider: 'email' }),
});
// 2a. Verify only (called from the link in the email — no auth required)
await fetch('/auth/link-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: tokenFromLink }),
});
// 2b. Verify AND get a session in one call (useful for unauthenticated flows)
const res = await fetch('/auth/link-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: tokenFromLink, loginAfterLinking: true }),
});
// → tokens set as cookies (or in body for X-Auth-Strategy: bearer); user is now logged inBoth endpoints are only mounted when linkedAccountsStore is provided in RouterOptions. link-request also requires email.sendVerificationEmail (or email.mailer) to be configured so it can send the email.
Tip: Pass
loginAfterLinking: truein the/auth/link-verifybody to receive a full session (tokens set as cookies, or in the JSON body forX-Auth-Strategy: bearer) immediately after the link is confirmed — no separate login step needed. This is especially useful for the unauthenticated conflict-linking flow driven byIPendingLinkStore.
TOTP Two-Factor Authentication — Full UI Integration Guide
TOTP (Time-based One-Time Password) is the Google Authenticator / Authy style 2FA. The following is the complete flow from both the server and UI perspective.
Prerequisites
The user must be logged in (have a valid accessToken cookie or Bearer token).
Step 1 — Generate a secret and display the QR code
Call POST /auth/2fa/setup from your settings page. The response contains:
secret— base32-encoded TOTP secret (store it temporarily in the UI, never in localStorage)otpauthUrl— theotpauth://URI (used to generate the QR code)qrCode— adata:image/png;base64,...data URL you can put directly into an<img>tag
// Client-side (authenticated)
const res = await fetch('/auth/2fa/setup', {
method: 'POST',
credentials: 'include', // sends the accessToken cookie
});
const { secret, qrCode } = await res.json();
// Display in your UI
document.getElementById('qr-img').src = qrCode;
document.getElementById('secret-text').textContent = secret; // for manual entryUI tip: Show both the QR code and the plain-text secret. Some users cannot scan QR codes (accessibility, older devices).
<!-- Example setup UI -->
<div id="totp-setup">
<p>Scan this QR code with Google Authenticator, Authy, or any TOTP app:</p>
<img id="qr-img" alt="TOTP QR code" />
<p>Or enter this code manually: <code id="secret-text"></code></p>
<label>Enter the 6-digit code shown in the app to confirm:</label>
<input id="totp-input" type="text" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
<button onclick="verifySetup()">Enable 2FA</button>
</div>Step 2 — Verify the setup and persist the secret
The user enters the 6-digit code from their authenticator app. Call POST /auth/2fa/verify-setup with both the code and the secret returned from step 1.
async function verifySetup() {
const code = document.getElementById('totp-input').value.trim();
const secret = document.getElementById('secret-text').textContent; // from step 1
const res = await fetch('/auth/2fa/verify-setup', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: code, secret }),
});
if (res.ok) {
// 2FA is now enabled — update UI, redirect to settings
alert('Two-factor authentication enabled!');
} else {
const { error } = await res.json();
alert('Invalid code: ' + error);
}
}The server calls updateTotpSecret(userId, secret) which sets isTotpEnabled = true on the user.
Step 3 — Login with 2FA
The 2FA challenge is triggered in two situations:
- TOTP enabled: the user has set up an authenticator app (
isTotpEnabled = true). require2FAflag: the admin has flagged the user (or a global policy applies) — works with any configured channel including magic-link, so users who only have an email address can still use 2FA without setting up an authenticator app.
When either condition is met, POST /auth/login responds with:
{
"requiresTwoFactor": true,
"tempToken": "<short-lived JWT>",
"available2faMethods": ["totp", "sms", "magic-link"]
}tempTokenexpires in 5 minutes — use it immediately.available2faMethodslists which 2FA channels are available to this specific user (see Multi-channel 2FA below).
If require2FA is set but no method is configured for the user (no TOTP, no phone, and no email sender), the server returns:
{ "requires2FASetup": true, "tempToken": "...", "code": "2FA_SETUP_REQUIRED" }with HTTP 403 — prompt the user to set up at least one 2FA method.
Show a code-entry UI and call POST /auth/2fa/verify:
// After detecting requiresTwoFactor === true in the login response:
let tempToken = data.tempToken;
async function submit2fa() {
const totpCode = document.getElementById('totp-code-input').value.trim();
const res = await fetch('/auth/2fa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tempToken, totpCode }),
});
if (res.ok) {
// Full session tokens are now set as HttpOnly cookies
window.location.href = '/dashboard';
} else {
const { error } = await res.json();
alert('Invalid code: ' + error);
}
}<!-- TOTP verification UI (shown after login step 1) -->
<div id="totp-verify">
<p>Enter the 6-digit code from your authenticator app:</p>
<input id="totp-code-input" type="text" maxlength="6" inputmode="numeric"
autocomplete="one-time-code" autofocus />
<button onclick="submit2fa()">Verify</button>
</div>Step 4 — Disable 2FA
Call POST /auth/2fa/disable (authenticated):
await fetch('/auth/2fa/disable', {
method: 'POST',
credentials: 'include',
});The server clears totpSecret and sets isTotpEnabled = false.
Multi-Channel 2FA — SMS and Magic-Link as Second Factor
After a successful POST /auth/login that returns requiresTwoFactor: true, the response includes available2faMethods — an array listing which 2FA channels are configured for the user.
The 2FA challenge is triggered when the user has isTotpEnabled = true or require2FA = true. The require2FA flag does not require an authenticator app — magic-link is a valid second factor on its own.
| Value | When it appears |
|-------|----------------|
| 'totp' | User has isTotpEnabled = true and a stored totpSecret |
| 'sms' | User has a stored phoneNumber and config.sms is configured |
| 'magic-link' | config.email.sendMagicLink or config.email.mailer is configured |
Your UI can let the user pick their preferred channel:
const loginRes = await fetch('/auth/login', { /* ... */ });
const { requiresTwoFactor, tempToken, available2faMethods } = await loginRes.json();
if (requiresTwoFactor) {
// Offer available channels to the user
show2faChannelPicker(available2faMethods, tempToken);
}2FA via SMS
Step A — Request the code:
await fetch('/auth/sms/send', {
method: 'POST'
