Building an auth API that behaves like a real product
Architecting Production-Grade Session Management in NestJS
Current Situation Analysis
The gap between academic authentication tutorials and production-ready session management is wider than most engineering teams anticipate. Tutorial implementations typically terminate at a successful POST /login response that returns a JSON Web Token (JWT). This creates a false sense of completeness. Real-world applications require a distributed state machine: short-lived access tokens, long-lived refresh tokens bound to secure cookies, database-backed session invalidation, email verification pipelines, password reset workflows, and strict environment configuration alignment.
This problem is systematically overlooked because developers treat authentication as a single endpoint rather than a lifecycle contract. The focus shifts to decorators, route guards, and token generation, while the operational realities of cookie synchronization, serverless deployment constraints, and email host resolution are deferred. The result is a system that works in local development but fractures under production conditions.
Data from production incident reports consistently highlights three failure vectors:
- Cookie Lifecycle Mismatch: Browsers require exact attribute matching (name, path, domain, secure, sameSite) to clear a cookie. A single string drift between set and clear operations leaves zombie sessions active.
- Serverless Configuration Blind Spots: Platform-as-a-Service (PaaS) environments like Vercel or AWS Lambda fail fast on missing runtime variables. A missing
DATABASE_URLor misconfiguredAPP_URLtriggers generic 500 errors that obscure the root cause. - Email Link Host Drift: Verification and password reset links embed the application's base URL. When
APP_URLremains pointed atlocalhostafter deployment, magic links break silently, causing user churn and support ticket volume.
These are not edge cases. They are the baseline requirements for any system that handles user identity. Treating auth as a "feature" instead of a "reliability contract" guarantees technical debt that compounds with every new endpoint.
WOW Moment: Key Findings
The following comparison isolates the structural differences between tutorial-grade implementations and production-hardened session management. The metrics reflect real-world operational behavior, not theoretical capabilities.
| Approach | Token Lifecycle Management | Cookie Synchronization | Configuration Drift Tolerance | Debugging Visibility |
|---|---|---|---|---|
| Academic Implementation | Static JWTs, no refresh rotation, localStorage storage | Manual string literals, mismatched clear operations | Fails silently on missing env vars, relies on runtime crashes | Network tab inspection required, no structured logging |
| Production Implementation | Short-lived access + rotated refresh tokens, httpOnly cookies | Centralized cookie policy, exact attribute matching | Runtime validation, graceful boot failures, env-aware URL builder | Structured session logs, cookie store verification, atomic DB transactions |
Why this matters: Production session management shifts the failure surface from the user interface to the infrastructure layer. By centralizing cookie policies, enforcing token rotation, and validating environment configuration at boot, you eliminate the "magical" bugs that consume engineering hours. The table demonstrates that academic approaches optimize for developer convenience, while production approaches optimize for system predictability. This enables reliable logout flows, secure cross-origin requests, and deterministic deployment behavior.
Core Solution
Building a resilient auth backend requires treating session state as a first-class architectural concern. The implementation below uses NestJS, Drizzle ORM, PostgreSQL, and Resend for email delivery. The design prioritizes explicit state transitions, centralized configuration, and strict cookie lifecycle management.
Step 1: Centralize the Cookie Policy
String literals for cookie names and attributes are a primary source of production bugs. TypeScript cannot catch typos in runtime strings. The solution is a single source of truth that enforces consistency across set, read, and clear operations.
// src/auth/cookie.policy.ts
export const SESSION_COOKIE_NAME = 'app_session_ref';
export const COOKIE_POLICY = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds
} as const;
Step 2: Database Schema & Drizzle Integration
Refresh tokens must be persisted server-side to enable forced revocation. The schema stores a cryptographic hash of the refresh token, not the raw value. This prevents database breaches from yielding usable sessions.
// src/database/schema/users.ts
import { pgTable, varchar, timestamp, boolean } from 'drizzle-orm/pg-core';
export const usersTable = pgTable('users', {
id: varchar('id', { length: 26 }).primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
refreshTokenHash: varchar('refresh_token_hash', { length: 255 }).nullable(),
emailVerified: boolean('email_verified').default(false).notNull(),
role: varchar('role', { length: 20 }).default('user').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
Step 3: Token Issuance & Cookie Binding
Access tokens should be short-lived (15 minutes). Refresh tokens are long-lived but bound to the httpOnly cookie and the database hash. The service handles issuance, cookie attachment, and hash storage atomically.
// src/auth/session.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Response } from 'express';
import { createHash } from 'crypto';
import { COOKIE_POLICY, SESSION_COOKIE_NAME } from './cookie.policy';
import { UserRepo } from '../database/repositories/user.repo';
@Injectable()
export class SessionService {
constructor(
private readonly jwt: JwtService,
private readonly userRepo: UserRepo,
) {}
async issueSession(userId: string, role: string, res: Response): Promise<void> {
const accessToken = this.jwt.sign(
{ sub: userId, role },
{ expiresIn: '15m' }
);
const refreshToken = this.jwt.sign(
{ sub: userId, type: 'refresh' },
{ expiresIn: '7d' }
);
const hash = createHash('sha256').update(refreshToken).digest('hex');
await this.userRepo.updateRefreshHash(userId, hash);
res.cookie(SESSION_COOKIE_NAME, refreshToken, COOKIE_POLICY);
res.setHeader('Authorization', `Bearer ${accessToken}`);
}
}
Step 4: Synchronized Logout
Logout requires a two-phase operation: database invalidation followed by exact cookie clearing. The cookie policy must match the set operation exactly, including the path attribute.
// src/auth/session.service.ts (continued)
async terminateSession(userId: string, res: Response): Promise<void> {
await this.userRepo.clearRefreshHash(userId);
res.clearCookie(SESSION_COOKIE_NAME, {
...COOKIE_POLICY,
maxAge: 0,
});
}
Step 5: Email Flow Integration
Verification and password reset flows depend on environment-aware URL construction. Hardcoding domains breaks deployments. The email service resolves the base URL from configuration and validates it before dispatch.
// src/mail/mail.service.ts
import { Injectable } from '@nestjs/common';
import { Resend } from 'resend';
@Injectable()
export class MailService {
private readonly client: Resend;
private readonly baseUrl: string;
constructor() {
this.client = new Resend(process.env.RESEND_API_KEY);
this.baseUrl = process.env.APP_URL || 'http://localhost:3000';
if (!this.baseUrl.startsWith('http')) {
throw new Error('APP_URL must be a valid HTTP(S) endpoint');
}
}
async sendVerificationLink(email: string, token: string): Promise<void> {
const verifyUrl = `${this.baseUrl}/auth/verify?token=${token}`;
await this.client.emails.send({
from: 'noreply@yourdomain.com',
to: email,
subject: 'Verify your account',
html: `<p>Click <a href="${verifyUrl}">here</a> to verify.</p>`,
});
}
}
Architecture Rationale:
httpOnlycookies prevent client-side JavaScript from accessing refresh tokens, mitigating XSS attacks.- Centralized
COOKIE_POLICYeliminates string drift between set and clear operations. - Database-backed hash storage enables forced session revocation without waiting for token expiration.
- Runtime
APP_URLvalidation prevents silent email link failures in production. - Drizzle ORM provides type-safe queries without the runtime overhead of heavier alternatives.
Pitfall Guide
1. Cookie Name/Attribute Drift
Explanation: Setting a cookie with res.cookie('token', ...) and clearing it with res.clearCookie('refresh_token', ...) leaves the original cookie intact. Browsers match cookies by exact name and path.
Fix: Define cookie names and attributes in a single constant object. Import and reuse this object across all set, read, and clear operations.
2. Serverless Environment Blind Spots
Explanation: PaaS platforms inject environment variables at runtime. Missing DATABASE_URL or JWT_SECRET causes the application to crash during module initialization, resulting in generic 500 errors with no stack trace in the browser.
Fix: Implement runtime configuration validation at application bootstrap. Fail fast with explicit error messages if critical variables are absent. Log configuration state (without secrets) during startup.
3. JWT Payload Bloat
Explanation: Storing user roles, permissions, or profile data directly in the JWT increases token size and creates stale data when user attributes change. Tokens are only updated on re-authentication.
Fix: Keep JWT payloads minimal (sub, role, iat, exp). Fetch dynamic user data from the database or cache on each request. Use refresh token rotation to update claims without forcing re-login.
4. Zombie Sessions on Logout
Explanation: Clearing the database hash but failing to clear the cookie (or vice versa) leaves the session in an inconsistent state. The client retains a valid refresh token that the server rejects, causing silent authentication failures.
Fix: Wrap database invalidation and cookie clearing in a synchronized operation. Verify cookie deletion by inspecting the Set-Cookie header in network traffic. Implement e2e tests that assert cookie absence post-logout.
5. Email Link Host Mismatch
Explanation: Verification and reset links embed the application's base URL. When APP_URL points to localhost in production, users receive broken links. This is a leading cause of support ticket volume for new deployments.
Fix: Use an environment-aware URL builder. Validate that APP_URL starts with http:// or https:// during startup. Log the resolved base URL on boot for auditability.
6. Missing Refresh Token Rotation
Explanation: Reusing the same refresh token indefinitely increases the window of opportunity for token theft. If a refresh token is compromised, the attacker retains access until manual revocation. Fix: Issue a new refresh token on every successful refresh operation. Revoke the old token immediately. Store only the latest hash in the database. This limits the blast radius of token leakage.
7. Over-Reliance on TypeScript for String Literals
Explanation: TypeScript's type system does not validate runtime string values. A typo in a cookie name, header key, or environment variable will compile successfully but fail silently at runtime.
Fix: Use as const objects for configuration. Implement runtime validation functions that assert expected string formats. Add unit tests that verify cookie policy consistency across service boundaries.
Production Bundle
Action Checklist
- Centralize cookie names and attributes in a single
COOKIE_POLICYconstant - Store refresh token hashes (not raw tokens) in PostgreSQL via Drizzle
- Validate
DATABASE_URL,JWT_SECRET, andAPP_URLat application bootstrap - Implement exact cookie clearing with matching path, secure, and sameSite flags
- Add refresh token rotation to limit the window of token compromise
- Configure Resend with environment-aware URL resolution for verification/reset links
- Write e2e tests covering register β verify β login β refresh β logout flows
- Enable Swagger documentation for contract sharing and endpoint testing
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Token Storage | httpOnly cookies | Prevents XSS, aligns with OWASP recommendations, requires no client-side state management | Low infrastructure cost, moderate implementation complexity |
| Refresh Strategy | Token rotation | Limits compromise window, forces session invalidation on theft | Slight database write overhead per refresh, negligible at scale |
| Email Provider | Resend | Developer-friendly API, reliable delivery, built-in tracking, predictable pricing | ~$20/mo for moderate volume, scales linearly |
| Database ORM | Drizzle | Type-safe, lightweight, zero runtime overhead, excellent PostgreSQL support | No additional cost, reduces query bugs and migration friction |
| Deployment Target | Serverless (Vercel/AWS) | Auto-scaling, pay-per-use, simplified infrastructure management | Cold start latency for auth endpoints, mitigated with provisioned concurrency |
Configuration Template
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { SessionService } from './session.service';
import { UserRepo } from '../database/repositories/user.repo';
import { MailService } from '../mail/mail.service';
@Module({
imports: [
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: { algorithm: 'HS256' },
}),
}),
],
controllers: [AuthController],
providers: [SessionService, UserRepo, MailService],
exports: [SessionService],
})
export class AuthModule {}
// src/database/repositories/user.repo.ts
import { Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { db } from '../db.connection';
import { usersTable } from '../schema/users';
@Injectable()
export class UserRepo {
async updateRefreshHash(userId: string, hash: string): Promise<void> {
await db
.update(usersTable)
.set({ refreshTokenHash: hash })
.where(eq(usersTable.id, userId));
}
async clearRefreshHash(userId: string): Promise<void> {
await db
.update(usersTable)
.set({ refreshTokenHash: null })
.where(eq(usersTable.id, userId));
}
}
Quick Start Guide
- Initialize the project: Run
nest new auth-backendand install dependencies:npm i @nestjs/jwt @nestjs/config drizzle-orm pg resend swagger-ui-express @nestjs/swagger. - Configure environment variables: Create a
.envfile withDATABASE_URL,JWT_SECRET,APP_URL, andRESEND_API_KEY. EnsureAPP_URLmatches your local or production domain. - Run database migrations: Execute
drizzle-kit pushto create theuserstable with the required schema. Verify the connection by querying the database directly. - Start the application: Run
npm run start:dev. Navigate tohttp://localhost:3000/apito access Swagger documentation. Test the/auth/register,/auth/login, and/auth/refreshendpoints using the provided UI. - Validate cookie behavior: Open browser DevTools β Application β Cookies. Confirm that
app_session_refis set withhttpOnly,secure(in production), andpath=/attributes. Verify that/auth/logoutremoves the cookie and clears the database hash.
