A

Authentication Strategies

apiauthenticationoauthjwtsessionssecurity

Authentication Strategies (AuthN)

Authentication answers the question "who are you?" Before your API can decide what a user can do (authorization), it must verify who they claim to be. This article covers OAuth 2.0 flows, session-based vs token-based authentication, and the security trade-offs between them.

OAuth 2.0 Overview

OAuth 2.0 is an authorization framework that enables third-party applications to obtain limited access to a service. Despite the name, OAuth is commonly used for authentication via OpenID Connect (OIDC), which adds an identity layer on top.

Key OAuth 2.0 Roles

  • Resource Owner: The user who owns the data
  • Client: Your application requesting access
  • Authorization Server: Issues tokens (Google, Auth0, your own)
  • Resource Server: API that accepts tokens

OAuth 2.0 Flows

Different scenarios require different flows:

Flow Use Case Client Type
Authorization Code Server-side web apps Confidential
Authorization Code + PKCE SPAs, mobile apps Public
Client Credentials Machine-to-machine Confidential
Device Code TVs, CLIs, IoT Input-constrained

Authorization Code Flow

The standard flow for server-side applications. The client secret stays on the server.

┌──────────┐                              ┌───────────────┐
│  Browser │                              │  Your Server  │
└────┬─────┘                              └───────┬───────┘
     │                                            │
     │ 1. User clicks "Login with Google"         │
     │ ──────────────────────────────────────────>│
     │                                            │
     │ 2. Redirect to Google                      │
     │ <──────────────────────────────────────────│
     │                                            │
     │ 3. User logs in at Google                  │
     │ ──────────────────────────────────────────>│ Google Auth Server
     │                                            │
     │ 4. Google redirects back with code         │
     │ <──────────────────────────────────────────│
     │                                            │
     │ 5. Browser hits callback URL               │
     │ ──────────────────────────────────────────>│
     │                                            │
     │           6. Server exchanges code for tokens (server-to-server)
     │                                            │ ────────────────────>
     │                                            │ <────────────────────
     │                                            │
     │ 7. Set session cookie, redirect to app     │
     │ <──────────────────────────────────────────│

Implementation

// src/routes/auth.ts
import { Router } from 'express';
import { OAuth2Client } from 'google-auth-library';

const router = Router();

const oauth2Client = new OAuth2Client({
  clientId: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  redirectUri: `${process.env.APP_URL}/auth/google/callback`,
});

// Step 1: Initiate OAuth flow
router.get('/auth/google', (req, res) => {
  const state = crypto.randomUUID(); // CSRF protection

  // Store state in session for verification
  req.session.oauthState = state;

  const authUrl = oauth2Client.generateAuthUrl({
    access_type: 'offline', // Get refresh token
    scope: [
      'openid',
      'https://www.googleapis.com/auth/userinfo.email',
      'https://www.googleapis.com/auth/userinfo.profile',
    ],
    state,
    prompt: 'consent', // Force consent screen for refresh token
  });

  res.redirect(authUrl);
});

// Step 2: Handle callback
router.get('/auth/google/callback', async (req, res) => {
  const { code, state, error } = req.query;

  // Check for OAuth errors
  if (error) {
    console.error('OAuth error:', error);
    return res.redirect('/login?error=oauth_failed');
  }

  // Verify state to prevent CSRF
  if (state !== req.session.oauthState) {
    return res.status(403).json({ error: 'Invalid state parameter' });
  }

  delete req.session.oauthState;

  try {
    // Exchange code for tokens
    const { tokens } = await oauth2Client.getToken(code as string);
    oauth2Client.setCredentials(tokens);

    // Get user info
    const userInfoResponse = await fetch(
      'https://www.googleapis.com/oauth2/v3/userinfo',
      { headers: { Authorization: `Bearer ${tokens.access_token}` } }
    );

    const userInfo = await userInfoResponse.json();

    // Find or create user in your database
    let user = await db.user.findUnique({
      where: { email: userInfo.email },
    });

    if (!user) {
      user = await db.user.create({
        data: {
          email: userInfo.email,
          name: userInfo.name,
          picture: userInfo.picture,
          googleId: userInfo.sub,
        },
      });
    }

    // Store refresh token if provided (for offline access)
    if (tokens.refresh_token) {
      await db.user.update({
        where: { id: user.id },
        data: { googleRefreshToken: tokens.refresh_token },
      });
    }

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

    res.redirect('/dashboard');
  } catch (err) {
    console.error('Token exchange failed:', err);
    res.redirect('/login?error=auth_failed');
  }
});

