@bymax-one/nest-auth
v1.4.5
Published
Full-stack authentication and authorization package for NestJS, React and Next.js — JWT, MFA, OAuth, sessions, multi-tenant SaaS ready
Readme
✨ Overview
@bymax-one/nest-auth is a complete authentication and authorization solution shipped as a single npm package with 5 subpath exports — covering everything from NestJS backend guards to React hooks and Next.js route handlers.
Instead of wiring together dozens of packages for JWT, MFA, OAuth, sessions, password reset, and brute-force protection, you install one library and get a production-ready auth system that works across your entire stack.
Why nest-auth?
- 🎯 One package, full stack — Backend module, shared types, fetch client, React hooks, and Next.js integration all in a single
pnpm add. Types and constants are shared automatically between server and client — no manual synchronization. - 🔌 Your database, your rules — The library defines TypeScript interfaces (
IUserRepository,IEmailProvider). You implement them with your ORM of choice (Prisma, TypeORM, Drizzle). No vendor lock-in, no hidden database dependencies. - 🔒 Native crypto only — All security-critical code (password hashing, MFA encryption, TOTP, token generation) runs on
node:crypto— zero third-party crypto packages, so the most sensitive code paths carry no third-party supply-chain risk. - ⚡ Pay for what you use — Features like MFA, sessions, OAuth, and platform admin are opt-in. When not configured, their controllers and services are never registered — zero overhead in your NestJS container.
- 🏢 Multi-tenant ready — Every operation is scoped by
tenantId. Built for SaaS from day one, not bolted on as an afterthought.
pnpm add @bymax-one/nest-auth🔥 Features
🔐 Core Authentication
- ✅ Registration & Login — Email/password with configurable validation
- ✅ JWT Access + Refresh Tokens — Automatic rotation with grace window for concurrent requests
- ✅ Multi-Factor Authentication — TOTP with QR code URI, recovery codes, and challenge flow
- ✅ OAuth 2.0 — Google out of the box, extensible via plugin interface
- ✅ Password Reset — Token-based or OTP, configurable per deployment
- ✅ Email Verification — OTP-based with configurable TTL
🛡️ Security
- ✅ Zero External Crypto — All cryptography via native
node:crypto(scrypt, AES-256-GCM, HMAC-SHA1, TOTP) - ✅ Brute-Force Protection — Configurable rate limiting per email + tenant
- ✅ Session Management — Track active sessions with FIFO eviction, and an
onNewSessionhook fired on every session created (alerting on it is yours to decide — seesendNewSessionAlert) - ✅ HttpOnly Cookies — Secure, SameSite, path-scoped refresh tokens by default
- ✅ Timing-Safe Comparisons — All secret comparisons use
crypto.timingSafeEqual - ✅ JWT Revocation — Instant access token revocation via Redis JTI blacklist
- ✅ Refresh-Token Reuse Detection — Replaying a consumed token revokes that login's whole lineage, and only that lineage
- ✅ Bulk Access-Token Revocation — A password reset advances a per-user token epoch, invalidating every outstanding access token in one write
- ✅ Absolute Session Lifetime — Optional hard cap on how long one login can be extended by rotation
- ✅ Cross-Site Request Refusal — Cookie-authenticated writes from an untrusted origin are rejected (matters under
SameSite=None) - ✅ Breached-Password Refusal — Optional Have I Been Pwned check by k-anonymity range; the password never leaves the process
- ✅ Per-IP Rate Limiting — Enforced by the library over Redis, so the limit holds across instances with no host wiring
🏢 Multi-Tenant & Platform
- ✅ Tenant Isolation — All operations scoped by
tenantIdwith configurable resolver - ✅ Platform Admin Auth — Separate token context and role hierarchy for super-admins
- ✅ User Invitations — Invite users with role assignment and configurable expiration
- ✅ Role-Based Access Control — Hierarchical roles with
@Roles()decorator
🧩 Developer Experience
- ✅ Full-Stack TypeScript — Strict types shared across server and client
- ✅ 5 Subpath Exports — Import only what you need, tree-shakeable
- ✅ Dynamic Module — Configure everything via
registerAsync(), sensible defaults included - ✅ Interface-Driven — Bring your own database and email provider
- ✅ No Passport Required — Guards validate JWT natively via
@nestjs/jwt
📦 Subpath Exports
One package, five entry points — import only what your app needs:
| Subpath | Import | Purpose | Dependencies |
| ----------- | ----------------------------- | ------------------------------------------- | :----------------: |
| Server | @bymax-one/nest-auth | NestJS module, guards, decorators, services | NestJS 11, ioredis |
| Shared | @bymax-one/nest-auth/shared | Types, constants, error codes | None |
| Client | @bymax-one/nest-auth/client | Fetch-based auth client | None |
| React | @bymax-one/nest-auth/react | Hooks & AuthProvider | React 19 |
| Next.js | @bymax-one/nest-auth/nextjs | Proxy, route handlers, JWT helpers | Next.js 16 |
shared (zero deps)
↗ ↖
server client
↑
react
↑
nextjs[!TIP] Prefer to learn from a working app? See the nest-auth-example — a full NestJS + Next.js project wired with this library.
🚀 Quick Start
1. Install
# Using pnpm (recommended)
pnpm add @bymax-one/nest-auth
# Using npm
npm install @bymax-one/nest-auth
# Using yarn
yarn add @bymax-one/nest-auth[!IMPORTANT] You must also install the required peer dependencies for the subpaths you use:
# Server subpath (required)
pnpm add @nestjs/common @nestjs/core @nestjs/jwt @nestjs/throttler @nestjs/websockets ioredis class-validator class-transformer reflect-metadata
# React subpath (optional)
pnpm add react
# Next.js subpath (optional)
pnpm add next react server-only[!NOTE]
server-onlyis what makes importing the Next.js JWT helper from a Client Component a build error. That module receives the HS256 secret, and a secret in a client chunk is a secret published to every visitor — with nothing downstream to notice. It is a marker package with no runtime behaviour, and Next.js's own documentation prescribes it for exactly this.
[!IMPORTANT] Requires
@nestjs/throttler >= 6.0.0forAUTH_THROTTLE_CONFIGSdecorators to be honored.
2. Implement the Repository Interface
The package defines what it needs — your app provides how. The consumer maps the abstract AuthUser fields onto its own database schema (column names, indexes, soft-delete columns are entirely up to you). The only invariant is that passwordHash MUST be persisted exactly as supplied by the library — it is the output of node:crypto scrypt and re-hashing or transforming it will break login.
// user.repository.ts
import { Injectable } from '@nestjs/common'
import type {
AuthUser,
CreateUserData,
CreateWithOAuthData,
FindUserByEmailParams,
FindUserByOAuthIdParams,
IUserRepository,
LinkOAuthParams,
TenantScopedUserRef,
UpdateEmailParams,
UpdateEmailVerifiedParams,
UpdateMfaParams,
UpdatePasswordParams,
UpdateStatusParams
} from '@bymax-one/nest-auth'
import { PrismaService } from './prisma.service'
@Injectable()
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaService) {}
async findById({ id, tenantId }: TenantScopedUserRef): Promise<AuthUser | null> {
// Both halves of the key, always. An id is unique only within a tenant, so `findUnique`
// by id alone can answer with another tenant's row.
return this.prisma.user.findFirst({ where: { id, tenantId } })
}
async findByEmail({ email, tenantId }: FindUserByEmailParams): Promise<AuthUser | null> {
return this.prisma.user.findUnique({
where: { email_tenantId: { email: email.toLowerCase(), tenantId } }
})
}
async create(data: CreateUserData): Promise<AuthUser> {
return this.prisma.user.create({
data: {
email: data.email.toLowerCase(),
name: data.name,
passwordHash: data.passwordHash,
role: data.role ?? 'user',
status: data.status ?? 'pending',
tenantId: data.tenantId,
emailVerified: data.emailVerified ?? false,
mfaEnabled: false
}
})
}
// Every mutator below uses `updateMany` rather than `update`, for one reason: `update` takes a
// UNIQUE where-clause, so it cannot accept `{ id, tenantId }` unless your schema declares that
// pair unique — and `update({ where: { id } })` is the tenant-blind write this port exists to
// prevent. `updateMany` accepts the compound filter and touches nothing outside the tenant.
async updatePassword({ id, tenantId, passwordHash }: UpdatePasswordParams): Promise<void> {
await this.prisma.user.updateMany({ where: { id, tenantId }, data: { passwordHash } })
}
async updateMfa({ id, tenantId, data }: UpdateMfaParams): Promise<void> {
await this.prisma.user.updateMany({
where: { id, tenantId },
data: {
mfaEnabled: data.mfaEnabled,
mfaSecret: data.mfaSecret,
mfaRecoveryCodes: data.mfaRecoveryCodes ?? []
}
})
}
async updateLastLogin({ id, tenantId }: TenantScopedUserRef): Promise<void> {
await this.prisma.user.updateMany({
where: { id, tenantId },
data: { lastLoginAt: new Date() }
})
}
async updateStatus({ id, tenantId, status }: UpdateStatusParams): Promise<void> {
await this.prisma.user.updateMany({ where: { id, tenantId }, data: { status } })
}
async updateEmailVerified({ id, tenantId, verified }: UpdateEmailVerifiedParams): Promise<void> {
await this.prisma.user.updateMany({
where: { id, tenantId },
data: { emailVerified: verified }
})
}
async updateEmail({ id, tenantId, email }: UpdateEmailParams): Promise<void> {
await this.prisma.user.updateMany({
where: { id, tenantId },
data: { email: email.toLowerCase() }
})
}
async findByOAuthId({
provider,
providerId,
tenantId
}: FindUserByOAuthIdParams): Promise<AuthUser | null> {
return this.prisma.user.findFirst({
where: { oauthProvider: provider, oauthProviderId: providerId, tenantId }
})
}
async linkOAuth({ id, tenantId, provider, providerId }: LinkOAuthParams): Promise<void> {
await this.prisma.user.updateMany({
where: { id, tenantId },
data: { oauthProvider: provider, oauthProviderId: providerId }
})
}
async createWithOAuth(data: CreateWithOAuthData): Promise<AuthUser> {
return this.prisma.user.create({
data: {
email: data.email.toLowerCase(),
name: data.name,
passwordHash: null,
role: data.role ?? 'user',
status: data.status ?? 'active',
tenantId: data.tenantId,
emailVerified: data.emailVerified ?? true,
oauthProvider: data.oauthProvider,
oauthProviderId: data.oauthProviderId,
mfaEnabled: false
}
})
}
}3. Implement the Email Provider Interface
Email delivery is fully delegated to the consumer — the library never imports a mailer SDK. Implement IEmailProvider with your transport of choice (Resend, SendGrid, SES, Nodemailer) and bind it to the BYMAX_AUTH_EMAIL_PROVIDER token.
[!WARNING] Any user-supplied value (display name, tenant name, inviter name) interpolated into HTML email bodies MUST be escaped to prevent stored XSS in notification content. Tokens and OTPs are library-generated and safe, but
inviterName,tenantName, device strings, and any consumer-supplied placeholder are attacker-controllable.
[!CAUTION] Never log the error your transport rejects with — it can contain the code you just sent. A policy, DLP or anti-spam relay answering
550commonly quotes the offending message body, so the error yoursend()throws carries the OTP or token this library rendered into it. Logging that error, or attaching it as acauseto one you log, puts a working credential into your log pipeline in clear text until it expires. Measured on a real relay, not hypothesised.This is your half: the library cannot reach inside your provider implementation.
describeChannelStatusis exported for it — the same helper the bundled provider uses on every one of its own log lines, so you get the whole treatment rather than just redaction:import { describeChannelStatus } from '@bymax-one/nest-auth' async sendPasswordResetOtp(tenantId: string, email: string, otp: string): Promise<void> { try { await this.resend.emails.send({ /* ... */ }) } catch (error: unknown) { // Never `logger.error(msg, error)` here, and never `new Error(msg, { cause: error })` // into something that logs — both carry the quoted body. this.logger.error(`reset OTP delivery failed: ${describeChannelStatus(error)}`) throw error } }Two functions, not one with a mode: which one you call IS the decision, and there is no permissive default to inherit by forgetting.
describeChannelStatus(error)wherever the body rendered something you would withhold. It takes no secrets, and that is the guarantee rather than an omission — nothing the channel wrote is published, so there is nothing to name. Redaction alone does not close this, and neither does a length cap. A relay may quote the body it rejected in transfer encoding rather than verbatim; base64 is the ordinary case and defeats both at once — substring matching finds nothing because the credential's characters are not in the line, and the cap does not help because the encoding runs from the body's first byte, so the code is in the first sentence. Measured: a reset-code body is 96 base64 characters end to end, and the first 200 characters of the line decode straight back to the OTP. UnderdescribeChannelStatusNOTHING the channel wrote reaches the line — not the message, not thename, not a status parsed off the front. Each of those was tried and each fell: shape validation admitsMTIzNDU2, the base64 of OTP123456; and a status grammar admits424-242, an OTP grouped, publishing424. What you keep is which message failed. What you lose is the transient-versus-permanent split, which your mail provider's dashboard has and this library does not.
describeError(error, [values])for errors whose text you have a reason to trust, where the values you name appear the way you wrote them and redaction reaches them. The bundled provider uses it nowhere — every one of its paths uses the opaque form, including the notices that render nothing secret, because a relay may re-encode whatever it quotes and the recipient address is in the message either way. If you can say the same about your channel, preferdescribeChannelStatusand keep this one for strings you built yourself.Neither reads
stack, and neither reads the transport's own fields — nodemailer hangs the server's full reply onresponse. Beyond that they differ, and the difference is the point:describeErrorreadsnameandmessage, strips the values you named, caps the length and removes control characters so a relay cannot forge extra records in a line-oriented pipeline.describeChannelStatusreads neither — the only thing it touches iscause, walked to count the links. Both walk that chain and never throw, whatever the transport's error does.
redactSecrets(text, [otp])is exported too, for a string you built yourself and know contains the literal value. It is not a substitute for either description on a transport's error: a substring match cannot see through an encoding, which is the whole reason the bundled provider stopped relying on it.If you use the bundled
DefaultAuthEmailProviderwithonDeliveryError: 'rethrow', the error it re-throws is the channel's original and is still yours to contain the same way.
// email.provider.ts
import { Injectable } from '@nestjs/common'
import type { IEmailProvider, InviteData, SessionAlertInfo } from '@bymax-one/nest-auth'
import { Resend } from 'resend'
const escapeHtml = (s: string): string =>
s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
@Injectable()
export class ResendEmailProvider implements IEmailProvider {
private readonly client = new Resend(process.env.RESEND_API_KEY!)
private readonly from = '[email protected]'
private readonly appUrl = process.env.APP_URL!
async sendPasswordResetToken(
_tenantId: string,
email: string,
token: string,
_locale?: string
): Promise<void> {
const url = `${this.appUrl}/reset-password?token=${encodeURIComponent(token)}`
await this.client.emails.send({
from: this.from,
to: email,
subject: 'Reset your password',
html: `<p>Click <a href="${url}">here</a> to reset your password.</p>`
})
}
async sendPasswordResetOtp(
_tenantId: string,
email: string,
otp: string,
_locale?: string
): Promise<void> {
await this.client.emails.send({
from: this.from,
to: email,
subject: 'Your password reset code',
html: `<p>Your code is <strong>${otp}</strong>. It expires in 10 minutes.</p>`
})
}
async sendEmailVerificationOtp(
_tenantId: string,
email: string,
otp: string,
_locale?: string
): Promise<void> {
await this.client.emails.send({
from: this.from,
to: email,
subject: 'Verify your email',
html: `<p>Your verification code is <strong>${otp}</strong>.</p>`
})
}
// The library does NOT await the three MFA notices — by the time one is sent the factor is
// already enabled or removed, so a bounced notice must not answer the caller with an error for
// an operation that succeeded. The consequence is here: an inline send like this one can be
// lost to a process shutdown or a serverless freeze arriving between the response and the send
// completing. Where these alerts matter — the administrative reset most of all, since it is what
// makes a support-desk takeover detectable — enqueue durably and resolve instead of sending
// inline. Awaiting would not fix it either: a freeze mid-await loses the notice AND the response.
async sendMfaEnabledNotification(
_tenantId: string,
email: string,
_locale?: string
): Promise<void> {
await this.client.emails.send({
from: this.from,
to: email,
subject: 'MFA enabled on your account',
html: '<p>Two-factor authentication has been enabled. If this was not you, contact support immediately.</p>'
})
}
async sendMfaDisabledNotification(
_tenantId: string,
email: string,
_locale?: string
): Promise<void> {
await this.client.emails.send({
from: this.from,
to: email,
subject: 'MFA disabled on your account',
html: '<p>Two-factor authentication has been disabled. If this was not you, contact support immediately.</p>'
})
}
async sendNewSessionAlert(
_tenantId: string,
email: string,
sessionInfo: SessionAlertInfo,
_locale?: string
): Promise<void> {
await this.client.emails.send({
from: this.from,
to: email,
subject: 'New sign-in to your account',
html: `
<p>New session detected:</p>
<ul>
<li>Device: ${escapeHtml(sessionInfo.device)}</li>
<li>IP: ${escapeHtml(sessionInfo.ip)}</li>
<li>Session: ${escapeHtml(sessionInfo.sessionHash)}</li>
</ul>
`
})
}
async sendInvitation(
_tenantId: string,
email: string,
inviteData: InviteData,
_locale?: string
): Promise<void> {
const url = `${this.appUrl}/accept-invite?token=${encodeURIComponent(inviteData.inviteToken)}`
await this.client.emails.send({
from: this.from,
to: email,
subject: `You have been invited to ${inviteData.tenantName}`,
html: `
<p><strong>${escapeHtml(inviteData.inviterName)}</strong> invited you to join
<strong>${escapeHtml(inviteData.tenantName)}</strong>.</p>
<p><a href="${url}">Accept invitation</a></p>
<p>This link expires on ${inviteData.expiresAt.toUTCString()}.</p>
`
})
}
}Wire it via extraProviders alongside the user repository:
import { BYMAX_AUTH_EMAIL_PROVIDER } from '@bymax-one/nest-auth'
extraProviders: [{ provide: BYMAX_AUTH_EMAIL_PROVIDER, useClass: ResendEmailProvider }]4. Register the Module
The user repository and Redis client are provided via NestJS dependency injection tokens — not as direct config fields. This follows the NestJS custom providers pattern and ensures the DI container manages all dependencies correctly.
// app.module.ts
import { Module } from '@nestjs/common'
import {
BymaxAuthModule,
BYMAX_AUTH_USER_REPOSITORY,
BYMAX_AUTH_REDIS_CLIENT
} from '@bymax-one/nest-auth'
@Module({
imports: [
BymaxAuthModule.registerAsync({
imports: [ConfigModule, DatabaseModule, RedisModule],
useFactory: (config: ConfigService) => ({
jwt: {
secret: config.get('JWT_SECRET'), // min 32 chars, high entropy
accessExpiresIn: '15m',
refreshExpiresInDays: 7
},
tokenDelivery: 'cookie', // 'cookie' | 'bearer' | 'both'
// Required while the limiter is on, and neither value can be a default:
// 'peer' behind a proxy reads the proxy's address for every request, and
// 'trusted-proxy' without one trusts a header the client can forge.
rateLimit: { clientIpSource: 'trusted-proxy' }, // 'peer' | 'trusted-proxy'
roles: {
hierarchy: {
admin: ['manager', 'user'],
manager: ['user'],
user: []
}
}
}),
inject: [ConfigService],
extraProviders: [
{
provide: BYMAX_AUTH_USER_REPOSITORY,
useClass: PrismaUserRepository
},
{
provide: BYMAX_AUTH_REDIS_CLIENT,
useFactory: (redis: RedisService) => redis.client,
inject: [RedisService]
}
]
})
]
})
export class AppModule {}5. Protect Routes
// users.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common'
import { JwtAuthGuard, RolesGuard, Roles, CurrentUser } from '@bymax-one/nest-auth'
// `import type` is required for a type used in a decorated signature: with
// `emitDecoratorMetadata` and `isolatedModules`, a value import would be emitted
// into the metadata and fail to erase (TS1272).
import type { DashboardJwtPayload } from '@bymax-one/nest-auth'
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
@Get('me')
getProfile(@CurrentUser() user: DashboardJwtPayload) {
return { id: user.sub, role: user.role, tenantId: user.tenantId }
}
@Get()
@Roles('admin')
listUsers() {
// Only accessible by admins (and above in hierarchy)
}
}6. Frontend Integration (React)
Build an AuthClient once with createAuthClient, then hand it to
AuthProvider. Hooks (useSession, useAuth, useAuthStatus) read
the context populated by the provider.
// app/providers.tsx
'use client'
import { AuthProvider } from '@bymax-one/nest-auth/react'
import { createAuthClient } from '@bymax-one/nest-auth/client'
const authClient = createAuthClient({
// Same-origin: a relative base sends every call through the Next.js
// proxy routes — `'/api'` plus the default `routePrefix` composes
// `/api/auth/*`. Use an absolute origin for a cross-origin API.
baseUrl: '/api'
})
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider client={authClient} onSessionExpired={() => (location.href = '/login')}>
{children}
</AuthProvider>
)
}Which 401 spends a refresh. The client's
authFetchretries through/refreshonly when the 401 says the access token is the problem —auth.token_invalid, or a body carrying no readable code. A route can sit behind the JWT guard and verify a second credential, soauth.invalid_credentialsfrom a password change and an expired token arrive at the same URL with the same status; the code separates them, the path never could. Any other 401 is returned to the caller untouched, andcreateAuthClient({ onSessionExpired })fires only when a refresh was warranted and failed.AuthProvider's ownonSessionExpiredprop is a different hook: it reacts to a 401 from the session read itself.
// app/(dashboard)/profile.tsx
'use client'
import { useAuth, useSession } from '@bymax-one/nest-auth/react'
export function Profile() {
const { user, status } = useSession()
const { logout } = useAuth()
if (status === 'loading') return <div>Loading…</div>
// `status` and `user` are separate fields, so the status check alone does
// not narrow `user` away from null — test the one you are about to read.
if (!user) return <div>Please log in</div>
return (
<div>
<p>Welcome, {user.name}!</p>
<button onClick={() => logout()}>Sign out</button>
</div>
)
}Plain SPAs — set refreshEndpoint
The example above is a Next.js app, where the default works. A Vite/CRA SPA talking straight to
a Nest backend must set refreshEndpoint, because it defaults to /api/auth/client-refresh —
a Next.js proxy route this library ships for that framework, and a path a plain SPA serves
nothing at.
const authClient = createAuthClient({
baseUrl: 'https://api.example.com',
// Without this, refresh POSTs to the Next proxy route and 404s.
refreshEndpoint: 'https://api.example.com/auth/refresh'
})The symptom, because it does not look like a configuration problem: if every access-token
expiry logs the user out, check refreshEndpoint before looking at cookies. The 404 is silent —
refresh fails, the session ends, and it presents as a session bug rather than as a missing route.
Also note tenantId is optional on every client input. Whether the server wants it is the
deployment's answer, not the type's: it is required when no tenantIdResolver is configured and
refused when one is, answering auth.validation with a tenantId field detail either way.
Send it, or do not, according to the deployment you talk to.
7. Frontend Integration (Next.js 16)
Mount the Edge-Runtime auth proxy at the project root and expose the
three /api/auth/* route handlers. The proxy handles anti-redirect-
loop protection, RBAC, status blocking, and background-request
detection; the route handlers bridge the browser to your NestJS
backend.
// proxy.ts — Next.js 16 Edge middleware
import { createAuthProxy } from '@bymax-one/nest-auth/nextjs'
// Next 16 scans this file for a function exported as `proxy` (or as the default), and it
// does not recognise a destructuring pattern: `export const { proxy } = ...` fails the build
// with "The file ./proxy.ts must export a function". Bind first, then export.
const authProxy = createAuthProxy({
publicRoutes: ['/', '/auth/login', '/auth/register'],
publicRoutesRedirectIfAuthenticated: ['/auth/login', '/auth/register'],
protectedRoutes: [
{ pattern: '/dashboard/:path*', allowedRoles: ['admin', 'member'] },
{ pattern: '/admin/:path*', allowedRoles: ['admin'] }
],
loginPath: '/auth/login',
getDefaultDashboard: (role) => (role === 'admin' ? '/dashboard/admin' : '/dashboard'),
apiBase: process.env.API_BASE_URL!,
jwtSecret: process.env.JWT_SECRET!,
cookieNames: {
access: 'access_token',
refresh: 'refresh_token',
hasSession: 'has_session'
},
userHeaders: {
userId: 'x-user-id',
role: 'x-user-role',
tenantId: 'x-tenant-id',
tenantDomain: 'x-tenant-domain'
},
blockedUserStatuses: ['BANNED', 'INACTIVE', 'EXPIRED']
})
export const proxy = authProxy.proxy
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
}// app/api/auth/silent-refresh/route.ts
import { createSilentRefreshHandler } from '@bymax-one/nest-auth/nextjs'
export const GET = createSilentRefreshHandler({
apiBase: process.env.API_BASE_URL!,
loginPath: '/auth/login',
cookieNames: {
access: 'access_token',
refresh: 'refresh_token',
hasSession: 'has_session'
}
})// app/api/auth/client-refresh/route.ts
import { createClientRefreshHandler } from '@bymax-one/nest-auth/nextjs'
export const POST = createClientRefreshHandler({ apiBase: process.env.API_BASE_URL! })// app/api/auth/logout/route.ts
import { createLogoutHandler } from '@bymax-one/nest-auth/nextjs'
export const POST = createLogoutHandler({
apiBase: process.env.API_BASE_URL!,
mode: 'redirect',
loginPath: '/auth/login',
cookieNames: {
access: 'access_token',
refresh: 'refresh_token',
hasSession: 'has_session'
}
})WebSocket upgrades
The browser WebSocket API cannot set handshake headers, so a browser client cannot send
Authorization: Bearer <token> at the upgrade. The usual workaround puts the access token in the
query string, where it lands in access logs, browser history and proxy caches — a long-lived
credential in plaintext. WsJwtGuard refuses it.
The supported path is a single-use ticket:
// 1. Mint from an authenticated session (POST, cookies or bearer as usual).
const { ticket, expiresIn } = await fetch('/auth/ws-ticket', {
method: 'POST',
credentials: 'include'
}).then((r) => r.json())
// 2. Open the socket with it. The ticket is consumed by the first redemption.
const socket = new WebSocket(`wss://api.example.com/socket?ticket=${ticket}`)The ticket is opaque, 32 bytes of CSPRNG output, and lives 30 seconds. Only sha256(ticket) is
ever a Redis key, and the stored value is a verified-identity snapshot — no jti, no
signature, no expiry of its own — so a redeemed ticket authorizes a socket and cannot be turned
back into a session. Minting requires an authenticated session in good standing that has already
satisfied MFA, so a ticket never carries more authority than the request that asked for it.
Non-browser clients that can set headers keep using Authorization: Bearer at the handshake;
both channels are accepted, and a ticket wins when both are present.
Both channels live on the client's handshake, and only a Socket.IO client has one — with
@nestjs/platform-ws the gateway receives the raw ws socket, which carries no handshake and
does not retain the upgrade request. WsJwtGuard refuses such a connection with
auth.token_invalid instead of crashing on it, but it cannot authenticate anyone on that
adapter. And because AuthException extends HttpException, which Nest's WebSocket layer does
not recognise, a gateway that applies the guard also needs WsAuthExceptionFilter for the
refusal to reach the client as anything but Internal server error — see
On a WebSocket, the envelope needs its own filter.
⚙️ Configuration
All options are configurable via registerAsync(). Here are the key configuration groups:
| Group | Key Options | Default |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| jwt | secret (required), previousSecrets, accessExpiresIn, refreshExpiresInDays, absoluteSessionLifetimeDays, algorithm, issuer, audience | 15m, 7d, 30d cap, HS256, both off |
| environment | 'production' | 'development' | 'test' — the only input that answers "is this production" | 'production' |
| password | minLength, costFactor, blockSize, parallelization | 15, scrypt N=2¹⁷, r=8, p=1 |
| tokenDelivery | 'cookie' | 'bearer' | 'both' | 'cookie' |
| cookies | accessTokenName, refreshTokenName, sessionSignalName, refreshCookiePath, sameSite, trustedOrigins, resolveDomains | 'lax', [] (see cookie section) |
| mfa | encryptionKey, previousEncryptionKeys, issuer, totpWindow, recoveryCodeCount | — |
| sessions | enabled, defaultMaxSessions, maxSessionsResolver | false, 5, — |
| bruteForce | maxAttempts, windowSeconds | 5, 900 |
| rateLimit | enabled, clientIpSource ('peer' | 'trusted-proxy') — per-IP limits over Redis | true, required |
| passwordReset | method ('token' | 'otp'), otpLength, otpTtlSeconds | 'token' |
| platform | enabled | false |
| invitations | enabled, tokenTtlSeconds | false |
| roles | hierarchy (required), platformHierarchy | — |
| oauth | google: { clientId, clientSecret, callbackUrl } | — |
| emailVerification | required, otpTtlSeconds | true, 600 |
| password (screen) | blocklist — extra words the default screen refuses, on top of the ones it ships | [] |
| controllers | Toggle individual controllers on/off | auth, passwordReset on; rest opt-in |
[!NOTE] When a feature is not configured (e.g.,
mfa,sessions,platform), its controllers and services are not registered in the NestJS container — zero overhead.
[!IMPORTANT]
environmentis how the module decides whether this is production, and it defaults to saying yes. It drives cookieSecure, the HTTPS requirement on the OAuthcallbackUrl, and three redirect validations. It used to be read fromNODE_ENV, which failed open on every near miss — unset,'staging','prod', or'production 'with a trailing space each silently took the insecure branch, in all six places at once. Whether a deployment is production is something the deployer knows and the process environment only hints at, so it is passed in. The consequence to plan for: a local or test setup must now sayenvironment: 'development'(or'test') explicitly, or it will be held to production rules — anhttp://callback URL is refused, and cookies are markedSecureand never sent over plaintext. That failure is loud, which is the point; the old one was silent. MatchesEnvironmentin rust-auth.
[!NOTE]
password.minLengthdefaults to 15, not 8. The DTOs keep a structural floor of 8 — the lowest NIST SP 800-63B-4 §3.1.1.1 permits under any circumstance — and this is the deployment's policy on top of it. §3.1.1.1 allows 8 only for a password used as part of multi-factor authentication and requires 15 for one used as a single factor; MFA here is opt-in per user, so the default deployment is single-factor. Configurable to anything in8..=128, validated at startup: below 8 changes no outcome (the DTOs refuse the request first), and above 128 is longer than any password the validation layer accepts. It is checked in the service rather than a decorator because a decorator is evaluated when the class is defined, before any configuration exists — and it answers the sameauth.validationcode and the same{ field, message }[]details a length failure already produced, so a client handling short passwords sees no new shape.
[!IMPORTANT]
rateLimit.clientIpSourceis required whenever rate limiting is enabled — there is no default. The option group is a discriminated union, so TypeScript refuses the omission at compile time; the module also refuses to start without it, because the type binds TypeScript and nothing else. Set'peer'when the application is directly exposed: the limit keys on the socket address, read from the connection and never from a forwarding header. Set'trusted-proxy'when it runs behind a proxy andtrust proxyis configured for the real hop count: the limit keys onreq.ip, the forwarded client address. Neither can be the default, because each is a working limiter in one deployment and no limiter at all in the other —'peer'behind a proxy puts every client in one bucket, so a single caller can rate-limit your whole user base with no credential, and'trusted-proxy'without a proxy lets the caller choose their own key. Both look like a working limiter at runtime. PassrateLimit.enabled: falseif the limits are enforced at the edge instead.
[!TIP] Binding tokens to an issuer and an audience.
jwt.issuerandjwt.audienceare off by default. Set either and its value is stamped on every token this backend mints and required on every token it verifies — one carrying a different value, or none at all, is rejected. That matters with HS256, where the verifier can also sign: every service holding the secret to check a token can mint one, so audience binding is what stops a token minted for one service being replayed at another that trusts the same secret.Two things to know before switching it on. Both backends of a shared deployment must carry the same pair, or they stop accepting each other's tokens. And enabling it invalidates the access tokens already in flight, since those were minted without the claims — a window of one access-token lifetime, which clients close by refreshing. An empty string reads as unconfigured rather than as "require the empty issuer", so an unset environment variable cannot turn the check on by accident.
Rotating the signing secret.
jwt.previousSecretslists secrets retired by a rotation, accepted for verification only. Without it, changingjwt.secretsigns every user out the moment the new configuration rolls out and invalidates every stored recovery-code digest — those are keyed by an HMAC derived from the secret, so users lose the codes they printed and filed. With it, both keep working while tokens issued under the old secret drain, and a rotation becomes a rollout. Remove the entry once the longest-lived token signed under it has expired: every entry is a key that still opens the door.mfa.encryptionKeyrotates the same way, through its own list — see below.
[!TIP] Rotating the MFA encryption key.
mfa.previousEncryptionKeyslists AES-256 keys retired by a rotation ofmfa.encryptionKey. The stored ciphertext carries no key identifier, so without the list a change of key makes every enrolled user's TOTP secret undecryptable at once, with no way back — their authenticator simply stops matching. With it, a stored secret that opened under a retired key is re-encrypted under the current one on the next successful challenge, so the rotation drains on its own instead of requiring the retired key to stay configured forever. Each entry is validated at startup exactly like the current key (base64, exactly 32 bytes, and never equal to the current key or to another entry), because a malformed one would otherwise surface at a user's first challenge rather than at boot. Drop the entry once your enrolled users have had time to authenticate at least once.
[!IMPORTANT] The parameters that carry a control's strength are bounded at startup.
mfa.totpWindowmust be0..=10: the window counts 30-second steps on either side of now, so2n + 1codes are valid at once — three at the default of 1, but 121 at 60, which makes a six-digit code a hundred times easier to guess while the configuration still reads as "MFA enabled".mfa.recoveryCodeCountmust be1..=50, because zero enrols an account with no way back if the authenticator is lost.password.blockSizemust be at least 8 andpassword.parallelizationat least 1: scrypt's memory cost is128 * N * r, so a smaller block size divides the hardness thatpassword.costFactor's floor exists to guarantee — invisibly, since the bounded parameter is still intact.rust-authenforces the identical ranges.
[!IMPORTANT]
jwt.accessExpiresInmust not exceed 30 days, the window the store keeps a bumped token epoch readable. The epoch is what makes a stateless access token revocable: a password reset advances it and every token stamped below it stops verifying — but only while the bumped value is still there. A longer-lived access token would outlive it, the lookup would fall back to0, and a token the reset revoked would verify again. Startup refuses the configuration rather than letting it fail open, and rejects an unreadable time span or a non-positive lifetime on the same pass.
[!WARNING] Do not gate on the access token's
statusclaim. It is point-in-time, never authoritative, and which of its three states you get depends on things a client cannot see:| how the token was minted |
status| | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | login, register, OAuth, MFA challenge | the account's value at that moment | | an ordinary refresh rotation | empty string — the session record carries no live status, so there is nothing to copy | | a refresh that re-signs becauserole,tenantIdormfaEnabledchanged | the account's value at that moment, read during that request |
rust-authstamps the same empty string on its rotation path, deliberately and with a test pinning it, so the middle row is a shared contract rather than a defect in either library.Every populated value is stale the instant the account changes, because status can change under an unexpired token and nothing re-stamps it. So a route that reads the claim is wrong in both directions:
status !== 'active'refuses everyone whose session has been refreshed ordinarily, andstatus === 'suspended'refuses nobody, ever. Both fail quietly and hours from the code that caused them — the first looks like a broken login, the second like nothing at all.The exceptional re-stamp is the worst of the three for a reader, not the best: it makes the claim usually wrong instead of reliably empty, which is the failure mode that survives testing.
Status is resolved per request or not at all: mount
UserStatusGuardon the route, and for anything richer read the account tenant-scoped, the way that guard does —findById({ id: request.user.sub, tenantId: request.user.tenantId }). The tenant argument is required by the port and cannot be dropped: ids may collide across tenants, so a lookup by bare id can resolve another tenant's account. That is what the library's own guards do, which is why the claim can be left as it is.mfaVerifiedbehaves the same way and for the same reason — it is alwaysfalseafter a rotation, so step-up does not survive a refresh and a user re-acquires it through the MFA challenge.One consequence worth planning for: the guard reads a status cache with a
userStatusCacheTtlSecondswindow (default 60), so a suspension takes up to that long to bite on a guarded route, and a reactivation the same to restore. Nothing exported invalidates that key for one user today — the only immediate lever isbumpUserTokenEpoch, which ends every session the user has rather than refreshing one cached string. Lower the TTL if a faster answer matters more than the repository reads it costs.
jwt.absoluteSessionLifetimeDays caps how long one login can be extended by rotation, and is
on by default at 30 days — NIST SP 800-63B-4 §3 makes a definite reauthentication timeout a
SHALL and puts it at no more than 30 days for AAL1. Without a cap, a client refreshing every
fifteen minutes keeps a session alive forever, and a refresh token stolen once becomes permanent
access. Raise it to a value the product can justify, or set 0 to accept unbounded sessions
deliberately.
Lowering the value ends sessions that are already older than the new one, at their next rotation;
raising it or setting 0 ends nothing. Sessions established before the cap existed carry no
recorded birth time and are never capped, whatever the value — they age out under
refreshExpiresInDays like any other.
cookies.trustedOrigins is deliberately off by default, because switching it on changes
behaviour for origins that already exist. It is required as soon as cookies.sameSite: 'none'
is set, and refused otherwise — that posture is the only one where the browser sends the session
cookie cross-site, and it is the only one where the origin check has anything to authorize.
The breach check is opt-in for a different reason: it is the only part of the credential path that reaches the network, and a library should not start talking to a third party because it was upgraded. Wire it explicitly:
BymaxAuthModule.registerAsync({
useFactory: () => ({ ... }),
extraProviders: [{ provide: BYMAX_AUTH_BREACH_CHECKER, useClass: HibpBreachChecker }]
})🏗️ Architecture
The package runs inside your NestJS application as a dynamic module — not as a separate service:
┌─────────────────────────────────────────────┐
│ Your NestJS Application │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ @bymax-one/nest-auth │ │
│ │ │ │
│ │ Controllers ←→ Services ←→ Redis │ │
│ │ Guards ←→ Crypto (node:crypto) │ │
│ │ Decorators ←→ Token Manager (JWT) │ │
│ └──────────┬────────────┬───────────────┘ │
│ │ │ │
│ ┌───────▼──┐ ┌──────▼───────┐ │
│ │ IUser │ │ IEmail │ │
│ │ Repo │ │ Provider │ │
│ │ (yours) │ │ (yours) │ │
│ └──────────┘ └──────────────┘ │
└─────────────────────────────────────────────┘Design Principles
| Principle | Description |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| 🔌 Interface-Driven | Define contracts, inject implementations — works with Prisma, TypeORM, Drizzle, or any SQL ORM |
| 🔒 Secure by Default | scrypt hashing, HttpOnly cookies, JWT blacklisting, brute-force protection — all enabled out of the box |
| 🪶 Zero Runtime Deps | "dependencies": {} — adds no runtime deps of its own; crypto is native node:crypto. Required peers (NestJS, ioredis…) come from your app |
| 🌳 Tree-Shakeable | sideEffects: false, subpath exports, ESM + CJS dual output |
| ⚡ Conditional Loading | Unconfigured features don't register — no wasted memory or startup time |
🔐 Security Model
The security architecture follows established standards and industry best practices.
JWT Token Type Discrimination
Every token carries a type claim that guards validate before accepting:
| Token type | Issued when | Accepted by |
| ----------------- | ----------------------------------------- | ---------------------- |
| 'dashboard' | Successful login or MFA challenge | JwtAuthGuard |
| 'platform' | Platform admin login or MFA challenge | JwtPlatformGuard |
| 'mfa_challenge' | Login with MFA enabled (pre-verification) | MFA challenge endpoint |
This prevents token type confusion attacks — a class of vulnerability documented by OWASP where a token issued for one purpose is accepted by another. The same pattern is used by AWS Cognito (token_use claim) and recommended by Curity's JWT best practices guide.
JwtPlatformGuard returns PLATFORM_AUTH_REQUIRED (not the generic TOKEN_INVALID) when a dashboard token is submitted to a platform route — so clients can distinguish wrong-context from expired/invalid errors.
Separate Auth Contexts for Multi-Tenant SaaS
Platform admins and tenant users are fully isolated stacks — separate repositories, JWT payloads, guards, and routes. A platform admin token cannot access tenant routes, and a tenant token cannot access platform routes, regardless of role. This aligns with the architecture recommended by AWS, Logto, and WorkOS for multi-tenant SaaS platforms.
The tenantId is always extracted from the validated JWT — never from the request body — preventing tenant spoofing at the architecture level.
Token Revocation via Redis JTI Blacklist
Access tokens are short-lived (default 15 minutes) and immediately revocable via a Redis JTI blacklist. Refresh tokens rotate on every use with a configurable grace window to handle concurrent requests. This is the industry-standard hybrid approach used by Auth0, Okta, and SuperTokens — combining short lifetimes for low-latency revocation with rotating refresh tokens for session continuity.
Password Hashing
Passwords are hashed with scrypt via node:crypto, which is memory-hard and resistant to GPU-based brute-force attacks. All secret comparisons use crypto.timingSafeEqual for constant-time evaluation — a requirement explicitly documented in the Node.js crypto documentation.
No External Cryptographic Dependencies
All security-critical operations use the OpenSSL-backed node:crypto module — no bcrypt, argon2, otpauth, uuid, or nanoid packages. This eliminates the supply chain attack surface for the most sensitive code paths.
Security Checklist
When integrating @bymax-one/nest-auth in production, verify each of the following:
cookies.resolveDomainsMUST validate against an allowlist of configured domains- MFA recovery without TOTP requires admin intervention (no self-service)
@MaxLength(128)on password DTOs prevents algorithmic-DoS via oversized scrypt inputs- JWT algorithm pinning to HS256 prevents algorithm-confusion attacks
- Constant-time comparisons via
crypto.timingSafeEqualfor all secret comparisons - HttpOnly cookies;
Secureenforced in production;SameSite=Laxby default on every auth cookie, so the OAuth provider's cross-site redirect back to your app still carries them. Deployments that do not need that redirect can take the stricter posture withcookies.sameSite: 'strict'. CSRF does not rest on this setting —TrustedOriginGuardis applied to every controller and runs before authentication.
The tokens are never readable from JavaScript, and verifying that end-to-end is yours. Under
cookie delivery this library never writes a token to localStorage, sessionStorage, or a
JS-readable cookie: access_token and refresh_token are HttpOnly, and the only readable
cookie is has_session=1, a hint carrying no credential so a SPA can tell a session probably
exists without touching a token.
This library's suite asserts its half — that the Set-Cookie headers carry those flags — and
it structurally cannot assert the other half. A token leaking into JS-readable storage is
invisible from the server: the API answers identically, the wire looks correct, and every test
here passes. Only a browser observes it. If you run a browser suite, assert there that
localStorage and sessionStorage are empty and that document.cookie carries neither token;
it is the one guarantee cookie delivery exists for and the one no server-side test can reach.
🛡️ Security Table
| Layer | Implementation |
| ------------------ | ------------------------------------------------------------------------------------------------------ |
| Password Hashing | node:crypto scrypt (N=2¹⁷, r=8, p=1, keyLen=64) — OWASP's recommended minimum |
| MFA Encryption | AES-256-GCM with 12-byte random IV per call |
| TOTP | HMAC-SHA1 per RFC 4226/6238, ±1 step window |
| Token Generation | crypto.randomBytes(32) — 256 bits of entropy |
| Secret Comparison | crypto.timingSafeEqual (constant-time) |
| JWT | HS256 via @nestjs/jwt, JTI blacklist via Redis |
| Cookies | HttpOnly, Secure, SameSite=Lax (override to strict), path-scoped |
| Brute-Force | Redis atomic counters per HMAC(email, jwt.secret) |
| CSRF (OAuth) | 64-char hex state nonce, single-use via getdel() |
| Refresh Rotation | Single-use tokens with a grace window; a replay past it revokes that login's whole family lineage |
| Cross-Site Writes | Origin / Sec-Fetch-Site check on cookie-authenticated writes — the gap SameSite=None leaves open |
| Breached Passwords | Optional Have I Been Pwned range check by k-anonymity; only a 5-char SHA-1 prefix leaves the process |
| Rate Limiting | Per-IP fixed-window counters in Redis, keyed by HMAC(ip) — enforced by the library, not by the host |
| Session Lifetime | Optional absolute cap on how long one login can be extended by rotation |
[!IMPORTANT] This package uses zero external cryptographic dependencies. All operations use Node.js native
node:crypto, eliminating supply chain attack vectors for critical security code.
🧱 Tech Stack
🧪 Testing & Quality
Authentication is critical infrastructure, so the suite is held to a bar beyond "it runs" — every behavior is pinned so that a regression fails a test.
- ✅ 100% line coverage — statements, branches, functions, and lines, enforced as a release gate across unit + e2e
- ✅ 100% mutation score — verified with Stryker: 5,333 seeded faults detected (5,311 killed, 22 timed out), no survivors and nothing left uncovered, against a
breakthreshold of 100 (measured cold on 2026-08-15) - ✅ 3,971 tests — 3,721 unit and 250 end-to-end, spanning all five subpaths
- ✅ Every equivalent mutant documented — the 367 mutants that no test can kill (a redundant guard, a dependency array of stable references) each carry an inline
// Stryker disablewith the reason, so the score is an accounting rather than a number
pnpm test # unit suite
pnpm test:cov:all # unit + e2e, 100% coverage gate
pnpm mutation # Stryker mutation testing[!NOTE] Line coverage proves a line executed under test; mutation testing proves a test would fail if that line were wrong. The full methodology and per-area breakdown are in docs/mutation_testing_results.md.
📖 API Reference
HTTP Endpoints
Conditionally registered controllers (mfa, sessions, platform, invitations, oauth, password-reset) only mount their endpoints when the corresponding feature is enabled in BymaxAuthModule.registerAsync().
| Method | Path | Auth / Guard | Description |
| ------ | ------------------------------ | ---------------------------------- | ----------------------------------------------------------- |
| POST | /register | Public | Register a new dashboard user and issue tokens |
| POST | /login | Public | Authenticate with email/password (may return MFA challenge) |
| POST | /logout | Public (reads both credentials) | Revoke the session; blacklists the access token it is given |
| POST | /refresh | Public (refresh cookie or body) | Rotate refresh token, issue new access token |
| GET | /me | JwtAuthGuard | Current dashboard user payload |
| POST | /ws-ticket | JwtAuthGuard | Mint a single-use ticket for a WebSocket upgrade |
| POST | /verify-email | Public | Verify email with OTP |
| POST | /resend-verification | Public | Resend email-verification OTP |
| POST | /password/forgot-password | Public | Request password reset (token or OTP) |
| POST | /password/reset-password | Public | Submit new password with reset token |
| POST | /password/verify-otp | Public | Verify password-reset OTP |
| POST | /password/resend-otp | Public | Resend password-reset OTP |
| POST | /mfa/setup | JwtAuthGuard | Generate TOTP secret and recovery codes |
| POST | /mfa/verify-enable | JwtAuthGuard | Confirm setup and enable MFA |
| POST | /mfa/challenge | Public + @SkipMfa() | Submit TOTP/recovery code after login |
| POST | /mfa/disable | JwtAuthGuard | Disable MFA for the current user |
| POST | /mfa/recovery-codes | JwtAuthGuard | Replace the recovery codes, proving a fresh OTP |
| GET | /sessions | JwtAuthGuard, UserStatusGuard | List active sessions for the current user |
| POST | /sessions/revoke-all | JwtAuthGuard, UserStatusGuard | Revoke every session except the caller's |
| DELETE | /sessions/:id
