← Back to Blog
TypeScript2026-05-12Β·83 min read

Building an auth API that behaves like a real product

By Abdul Halim

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:

  1. 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.
  2. Serverless Configuration Blind Spots: Platform-as-a-Service (PaaS) environments like Vercel or AWS Lambda fail fast on missing runtime variables. A missing DATABASE_URL or misconfigured APP_URL triggers generic 500 errors that obscure the root cause.
  3. Email Link Host Drift: Verification and password reset links embed the application's base URL. When APP_URL remains pointed at localhost after 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:

  • httpOnly cookies prevent client-side JavaScript from accessing refresh tokens, mitigating XSS attacks.
  • Centralized COOKIE_POLICY eliminates string drift between set and clear operations.
  • Database-backed hash storage enables forced session revocation without waiting for token expiration.
  • Runtime APP_URL validation 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_POLICY constant
  • Store refresh token hashes (not raw tokens) in PostgreSQL via Drizzle
  • Validate DATABASE_URL, JWT_SECRET, and APP_URL at 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

  1. Initialize the project: Run nest new auth-backend and install dependencies: npm i @nestjs/jwt @nestjs/config drizzle-orm pg resend swagger-ui-express @nestjs/swagger.
  2. Configure environment variables: Create a .env file with DATABASE_URL, JWT_SECRET, APP_URL, and RESEND_API_KEY. Ensure APP_URL matches your local or production domain.
  3. Run database migrations: Execute drizzle-kit push to create the users table with the required schema. Verify the connection by querying the database directly.
  4. Start the application: Run npm run start:dev. Navigate to http://localhost:3000/api to access Swagger documentation. Test the /auth/register, /auth/login, and /auth/refresh endpoints using the provided UI.
  5. Validate cookie behavior: Open browser DevTools β†’ Application β†’ Cookies. Confirm that app_session_ref is set with httpOnly, secure (in production), and path=/ attributes. Verify that /auth/logout removes the cookie and clears the database hash.