// Logout
router.post('/auth/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      console.error('Session destruction failed:', err);
    }
    res.clearCookie('sessionId');
    res.json({ success: true });
  });
});

export default router;

Authorization Code Flow with PKCE

For SPAs and mobile apps, you can't securely store a client secret. PKCE (Proof Key for Code Exchange) adds security by using a dynamic secret.

┌──────────────┐                         ┌──────────────────┐
│   SPA/App    │                         │   Auth Server    │
└──────┬───────┘                         └────────┬─────────┘
       │                                          │
       │ 1. Generate code_verifier (random)       │
       │    code_challenge = SHA256(verifier)     │
       │                                          │
       │ 2. Redirect with code_challenge          │
       │ ────────────────────────────────────────>│
       │                                          │
       │ 3. User authenticates                    │
       │                                          │
       │ 4. Redirect back with code               │
       │ <────────────────────────────────────────│
       │                                          │
       │ 5. Exchange code + code_verifier         │
       │ ────────────────────────────────────────>│
       │                                          │
       │ 6. Verify: SHA256(verifier) == challenge │
       │    Return tokens                         │
       │ <────────────────────────────────────────│

Implementation

// src/lib/auth-pkce.ts
import { encode as base64UrlEncode } from 'base64url';

// Generate cryptographically random verifier
export const generateCodeVerifier = (): string => {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(Buffer.from(array));
};

// Create challenge from verifier
export const generateCodeChallenge = async (verifier: string): Promise<string> => {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(Buffer.from(hash));
};

// src/auth/login.ts (SPA client code)
export const initiateLogin = async () => {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  // Store verifier for later (sessionStorage for SPAs)
  sessionStorage.setItem('code_verifier', codeVerifier);

  const state = crypto.randomUUID();
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    client_id: import.meta.env.VITE_AUTH0_CLIENT_ID,
    redirect_uri: `${window.location.origin}/callback`,
    response_type: 'code',
    scope: 'openid profile email',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  window.location.href = `https://YOUR_DOMAIN.auth0.com/authorize?${params}`;
};

// src/auth/callback.ts
export const handleCallback = async () => {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');

  // Verify state
  if (state !== sessionStorage.getItem('oauth_state')) {
    throw new Error('Invalid state');
  }

  const codeVerifier = sessionStorage.getItem('code_verifier');

  if (!code || !codeVerifier) {
    throw new Error('Missing code or verifier');
  }

  // Exchange code for tokens
  const response = await fetch('https://YOUR_DOMAIN.auth0.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      client_id: import.meta.env.VITE_AUTH0_CLIENT_ID,
      code,
      code_verifier: codeVerifier,
      redirect_uri: `${window.location.origin}/callback`,
    }),
  });

  const tokens = await response.json();

  // Clean up
  sessionStorage.removeItem('code_verifier');
  sessionStorage.removeItem('oauth_state');

  return tokens;
};

Client Credentials Flow

For server-to-server communication where no user is involved. The client authenticates with its own credentials.

// src/lib/machine-auth.ts

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
}

class MachineToMachineAuth {
  private token: string | null = null;
  private tokenExpiry: number = 0;

  constructor(
    private clientId: string,
    private clientSecret: string,
    private tokenUrl: string,
    private audience: string
  ) {}

  async getToken(): Promise<string> {
    // Return cached token if still valid (with 60s buffer)
    if (this.token && Date.now() < this.tokenExpiry - 60000) {
      return this.token;
    }

    const response = await fetch(this.tokenUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'client_credentials',
        client_id: this.clientId,
        client_secret: this.clientSecret,
        audience: this.audience,
      }),
    });

    if (!response.ok) {
      throw new Error(`Token request failed: ${response.status}`);
    }

    const data: TokenResponse = await response.json();

    this.token = data.access_token;
    this.tokenExpiry = Date.now() + data.expires_in * 1000;

    return this.token;
  }
}

// Usage
const paymentServiceAuth = new MachineToMachineAuth(
  process.env.PAYMENT_CLIENT_ID!,
  process.env.PAYMENT_CLIENT_SECRET!,
  'https://auth.example.com/oauth/token',
  'https://api.payments.example.com'
);

// Call another service
const processPayment = async (amount: number) => {
  const token = await paymentServiceAuth.getToken();

  return fetch('https://api.payments.example.com/charge', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ amount }),
  });
};

Sessions vs JWTs

The two dominant approaches to maintaining authentication state.

Session-Based Authentication

Server stores session data. Client gets an opaque session ID in a cookie.

// src/middleware/session.ts
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

export const sessionMiddleware = session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET!,
  name: 'sessionId', // Cookie name
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
    httpOnly: true,  // Not accessible via JavaScript
    sameSite: 'lax', // CSRF protection
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  },
});

// Auth middleware
export const requireAuth = (req, res, next) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: { code: 'UNAUTHORIZED' } });
  }
  next();
};

// Usage
app.use(sessionMiddleware);

app.post('/login', async (req, res) => {
  const user = await verifyCredentials(req.body.email, req.body.password);

  if (!user) {
    return res.status(401).json({ error: { code: 'INVALID_CREDENTIALS' } });
  }

  // Create session
  req.session.userId = user.id;
  req.session.userEmail = user.email;

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

app.get('/profile', requireAuth, async (req, res) => {
  const user = await db.user.findUnique({
    where: { id: req.session.userId },
  });

  res.json({ data: user });
});

app.post('/logout', (req, res) => {
  req.session.destroy(() => {
    res.clearCookie('sessionId');
    res.json({ success: true });
  });
});

Session advantages:

  • Instant revocation: Delete from Redis, user is immediately logged out
  • Server control: Can store arbitrary data, change session contents anytime
  • Smaller cookies: Only session ID transmitted, not full payload
  • Built-in CSRF protection: Use sameSite: 'strict' or CSRF tokens

Session disadvantages:

  • Server state: Requires session store (Redis, database)
  • Scaling complexity: Session store must be shared across servers
  • Cross-domain limitations: Cookies don't work across different domains

JWT-Based Authentication

Server issues signed tokens. Client stores and sends token with each request.

// src/lib/jwt.ts
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET!;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';

interface TokenPayload {
  userId: string;
  email: string;
}

export const generateTokens = (user: { id: string; email: string }) => {
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    JWT_SECRET,
    { expiresIn: ACCESS_TOKEN_EXPIRY }
  );

  const refreshToken = jwt.sign(
    { userId: user.id, type: 'refresh' },
    JWT_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );

  return { accessToken, refreshToken };
};

export const verifyAccessToken = (token: string): TokenPayload => {
  return jwt.verify(token, JWT_SECRET) as TokenPayload;
};

export const verifyRefreshToken = (token: string): { userId: string } => {
  const payload = jwt.verify(token, JWT_SECRET) as { userId: string; type: string };

  if (payload.type !== 'refresh') {
    throw new Error('Invalid token type');
  }

  return { userId: payload.userId };
};

// src/routes/auth.ts
router.post('/login', async (req, res) => {
  const user = await verifyCredentials(req.body.email, req.body.password);

  if (!user) {
    return res.status(401).json({ error: { code: 'INVALID_CREDENTIALS' } });
  }

  const { accessToken, refreshToken } = generateTokens(user);

  // Store refresh token hash in database for revocation
  await db.refreshToken.create({
    data: {
      userId: user.id,
      tokenHash: hashToken(refreshToken),
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  res.json({
    data: {
      user: { id: user.id, email: user.email },
      accessToken,
      refreshToken,
    },
  });
});

router.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  try {
    const { userId } = verifyRefreshToken(refreshToken);

    // Check if refresh token is still valid in database
    const storedToken = await db.refreshToken.findFirst({
      where: {
        userId,
        tokenHash: hashToken(refreshToken),
        expiresAt: { gt: new Date() },
        revokedAt: null,
      },
    });

    if (!storedToken) {
      return res.status(401).json({ error: { code: 'INVALID_REFRESH_TOKEN' } });
    }

    const user = await db.user.findUnique({ where: { id: userId } });
    const tokens = generateTokens(user!);

    // Rotate refresh token (invalidate old one)
    await db.refreshToken.update({
      where: { id: storedToken.id },
      data: { revokedAt: new Date() },
    });

    // Create new refresh token
    await db.refreshToken.create({
      data: {
        userId,
        tokenHash: hashToken(tokens.refreshToken),
        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
      },
    });

    res.json({ data: tokens });
  } catch {
    res.status(401).json({ error: { code: 'INVALID_REFRESH_TOKEN' } });
  }
});

// Auth middleware
export const jwtAuth = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: { code: 'UNAUTHORIZED' } });
  }

  const token = authHeader.slice(7);

  try {
    req.user = verifyAccessToken(token);
    next();
  } catch {
    res.status(401).json({ error: { code: 'INVALID_TOKEN' } });
  }
};

JWT advantages:

  • Stateless: No server-side session store required
  • Cross-domain: Works easily across different domains/services
  • Scalable: Any server can verify tokens independently
  • Self-contained: Token carries user info (reduces database lookups)

JWT disadvantages:

  • No instant revocation: Token valid until expiry (use short expiry + refresh tokens)
  • Larger payload: Full token sent with every request
  • Complexity: Refresh token rotation, secure storage on client

Where to Store JWTs on the Client

Storage XSS Risk CSRF Risk Recommendation
localStorage High (accessible via JS) None Avoid for sensitive apps
sessionStorage High (accessible via JS) None Slightly better (cleared on tab close)
HttpOnly Cookie None Medium Best with CSRF protection
Memory (variable) None None Best for high security, but lost on refresh

Recommended approach: Store access token in memory, refresh token in HttpOnly cookie.

// Server - set refresh token as HttpOnly cookie
router.post('/login', async (req, res) => {
  const { accessToken, refreshToken } = generateTokens(user);

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

  res.json({ data: { accessToken } });
});

// Client - store access token in memory
let accessToken: string | null = null;

const login = async (email: string, password: string) => {
  const response = await fetch('/api/login', {
    method: 'POST',
    credentials: 'include', // Include cookies
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  const { data } = await response.json();
  accessToken = data.accessToken;
};

const fetchWithAuth = async (url: string, options: RequestInit = {}) => {
  let response = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  });

  // If 401, try to refresh
  if (response.status === 401) {
    const refreshed = await refreshAccessToken();

    if (refreshed) {
      response = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${accessToken}`,
        },
      });
    }
  }

  return response;
};

const refreshAccessToken = async (): Promise<boolean> => {
  try {
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include', // Send refresh token cookie
    });

    if (!response.ok) return false;

    const { data } = await response.json();
    accessToken = data.accessToken;
    return true;
  } catch {
    return false;
  }
};

Security Considerations

XSS (Cross-Site Scripting)

XSS allows attackers to execute JavaScript in your app's context. If tokens are accessible via JavaScript, they can be stolen.

Mitigations:

  • Use HttpOnly cookies when possible
  • Sanitize all user input before rendering
  • Use Content Security Policy (CSP) headers
  • Keep access tokens short-lived

CSRF (Cross-Site Request Forgery)

CSRF tricks users into making unwanted requests to your API using their existing session.

Mitigations:

  • Use SameSite=Strict or SameSite=Lax cookies
  • Implement CSRF tokens for state-changing operations
  • Verify Origin and Referer headers
// CSRF token implementation
import csrf from 'csurf';

const csrfProtection = csrf({ cookie: true });

// Get CSRF token
app.get('/csrf-token', csrfProtection, (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

// Protected route
app.post('/transfer', csrfProtection, (req, res) => {
  // CSRF token automatically validated
  // ... handle transfer
});

Token Theft

Even with best practices, tokens can be stolen through various attacks.

Mitigations:

  • Short access token expiry (15 minutes)
  • Refresh token rotation (new refresh token on each use)
  • Bind tokens to device/IP (optional, can break mobile)
  • Monitor for suspicious activity (multiple locations, unusual times)
// Refresh token rotation with family tracking
router.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  const { userId } = verifyRefreshToken(refreshToken);

  const storedToken = await db.refreshToken.findFirst({
    where: { tokenHash: hashToken(refreshToken) },
  });

  if (!storedToken) {
    return res.status(401).json({ error: { code: 'INVALID_TOKEN' } });
  }

  // If token was already used, revoke entire family (possible theft)
  if (storedToken.usedAt) {
    await db.refreshToken.updateMany({
      where: { familyId: storedToken.familyId },
      data: { revokedAt: new Date() },
    });

    return res.status(401).json({
      error: {
        code: 'TOKEN_REUSE_DETECTED',
        message: 'Session invalidated for security',
      },
    });
  }

  // Mark token as used
  await db.refreshToken.update({
    where: { id: storedToken.id },
    data: { usedAt: new Date() },
  });

  // Issue new tokens in same family
  const tokens = generateTokens({ id: userId });

  await db.refreshToken.create({
    data: {
      userId,
      tokenHash: hashToken(tokens.refreshToken),
      familyId: storedToken.familyId,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  res.json({ data: tokens });
});

Recommendation Summary

Scenario Recommendation
Traditional web app (same domain) Sessions with HttpOnly cookies
SPA (same domain) Sessions or JWT with HttpOnly refresh cookie
SPA (cross-domain API) JWT with short expiry + refresh tokens
Mobile app JWT with secure storage (Keychain/Keystore)
Microservices JWT or OAuth Client Credentials
Third-party integrations OAuth 2.0 with appropriate flow

See Also

Last updated: March 23, 2026