Authentication Patterns in TypeScript: From JWT to OIDC and Beyond

Fellow TypeScript enthusiasts, let's face it – authentication is often the unglamorous yet critical foundation we must get right in our applications. As TypeScript developers, we have a secret weapon: strong typing that can help us avoid the security pitfalls that plague many authentication implementations.

In this deep dive for TypeScript.Guru, we'll explore modern authentication patterns with strongly-typed implementations that leverage the full power of the language. Whether you're building a small side project or enterprise-grade systems, you'll find actionable patterns that combine security best practices with TypeScript's type safety.

Table of Contents

  1. JWT (JSON Web Tokens)
  2. OIDC (OpenID Connect)
  3. Magic Links
  4. OAuth 2.0
  5. Session-Based Authentication
  6. Passwordless Authentication
  7. Choosing the Right Pattern
  8. TypeScript Authentication Best Practices

JWT (JSON Web Tokens)

JSON Web Tokens (JWT) have become the darling of modern authentication, especially for building stateless APIs and SPAs. The beauty of JWTs in a TypeScript environment lies in how naturally they map to interfaces, making the entire authentication process more robust.

What is JWT?

A JWT is a compact, self-contained means for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.

JWT Structure

A JWT consists of three parts separated by dots (.):

  • Header: Typically consists of the token type and the signing algorithm
  • Payload: Contains the claims (statements about an entity and additional data)
  • Signature: Used to verify that the sender of the JWT is who it says it is

Implementing JWT Authentication in TypeScript

Here's how we can implement JWT authentication in a Node.js/Express application with proper TypeScript typing:

// auth.ts
import jwt from 'jsonwebtoken';
import express from 'express';

// Define types for better type safety
interface UserPayload {
  id: string;
  email: string;
  role: 'admin' | 'user' | 'guest'; // Using literal types for better type safety
  iat?: number; // JWT automatically adds issued at timestamp
  exp?: number; // JWT expiration timestamp
}

// Extend Express Request to avoid type assertions later
declare global {
  namespace Express {
    interface Request {
      user?: UserPayload;
    }
  }
}

export class AuthService {
  private readonly SECRET_KEY: string;
  private readonly TOKEN_EXPIRY: string;

  constructor() {
    // Environmental configuration with fallbacks
    this.SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key';
    this.TOKEN_EXPIRY = process.env.JWT_EXPIRY || '1h'; // Token expires in 1 hour
  }

  // Generate a strongly-typed JWT token
  generateToken(user: Omit<UserPayload, 'iat' | 'exp'>): string {
    return jwt.sign(
      { 
        id: user.id,
        email: user.email,
        role: user.role
      },
      this.SECRET_KEY,
      { expiresIn: this.TOKEN_EXPIRY }
    );
  }

  // Verify a JWT token with proper typing
  verifyToken(token: string): UserPayload | null {
    try {
      const payload = jwt.verify(token, this.SECRET_KEY) as UserPayload;
      
      // Additional runtime type checking if needed
      if (!payload.id || !payload.email || !payload.role) {
        return null;
      }
      
      return payload;
    } catch (error) {
      // More specific error handling
      if (error instanceof jwt.JsonWebTokenError) {
        console.error('JWT Error:', error.message);
      } else if (error instanceof jwt.TokenExpiredError) {
        console.error('JWT Expired:', error.message);
      }
      return null;
    }
  }
}

// Type-safe middleware to protect routes
export const authenticateJWT = (
  req: express.Request, 
  res: express.Response, 
  next: express.NextFunction
) => {
  const authHeader = req.headers.authorization;
  
  if (!authHeader) {
    return res.status(401).json({ message: 'Authorization header missing' });
  }

  const token = authHeader.split(' ')[1]; // Bearer <token>
  
  const authService = new AuthService();
  const user = authService.verifyToken(token);
  
  if (!user) {
    return res.status(403).json({ message: 'Invalid or expired token' });
  }
  
  // No more type assertion needed thanks to our Request interface extension
  req.user = user;
  next();
};

TypeScript Tip: By extending the Express.Request interface, we avoid type assertions (req as any) and get full IntelliSense support when accessing req.user in route handlers!

Using JWT Authentication in Your TypeScript Application

Let's integrate our type-safe JWT authentication into an Express application:

// app.ts
import express from 'express';
import { authenticateJWT, AuthService } from './auth';

// Define request body interface for better type checking
interface LoginRequest {
  email: string;
  password: string;
}

// Error response type
interface ErrorResponse {
  message: string;
  code?: string;
}

// Success response type
interface LoginResponse {
  token: string;
  expiresIn: number;
  user: {
    id: string;
    email: string;
    role: string;
  };
}

const app = express();
app.use(express.json());

const authService = new AuthService();

// Login route with proper request/response typing
app.post<{}, LoginResponse | ErrorResponse, LoginRequest>('/login', (req, res) => {
  const { email, password } = req.body;
  
  // Verify credentials (simplified for this example)
  if (email === '[email protected]' && password === 'password') {
    const user = {
      id: '123',
      email: '[email protected]',
      role: 'user' as const // Using const assertion for literal type
    };
    
    const token = authService.generateToken(user);
    
    // Type-safe response
    return res.json({ 
      token,
      expiresIn: 3600, // 1 hour in seconds
      user: {
        id: user.id,
        email: user.email,
        role: user.role
      }
    });
  }
  
  return res.status(401).json({ message: 'Invalid credentials' });
});

// Protected route with no type assertion needed
app.get('/protected', authenticateJWT, (req, res) => {
  // TypeScript knows req.user exists and has the right shape
  res.json({ 
    message: 'You have access to this protected resource', 
    user: req.user 
  });
});

app.listen(3000, () => console.log('Server running on port 3000'));

TypeScript's generic type parameters with Express route handlers enforce strict typing for request bodies and responses. This catches type mismatches at compile time rather than runtime, significantly improving your application's reliability.

Advantages of JWT in TypeScript Applications

  1. Stateless Architecture: No need to store sessions on the server, perfect for scalable microservices.
  2. Type Safety: Interfaces map cleanly to JWT payloads, reducing runtime errors.
  3. Scalability: Works exceptionally well in distributed architectures.
  4. Cross-domain: Easily share authentication across domains in your frontend/backend stack.
  5. Performance: Reduces database lookups for authentication checks in high-performance APIs.

Disadvantages of JWT

  1. Token Size: JWTs can be larger than session IDs, increasing your API payload size.
  2. Revocation Challenges: Cannot be invalidated before expiry unless using a token blacklist or database.
  3. Secret Management: Requires careful management of signing keys across your services.
  4. Type Safety Limitations: Compile-time checking doesn't validate runtime JWT data structure.

Security Warning: Remember that even with perfect interfaces, you should always validate JWT payloads at runtime! Type assertions (as UserPayload) provide compile-time confidence but no runtime guarantees when dealing with external tokens.

OIDC (OpenID Connect)

OpenID Connect (OIDC) is where the enterprise meets the modern web. When implemented with TypeScript, this complex authentication protocol becomes significantly more manageable through strong typing of tokens, claims, and providers.

What is OIDC?

OIDC extends OAuth 2.0 by adding an identity layer, allowing clients to verify the identity of end-users and obtain basic profile information. It introduces the concept of an ID token, which is a JWT containing claims about the authentication event and user. Strong typing with interfaces makes the otherwise complex protocol much more maintainable.

Key Components of OIDC

  • Identity Provider (IdP): Authenticates users and issues ID tokens
  • Relying Party (RP): The application that relies on the IdP for authentication
  • ID Token: JWT containing user identity information
  • UserInfo Endpoint: API for fetching additional user attributes

Implementing OIDC in TypeScript

Let's implement a robust OIDC client using TypeScript with the popular openid-client library. We'll see how TypeScript's type system helps manage the complexity of OIDC:

// oidc-client.ts
import { Issuer, Client, TokenSet, UserinfoResponse } from 'openid-client';
import express from 'express';
import session from 'express-session';

// Define strongly-typed interfaces for our OIDC interactions
interface OIDCConfig {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
  discoveryUrl: string;
  scope: string;
}

// Define the expected user info shape from your provider
interface OIDCUserInfo {
  sub: string;          // Subject - Unique identifier
  email?: string;       // Email may be present if requested in scope
  email_verified?: boolean;
  name?: string;        // Full name may be present if profile scope requested
  given_name?: string;  // First name
  family_name?: string; // Last name
  picture?: string;     // Profile picture URL
  locale?: string;      // User's locale
  [key: string]: unknown; // Allow for provider-specific claims
}

// Define session with TypeScript to avoid type assertions
declare module 'express-session' {
  interface SessionData {
    state?: string;
    tokens?: TokenSet;
    userInfo?: OIDCUserInfo;
  }
}

export class OIDCService {
  private client: Client | null = null;
  private readonly config: OIDCConfig;
  
  constructor(config: Partial<OIDCConfig> = {}) {
    // Merge provided config with defaults or environment variables
    this.config = {
      clientId: config.clientId || process.env.OIDC_CLIENT_ID || '',
      clientSecret: config.clientSecret || process.env.OIDC_CLIENT_SECRET || '',
      redirectUri: config.redirectUri || process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback',
      discoveryUrl: config.discoveryUrl || process.env.OIDC_DISCOVERY_URL || '',
      scope: config.scope || 'openid email profile'
    };
    
    // Validate required configuration
    if (!this.config.clientId || !this.config.clientSecret || !this.config.discoveryUrl) {
      throw new Error('Missing required OIDC configuration');
    }
  }
  
  async initialize(): Promise<void> {
    try {
      // Discover the OIDC provider (e.g., Auth0, Okta, etc.)
      const issuer = await Issuer.discover(this.config.discoveryUrl);
      
      // Create an OIDC client with typed configuration
      this.client = new issuer.Client({
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
        redirect_uris: [this.config.redirectUri],
        response_types: ['code'],
      });
    } catch (error) {
      console.error('OIDC initialization failed:', error instanceof Error ? error.message : String(error));
      throw error;
    }
  }

  getAuthorizationUrl(state: string): string {
    if (!this.client) {
      throw new Error('OIDC client not initialized');
    }
    
    return this.client.authorizationUrl({
      scope: this.config.scope,
      state,
    });
  }

  async handleCallback(code: string, state: string, savedState: string): Promise<TokenSet> {
    if (!this.client) {
      throw new Error('OIDC client not initialized');
    }
    
    if (state !== savedState) {
      throw new Error('State mismatch');
    }
    
    return await this.client.callback(this.config.redirectUri, { code, state });
  }

  async getUserInfo(accessToken: string): Promise<OIDCUserInfo> {
    if (!this.client) {
      throw new Error('OIDC client not initialized');
    }
    
    // Type the response and validate required fields
    const userInfo = await this.client.userinfo(accessToken) as UserinfoResponse;
    
    // Validate minimum required fields
    if (!userInfo.sub) {
      throw new Error('Invalid user info response');
    }
    
    return userInfo as OIDCUserInfo;
  }
}

// Express middleware for OIDC authentication with full TypeScript support
export const setupOIDC = async (app: express.Application): Promise<OIDCService> => {
  // Create and initialize the OIDC service
  const oidcService = new OIDCService({
    // Configuration can be passed directly or pulled from environment variables
    clientId: process.env.OIDC_CLIENT_ID,
    clientSecret: process.env.OIDC_CLIENT_SECRET,
    discoveryUrl: process.env.OIDC_DISCOVERY_URL || 'https://your-identity-provider/.well-known/openid-configuration',
  });
  
  await oidcService.initialize();
  
  // Setup session middleware with proper typing
  app.use(session({
    secret: process.env.SESSION_SECRET || 'your-session-secret',
    resave: false,
    saveUninitialized: true,
    cookie: {
      secure: process.env.NODE_ENV === 'production',
      maxAge: 24 * 60 * 60 * 1000, // 24 hours
      httpOnly: true
    }
  }));
  
  // Login route - initiates OIDC authentication flow
  app.get('/login', (req, res) => {
    // Generate a cryptographically secure state
    const state = crypto.randomBytes(16).toString('hex');
    req.session.state = state;
    
    const authUrl = oidcService.getAuthorizationUrl(state);
    res.redirect(authUrl);
  });
  
  // Callback route - handles the OAuth/OIDC response
  app.get('/callback', async (req, res) => {
    try {
      // Type-safe query parameter extraction
      const { code, state } = req.query as { code?: string, state?: string };
      
      // Validate required parameters
      if (!code || !state) {
        return res.status(400).json({ error: 'Missing required parameters' });
      }
      
      const savedState = req.session.state;
      
      if (!savedState) {
        return res.status(400).json({ error: 'No authentication flow in progress' });
      }
      
      // Handle the callback with proper error handling
      const tokens = await oidcService.handleCallback(code, state, savedState);
      req.session.tokens = tokens;
      
      // Ensure access token is available
      if (!tokens.access_token) {
        return res.status(500).json({ error: 'No access token received' });
      }
      
      // Fetch user info with type-safe response
      const userInfo = await oidcService.getUserInfo(tokens.access_token);
      req.session.userInfo = userInfo;
      
      res.redirect('/profile');
    } catch (error) {
      console.error('OIDC callback error:', error instanceof Error ? error.message : String(error));
      
      // Provide a user-friendly error message
      res.status(500).render('error', { 
        message: 'Authentication failed',
        details: process.env.NODE_ENV === 'development' ? error : undefined
      });
    }
  });
  
  // Profile route - displays authenticated user info
  app.get('/profile', (req, res) => {
    // Type-safe session check
    if (!req.session.userInfo) {
      return res.redirect('/login');
    }
    
    res.json(req.session.userInfo);
  });
  
  // Logout route - properly handles OIDC logout if supported
  app.get('/logout', async (req, res) => {
    try {
      // Check if end_session_endpoint is available
      if (oidcService.client?.issuer.metadata.end_session_endpoint && req.session.tokens?.id_token) {
        // Get logout URL
        const logoutUrl = oidcService.client.endSessionUrl({
          id_token_hint: req.session.tokens.id_token,
          post_logout_redirect_uri: `${req.protocol}://${req.headers.host}/`
        });
        
        // Clear the session
        req.session.destroy((err) => {
          if (err) {
            console.error('Session destruction error:', err);
          }
          // Redirect to the OIDC provider's logout endpoint
          res.redirect(logoutUrl);
        });
      } else {
        // Simple session destruction if OIDC logout not available
        req.session.destroy((err) => {
          if (err) {
            console.error('Session destruction error:', err);
          }
          res.redirect('/');
        });
      }
    } catch (error) {
      console.error('Logout error:', error);
      req.session.destroy(() => res.redirect('/'));
    }
  });
  
  return oidcService;
};

// TypeScript type guard for checking token access
function hasAccessToken(tokens: TokenSet): tokens is TokenSet & { access_token: string } {
  return tokens.access_token !== undefined;
}

Usage in Your Application

// app.ts
import express from 'express';
import { setupOIDC } from './oidc-client';

async function bootstrap() {
  const app = express();
  
  // Setup OIDC authentication
  await setupOIDC(app);
  
  app.get('/', (req, res) => {
    res.send(`
      <h1>OIDC Authentication Example</h1>
      <a href="/login">Login with OIDC</a>
    `);
  });
  
  app.listen(3000, () => console.log('Server running on port 3000'));
}

bootstrap().catch(console.error);

Advantages of OIDC with TypeScript

  1. Type Safety for Complex Protocols: Interfaces can model the complex OIDC structures and responses.
  2. Enterprise Integration: Perfect for enterprise applications requiring SSO.
  3. Standardized Claims: OIDC claims map cleanly to interfaces, providing consistency.
  4. Separation of Concerns: Offloads authentication to specialized providers, letting you focus on your business logic.
  5. Strong Security Model: Built on proven standards with strong typing helping enforce proper implementation.

Disadvantages of OIDC

  1. Implementation Complexity: Even with TypeScript, OIDC remains complex to implement correctly.
  2. Library Dependencies: Most OIDC libraries have numerous dependencies that can create package conflicts.
  3. Type Definition Challenges: Some OIDC providers have non-standard responses that don't match library type definitions.
  4. Learning Curve: Steeper learning curve than simpler auth methods.

Implementation Insight: OIDC shines in enterprise applications where integration with existing identity systems is required. While JWT might be simpler for smaller projects, OIDC offers a more complete solution for complex authentication needs, with strong typing helping manage the complexity.

Magic links provide an elegant passwordless authentication experience that combines excellent security with minimal user friction. When implemented with TypeScript, they become especially robust due to proper typing of tokens and verification processes.

Magic links are unique URLs containing cryptographically secure tokens sent to users via email. When a user clicks the link, your backend verifies the token and logs the user in without requiring a password. The beauty of this approach is that it can be implemented with robust type safety, avoiding many common security pitfalls.

Let's build a properly typed, robust magic link authentication system that takes full advantage of TypeScript's capabilities:

// magic-link.ts
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { Pool, QueryResult } from 'pg';
import express from 'express';

// Define our token structure with proper typing
interface MagicLinkToken {
  userId: string;
  email: string;   // Store email to prevent token sharing
  token: string;
  expires: Date;
  used: boolean;   // Track if the token has been used
}

// Error types for better error handling
enum MagicLinkErrorType {
  USER_NOT_FOUND = 'USER_NOT_FOUND',
  EMAIL_FAILURE = 'EMAIL_FAILURE',
  TOKEN_EXPIRED = 'TOKEN_EXPIRED',
  TOKEN_USED = 'TOKEN_USED',
  TOKEN_INVALID = 'TOKEN_INVALID',
  INTERNAL_ERROR = 'INTERNAL_ERROR'
}

class MagicLinkError extends Error {
  constructor(public type: MagicLinkErrorType, message: string) {
    super(message);
    this.name = 'MagicLinkError';
  }
}

// Define database schemas as types for clarity
interface UserRecord {
  id: string;
  email: string;
  name?: string;
  created_at: Date;
}

interface TokenRecord {
  id: string;
  user_id: string;
  token: string;
  expires_at: Date;
  used: boolean;
  created_at: Date;
}

export class MagicLinkService {
  private readonly TOKEN_EXPIRY = 15 * 60 * 1000; // 15 minutes
  private readonly mailer: nodemailer.Transporter;
  private readonly db: Pool;
  
  constructor() {
    // Setup email transporter with proper type checking
    this.mailer = nodemailer.createTransport({
      host: process.env.SMTP_HOST,
      port: parseInt(process.env.SMTP_PORT || '587'),
      secure: process.env.SMTP_SECURE === 'true',
      auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASS,
      },
    });
    
    // Setup database connection
    this.db = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
    
    // Create tables if they don't exist - a real app would use migrations
    this.initializeDatabase().catch(err => {
      console.error('Failed to initialize database:', err);
      throw err;
    });
  }
  
  private async initializeDatabase(): Promise<void> {
    // Create users table
    await this.db.query(`
      CREATE TABLE IF NOT EXISTS users (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        email TEXT UNIQUE NOT NULL,
        name TEXT,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
      )
    `);
    
    // Create magic_link_tokens table
    await this.db.query(`
      CREATE TABLE IF NOT EXISTS magic_link_tokens (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        token TEXT UNIQUE NOT NULL,
        expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
        used BOOLEAN NOT NULL DEFAULT FALSE,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
      )
    `);
    
    // Create index for faster token lookups
    await this.db.query(`
      CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_token ON magic_link_tokens(token)
    `);
    
    // Setup cleanup function - delete tokens older than 24 hours
    await this.db.query(`
      CREATE OR REPLACE FUNCTION cleanup_expired_tokens()
      RETURNS void AS $
      BEGIN
        DELETE FROM magic_link_tokens 
        WHERE expires_at < NOW() OR created_at < NOW() - INTERVAL '24 hours';
      END;
      $ LANGUAGE plpgsql;
    `);
    
    // Schedule regular cleanup
    await this.db.query(`
      DO $
      BEGIN
        IF NOT EXISTS (
          SELECT 1 FROM pg_stat_activity 
          WHERE query LIKE '%cleanup_expired_tokens%' AND state = 'active'
        ) THEN
          PERFORM pg_notify('cleanup_channel', '');
        END IF;
      END $;
    `);
  }
  
  async sendMagicLink(email: string, baseUrl: string = 'http://localhost:3000'): Promise<boolean> {
    try {
      // Find or create user by email - upsert pattern
      const userResult = await this.db.query<UserRecord>(
        `INSERT INTO users (email) 
         VALUES ($1) 
         ON CONFLICT (email) DO UPDATE SET email = $1
         RETURNING id, email`,
        [email]
      );
      
      if (userResult.rows.length === 0) {
        throw new MagicLinkError(
          MagicLinkErrorType.INTERNAL_ERROR, 
          'Failed to create or find user'
        );
      }
      
      const userId = userResult.rows[0].id;
      
      // Generate a cryptographically secure token
      const token = crypto.randomBytes(32).toString('hex');
      const expiresAt = new Date(Date.now() + this.TOKEN_EXPIRY);
      
      // Store token in database with proper typing
      await this.db.query<TokenRecord>(
        `INSERT INTO magic_link_tokens (user_id, token, expires_at)
         VALUES ($1, $2, $3)`,
        [userId, token, expiresAt]
      );
      
      // Create magic link URL with properly encoded token
      const magicLink = new URL(`/login/verify`, baseUrl);
      magicLink.searchParams.append('token', token);
      
      // Send email with typed nodemailer interface
      await this.mailer.sendMail({
        from: `"${process.env.APP_NAME || 'Your App'}" <${process.env.EMAIL_FROM || '[email protected]'}>`,
        to: email,
        subject: 'Your Secure Login Link',
        text: `Click this link to log in: ${magicLink.toString()}\n\nThis link will expire in 15 minutes and can only be used once.`,
        html: `
          <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
            <h2>Secure Login</h2>
            <p>Click the button below to securely log in to your account.</p>
            <a href="${magicLink.toString()}" 
               style="display: inline-block; background-color: #4CAF50; color: white; padding: 12px 24px; 
                      text-decoration: none; border-radius: 4px; font-weight: bold;">
              Log In Securely
            </a>
            <p style="color: #666; margin-top: 20px; font-size: 0.8em;">
              This link will expire in 15 minutes and can only be used once.
            </p>
          </div>
        `,
      });
      
      return true;
    } catch (error) {
      // Type-safe error handling
      if (error instanceof MagicLinkError) {
        console.error(`Magic link error (${error.type}):`, error.message);
      } else {
        console.error('Error sending magic link:', error instanceof Error ? error.message : String(error));
      }
      return false;
    }
  }
  
  async verifyToken(token: string): Promise<string | null> {
    try {
      // Begin transaction
      const client = await this.db.connect();
      try {
        // Start transaction
        await client.query('BEGIN');
        
        // Find and update the token atomically
        const tokenResult = await client.query<TokenRecord>(
          `UPDATE magic_link_tokens
           SET used = TRUE
           WHERE token = $1
             AND expires_at > NOW()
             AND used = FALSE
           RETURNING user_id`,
          [token]
        );
        
        if (tokenResult.rows.length === 0) {
          // Check if token exists but was expired or used
          const checkResult = await client.query<TokenRecord>(
            `SELECT expires_at, used FROM magic_link_tokens WHERE token = $1`,
            [token]
          );
          
          if (checkResult.rows.length > 0) {
            const record = checkResult.rows[0];
            
            if (record.used) {
              throw new MagicLinkError(MagicLinkErrorType.TOKEN_USED, 'Token has already been used');
            }
            
            if (record.expires_at < new Date()) {
              throw new MagicLinkError(MagicLinkErrorType.TOKEN_EXPIRED, 'Token has expired');
            }
          } else {
            throw new MagicLinkError(MagicLinkErrorType.TOKEN_INVALID, 'Invalid token');
          }
          
          return null;
        }
        
        const userId = tokenResult.rows[0].user_id;
        
        // Commit transaction
        await client.query('COMMIT');
        
        return userId;
      } catch (error) {
        // Rollback transaction on error
        await client.query('ROLLBACK');
        throw error;
      } finally {
        // Release client back to pool
        client.release();
      }
    } catch (error) {
      if (error instanceof MagicLinkError) {
        console.error(`Token verification error (${error.type}):`, error.message);
      } else {
        console.error('Error verifying token:', error instanceof Error ? error.message : String(error));
      }
      return null;
    }
  }
}

// Type for our authenticated user
interface AuthenticatedUser {
  id: string;
  email: string;
  name?: string;
}

// Extend Express Request to include our authenticated user
declare global {
  namespace Express {
    interface Request {
      user?: AuthenticatedUser;
    }
  }
}

// Express middleware for magic link authentication with proper TypeScript types
export const setupMagicLinkAuth = (app: express.Application): MagicLinkService => {
  const magicLinkService = new MagicLinkService();
  
  // Define request body types for enhanced type safety
  interface MagicLinkRequest {
    email: string;
    redirectTo?: string;
  }
  
  interface MagicLinkResponse {
    success: boolean;
    message: string;
  }
  
  // Setup route with proper typing
  app.post<{}, MagicLinkResponse, MagicLinkRequest>('/login/email', async (req, res) => {
    const { email, redirectTo } = req.body;
    
    if (!email) {
      return res.status(400).json({ 
        success: false, 
        message: 'Email is required' 
      });
    }
    
    // Validate email format using TypeScript-friendly regex
    const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
    if (!emailRegex.test(email)) {
      return res.status(400).json({ 
        success: false, 
        message: 'Invalid email format' 
      });
    }
    
    // Get the base URL from request for proper link generation
    const baseUrl = `${req.protocol}://${req.get('host')}`;
    
    // Store redirectTo in session if provided
    if (redirectTo) {
      // Validate redirectTo to prevent open redirects
      const isValidRedirect = redirectTo.startsWith('/') && !redirectTo.includes('//');
      if (isValidRedirect) {
        req.session.redirectTo = redirectTo;
      }
    }
    
    // Send the magic link
    const sent = await magicLinkService.sendMagicLink(email, baseUrl);
    
    // Always return success=true to prevent email enumeration attacks
    res.json({ 
      success: true, 
      message: 'If your email exists in our system, you will receive a magic link shortly' 
    });
  });
  
  // Token verification endpoint with proper error handling
  app.get('/login/verify', async (req, res) => {
    const token = req.query.token as string | undefined;
    
    if (!token) {
      return res.status(400).render('error', { 
        title: 'Invalid Link',
        message: 'The login link is invalid or missing a token.'
      });
    }
    
    const userId = await magicLinkService.verifyToken(token);
    
    if (userId) {
      // Fetch user details from database for the session
      try {
        const userResult = await magicLinkService['db'].query<AuthenticatedUser>(
          'SELECT id, email, name FROM users WHERE id = $1',
          [userId]
        );
        
        if (userResult.rows.length > 0) {
          const user = userResult.rows[0];
          
          // Set type-safe session values
          req.session.userId = userId;
          req.session.user = user;
          req.session.isAuthenticated = true;
          req.session.authMethod = 'magic-link';
          req.session.authTime = new Date().toISOString();
          
          // Check for and use redirectTo if available
          const redirectTo = req.session.redirectTo || '/dashboard';
          delete req.session.redirectTo; // Clean up after use
          
          return res.redirect(redirectTo);
        }
      } catch (error) {
        console.error('Error fetching user details:', error instanceof Error ? error.message : String(error));
      }
      
      // Fallback if user details couldn't be fetched
      req.session.userId = userId;
      req.session.isAuthenticated = true;
      
      res.redirect('/dashboard');
    } else {
      // Provide a user-friendly error page
      res.status(400).render('error', {
        title: 'Invalid or Expired Link',
        message: 'This login link is invalid or has expired. Please request a new one.',
        actionLink: '/login',
        actionText: 'Return to Login'
      });
    }
  });
  
  // Add a middleware that loads the user on every request if authenticated
  app.use(async (req, res, next) => {
    if (req.session.isAuthenticated && req.session.userId && !req.user) {
      try {
        const userResult = await magicLinkService['db'].query<AuthenticatedUser>(
          'SELECT id, email, name FROM users WHERE id = $1',
          [req.session.userId]
        );
        
        if (userResult.rows.length > 0) {
          req.user = userResult.rows[0];
        }
      } catch (error) {
        console.error('Error loading user:', error instanceof Error ? error.message : String(error));
      }
    }
    next();
  });
  
  return magicLinkService;
};

// Type guard for checking if user is authenticated
function isAuthenticated(req: express.Request): boolean {
  return req.session.isAuthenticated === true && !!req.user;
}

Usage in Your Application

// app.ts
import express from 'express';
import session from 'express-session';
import { setupMagicLinkAuth } from './magic-link';

const app = express();

app.use(express.json());
app.use(session({
  secret: process.env.SESSION_SECRET || 'your-session-secret',
  resave: false,
  saveUninitialized: true,
}));

// Setup magic link authentication
const magicLinkService = setupMagicLinkAuth(app);

app.get('/', (req, res) => {
  res.send(`
    <h1>Magic Link Authentication Example</h1>
    <form action="/login/email" method="post">
      <input type="email" name="email" placeholder="Email" required />
      <button type="submit">Send Magic Link</button>
    </form>
  `);
});

app.get('/dashboard', (req, res) => {
  if (!(req.session as any).isAuthenticated) {
    return res.redirect('/');
  }
  
  res.send(`
    <h1>Dashboard</h1>
    <p>You are logged in with user ID: ${(req.session as any).userId}</p>
    <a href="/logout">Logout</a>
  `);
});

app.get('/logout', (req, res) => {
  req.session.destroy(() => {
    res.redirect('/');
  });
});

app.listen(3000, () => console.log('Server running on port 3000'));
  1. Type Safety: Interfaces perfectly model magic link tokens and user states.
  2. Secure by Design: No passwords to hash, compare, or store means fewer security vulnerabilities.
  3. Implementation Clarity: Strong typing makes the token generation, storage, and validation process explicit.
  4. Perfect for Modern Applications: Matches the stateless nature of modern backends.
  5. Transaction Safety: Proper typing helps ensure atomicity when dealing with token validation.
  1. Email Dependency: Still requires reliable email delivery, which can introduce latency.
  2. Complex State Management: Requires careful handling of token states.
  3. Development Overhead: More initial code to write compared to simple password systems.
  4. Delivery Challenges: Email deliverability issues can confuse users.

Developer Insight: Magic links are compelling when you want to provide an excellent UX while eliminating password-related security issues. They shine in applications where security is important but immediate authentication isn't required. TypeScript provides a solid foundation for implementing them safely by ensuring token generation, storage, and verification happens exactly as intended.

OAuth 2.0

OAuth 2.0 is an authorization framework that enables third-party applications to access resources on behalf of users without exposing credentials.

What is OAuth 2.0?

OAuth 2.0 is a protocol that allows a user to grant a third-party application limited access to their resources on another service, without sharing their credentials. It's commonly used for social login (e.g., "Sign in with Google").

Key Components of OAuth 2.0

  • Resource Owner: The user who owns the data
  • Client: The application requesting access to resources
  • Authorization Server: Issues tokens after authenticating the user
  • Resource Server: Hosts the protected resources
  • Access Token: Token used to access protected resources

Implementing OAuth 2.0 in TypeScript

Here's an implementation of OAuth 2.0 authentication with GitHub:

// oauth.ts
import axios from 'axios';
import express from 'express';
import session from 'express-session';

interface OAuthConfig {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
  authorizationEndpoint: string;
  tokenEndpoint: string;
  userInfoEndpoint: string;
  scope: string;
}

export class OAuthService {
  private readonly config: OAuthConfig;
  
  constructor(config: OAuthConfig) {
    this.config = config;
  }
  
  getAuthorizationUrl(state: string): string {
    const params = new URLSearchParams({
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      scope: this.config.scope,
      state,
      response_type: 'code',
    });
    
    return `${this.config.authorizationEndpoint}?${params.toString()}`;
  }
  
  async getAccessToken(code: string): Promise<string> {
    const params = new URLSearchParams({
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      code,
      redirect_uri: this.config.redirectUri,
      grant_type: 'authorization_code',
    });
    
    const response = await axios.post(this.config.tokenEndpoint, params.toString(), {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        Accept: 'application/json',
      },
    });
    
    return response.data.access_token;
  }
  
  async getUserInfo(accessToken: string): Promise<any> {
    const response = await axios.get(this.config.userInfoEndpoint, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    
    return response.data;
  }
}

// Express middleware for OAuth authentication with GitHub
export const setupGitHubOAuth = (app: express.Application): OAuthService => {
  const oauthService = new OAuthService({
    clientId: process.env.GITHUB_CLIENT_ID || 'your-client-id',
    clientSecret: process.env.GITHUB_CLIENT_SECRET || 'your-client-secret',
    redirectUri: 'http://localhost:3000/auth/github/callback',
    authorizationEndpoint: 'https://github.com/login/oauth/authorize',
    tokenEndpoint: 'https://github.com/login/oauth/access_token',
    userInfoEndpoint: 'https://api.github.com/user',
    scope: 'user:email',
  });
  
  app.use(session({
    secret: process.env.SESSION_SECRET || 'your-session-secret',
    resave: false,
    saveUninitialized: true,
  }));
  
  app.get('/auth/github', (req, res) => {
    const state = Math.random().toString(36).substring(2, 15);
    (req.session as any).oauthState = state;
    
    const authUrl = oauthService.getAuthorizationUrl(state);
    res.redirect(authUrl);
  });
  
  app.get('/auth/github/callback', async (req, res) => {
    try {
      const { code, state } = req.query as { code: string, state: string };
      const savedState = (req.session as any).oauthState;
      
      if (state !== savedState) {
        return res.status(400).json({ message: 'State mismatch' });
      }
      
      // Exchange code for access token
      const accessToken = await oauthService.getAccessToken(code);
      (req.session as any).accessToken = accessToken;
      
      // Get user information
      const userInfo = await oauthService.getUserInfo(accessToken);
      (req.session as any).userInfo = userInfo;
      (req.session as any).isAuthenticated = true;
      
      res.redirect('/profile');
    } catch (error) {
      console.error('OAuth callback error:', error);
      res.status(500).json({ message: 'Authentication failed' });
    }
  });
  
  return oauthService;
};

Usage in Your Application

// app.ts
import express from 'express';
import { setupGitHubOAuth } from './oauth';

const app = express();

// Setup GitHub OAuth authentication
const oauthService = setupGitHubOAuth(app);

app.get('/', (req, res) => {
  res.send(`
    <h1>OAuth 2.0 Authentication Example</h1>
    <a href="/auth/github">Login with GitHub</a>
  `);
});

app.get('/profile', (req, res) => {
  if (!(req.session as any).isAuthenticated) {
    return res.redirect('/');
  }
  
  const userInfo = (req.session as any).userInfo;
  
  res.send(`
    <h1>Profile</h1>
    <p>Name: ${userInfo.name || 'N/A'}</p>
    <p>Login: ${userInfo.login}</p>
    <img src="${userInfo.avatar_url}" width="100" />
    <p><a href="/logout">Logout</a></p>
  `);
});

app.get('/logout', (req, res) => {
  req.session.destroy(() => {
    res.redirect('/');
  });
});

app.listen(3000, () => console.log('Server running on port 3000'));

Advantages of OAuth 2.0

  1. Delegated authorization: Provides access to resources without sharing credentials
  2. Wide adoption: Supported by most major services
  3. User experience: Easy "Sign in with X" experience
  4. Selective permissions: Granular control over resource access

Disadvantages of OAuth 2.0

  1. Complexity: More complex to implement than basic authentication
  2. Dependency: Relies on external providers
  3. Security considerations: Requires careful implementation to avoid vulnerabilities

Session-Based Authentication

Session-based authentication is a traditional approach where the server maintains session state for authenticated users.

What is Session-Based Authentication?

In session-based authentication, the server creates a session for the user after successful authentication, stores session data on the server, and provides the user with a session ID (typically stored in a cookie).

Implementing Session-Based Authentication in TypeScript

Here's a simple implementation using Express and TypeScript:

// session-auth.ts
import express from 'express';
import session from 'express-session';
import bcrypt from 'bcrypt';
import { Pool } from 'pg';

export class SessionAuthService {
  private readonly db: Pool;
  
  constructor() {
    // Setup database connection
    this.db = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
  }
  
  async authenticateUser(username: string, password: string): Promise<any | null> {
    try {
      // Find user by username
      const result = await this.db.query(
        'SELECT id, username, password_hash FROM users WHERE username = $1',
        [username]
      );
      
      if (result.rows.length === 0) {
        return null; // User not found
      }
      
      const user = result.rows[0];
      
      // Verify password
      const passwordMatch = await bcrypt.compare(password, user.password_hash);
      
      if (!passwordMatch) {
        return null; // Password doesn't match
      }
      
      // Return user without sensitive data
      const { password_hash, ...userWithoutPassword } = user;
      return userWithoutPassword;
    } catch (error) {
      console.error('Authentication error:', error);
      return null;
    }
  }
  
  async registerUser(username: string, password: string): Promise<any | null> {
    try {
      // Check if username already exists
      const existingUser = await this.db.query(
        'SELECT id FROM users WHERE username = $1',
        [username]
      );
      
      if (existingUser.rows.length > 0) {
        return null; // Username already taken
      }
      
      // Hash the password
      const saltRounds = 10;
      const passwordHash = await bcrypt.hash(password, saltRounds);
      
      // Insert the new user
      const result = await this.db.query(
        'INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username',
        [username, passwordHash]
      );
      
      return result.rows[0];
    } catch (error) {
      console.error('Registration error:', error);
      return null;
    }
  }
}

// Express middleware for session-based authentication
export const setupSessionAuth = (app: express.Application): SessionAuthService => {
  const authService = new SessionAuthService();
  
  app.use(express.json());
  app.use(session({
    secret: process.env.SESSION_SECRET || 'your-session-secret',
    resave: false,
    saveUninitialized: false,
    cookie: {
      secure: process.env.NODE_ENV === 'production', // Use secure cookies in production
      maxAge: 24 * 60 * 60 * 1000, // 24 hours
    },
  }));
  
  // Authentication middleware
  const requireAuth = (req: express.Request, res: express.Response, next: express.NextFunction) => {
    if (!(req.session as any).user) {
      return res.status(401).json({ message: 'Authentication required' });
    }
    next();
  };
  
  // Login route
  app.post('/login', async (req, res) => {
    const { username, password } = req.body;
    
    if (!username || !password) {
      return res.status(400).json({ message: 'Username and password are required' });
    }
    
    const user = await authService.authenticateUser(username, password);
    
    if (!user) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }
    
    // Store user in session
    (req.session as any).user = user;
    
    res.json({ message: 'Login successful', user });
  });
  
  // Register route
  app.post('/register', async (req, res) => {
    const { username, password } = req.body;
    
    if (!username || !password) {
      return res.status(400).json({ message: 'Username and password are required' });
    }
    
    const user = await authService.registerUser(username, password);
    
    if (!user) {
      return res.status(400).json({ message: 'Registration failed' });
    }
    
    res.status(201).json({ message: 'Registration successful', user });
  });
  
  // Logout route
  app.post('/logout', (req, res) => {
    req.session.destroy(() => {
      res.json({ message: 'Logout successful' });
    });
  });
  
  // Protected route example
  app.get('/profile', requireAuth, (req, res) => {
    res.json({ user: (req.session as any).user });
  });
  
  return authService;
};

Usage in Your Application

// app.ts
import express from 'express';
import { setupSessionAuth } from './session-auth';

const app = express();

// Setup session-based authentication
const authService = setupSessionAuth(app);

app.get('/', (req, res) => {
  res.send(`
    <h1>Session-Based Authentication Example</h1>
    <h2>Login</h2>
    <form id="loginForm">
      <input type="text" name="username" placeholder="Username" required />
      <input type="password" name="password" placeholder="Password" required />
      <button type="submit">Login</button>
    </form>
    
    <h2>Register</h2>
    <form id="registerForm">
      <input type="text" name="username" placeholder="Username" required />
      <input type="password" name="password" placeholder="Password" required />
      <button type="submit">Register</button>
    </form>
    
    <script>
      document.getElementById('loginForm').addEventListener('submit', async (e) => {
        e.preventDefault();
        const formData = new FormData(e.target);
        const response = await fetch('/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            username: formData.get('username'),
            password: formData.get('password')
          })
        });
        
        const data = await response.json();
        alert(data.message);
        
        if (response.ok) {
          window.location.href = '/profile';
        }
      });
      
      document.getElementById('registerForm').addEventListener('submit', async (e) => {
        e.preventDefault();
        const formData = new FormData(e.target);
        const response = await fetch('/register', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            username: formData.get('username'),
            password: formData.get('password')
          })
        });
        
        const data = await response.json();
        alert(data.message);
      });
    </script>
  `);
});

app.listen(3000, () => console.log('Server running on port 3000'));

Advantages of Session-Based Authentication

  1. Server control: Easy to invalidate sessions
  2. Stateful: Full control over the session lifecycle
  3. Performance: Small session IDs rather than larger tokens
  4. Familiarity: Well-established pattern with broad support

Disadvantages of Session-Based Authentication

  1. Scalability challenges: Requires session storage on the server
  2. Cross-domain issues: Cookies have limitations across domains
  3. CSRF vulnerability: Requires additional protection
  4. Mobile applications: Challenges with cookie handling

Passwordless Authentication

Passwordless authentication eliminates passwords entirely, relying on other verification methods like one-time codes, biometrics, or security keys.

What is Passwordless Authentication?

Passwordless authentication uses alternatives to passwords for verifying user identity. Common methods include:

  • One-time codes sent via SMS or email
  • Magic links (covered earlier)
  • Biometrics (fingerprint, face recognition)
  • Hardware security keys (FIDO2/WebAuthn)

Implementing WebAuthn in TypeScript

Here's an implementation of WebAuthn (FIDO2) authentication:

// webauthn.ts
import express from 'express';
import { Pool } from 'pg';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { 
  RegistrationResponseJSON, 
  AuthenticationResponseJSON 
} from '@simplewebauthn/typescript-types';
import base64url from 'base64url';

// RPServer represents the Relying Party (RP) server
export class WebAuthnService {
  private readonly db: Pool;
  private readonly rpName = 'TypeScript Auth Demo';
  private readonly rpID = 'localhost';
  private readonly origin = 'http://localhost:3000';
  
  constructor() {
    // Setup database connection
    this.db = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
  }
  
  // Generate registration options
  async generateRegistrationOptions(userId: string, username: string): Promise<any> {
    // Get existing authenticators for this user
    const result = await this.db.query(
      'SELECT credential_id FROM user_authenticators WHERE user_id = $1',
      [userId]
    );
    
    const excludeCredentials = result.rows.map(row => ({
      id: base64url.toBuffer(row.credential_id),
      type: 'public-key',
      transports: ['usb', 'ble', 'nfc', 'internal'],
    }));
    
    // Generate registration options
    const options = generateRegistrationOptions({
      rpName: this.rpName,
      rpID: this.rpID,
      userID: userId,
      userName: username,
      userDisplayName: username,
      attestationType: 'none',
      excludeCredentials,
      authenticatorSelection: {
        authenticatorAttachment: 'platform',
        userVerification: 'preferred',
        requireResidentKey: false,
      },
    });
    
    // Save challenge to verify later
    await this.db.query(
      'INSERT INTO webauthn_challenges (user_id, challenge) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET challenge = $2',
      [userId, options.challenge]
    );
    
    return options;
  }
  
  // Verify registration response
  async verifyRegistration(userId: string, response: RegistrationResponseJSON): Promise<boolean> {
    try {
      // Get stored challenge
      const challengeResult = await this.db.query(
        'SELECT challenge FROM webauthn_challenges WHERE user_id = $1',
        [userId]
      );
      
      if (challengeResult.rows.length === 0) {
        return false;
      }
      
      const expectedChallenge = challengeResult.rows[0].challenge;
      
      // Verify the registration
      const verification = await verifyRegistrationResponse({
        response,
        expectedChallenge,
        expectedOrigin: this.origin,
        expectedRPID: this.rpID,
      });
      
      if (!verification.verified) {
        return false;
      }
      
      // Store the authenticator
      const { credentialID, credentialPublicKey } = verification.registrationInfo!;
      
      await this.db.query(
        'INSERT INTO user_authenticators (user_id, credential_id, public_key, counter) VALUES ($1, $2, $3, $4)',
        [
          userId,
          base64url.encode(credentialID),
          base64url.encode(credentialPublicKey),
          verification.registrationInfo!.counter,
        ]
      );
      
      return true;
    } catch (error) {
      console.error('WebAuthn registration verification error:', error);
      return false;
    }
  }
  
  // Generate authentication options
  async generateAuthenticationOptions(username: string): Promise<any> {
    // Find user by username
    const userResult = await this.db.query(
      'SELECT id FROM users WHERE username = $1',
      [username]
    );
    
    if (userResult.rows.length === 0) {
      throw new Error('User not found');
    }
    
    const userId = userResult.rows[0].id;
    
    // Get user's authenticators
    const authenticatorsResult = await this.db.query(
      'SELECT credential_id FROM user_authenticators WHERE user_id = $1',
      [userId]
    );
    
    if (authenticatorsResult.rows.length === 0) {
      throw new Error('No authenticators registered for this user');
    }
    
    const allowCredentials = authenticatorsResult.rows.map(row => ({
      id: base64url.toBuffer(row.credential_id),
      type: 'public-key',
      transports: ['usb', 'ble', 'nfc', 'internal'],
    }));
    
    // Generate authentication options
    const options = generateAuthenticationOptions({
      rpID: this.rpID,
      allowCredentials,
      userVerification: 'preferred',
    });
    
    // Save challenge
    await this.db.query(
      'INSERT INTO webauthn_challenges (user_id, challenge) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET challenge = $2',
      [userId, options.challenge]
    );
    
    return { options, userId };
  }
  
  // Verify authentication response
  async verifyAuthentication(userId: string, response: AuthenticationResponseJSON): Promise<boolean> {
    try {
      // Get challenge
      const challengeResult = await this.db.query(
        'SELECT challenge FROM webauthn_challenges WHERE user_id = $1',
        [userId]
      );
      
      if (challengeResult.rows.length === 0) {
        return false;
      }
      
      const expectedChallenge = challengeResult.rows[0].challenge;
      
      // Get authenticator
      const authenticatorResult = await this.db.query(
        'SELECT credential_id, public_key, counter FROM user_authenticators WHERE credential_id = $1',
        [response.id]
      );
      
      if (authenticatorResult.rows.length === 0) {
        return false;
      }
      
      const authenticator = authenticatorResult.rows[0];
      
      // Verify the authentication
      const verification = await verifyAuthenticationResponse({
        response,
        expectedChallenge,
        expectedOrigin: this.origin,
        expectedRPID: this.rpID,
        authenticator: {
          credentialID: base64url.toBuffer(authenticator.credential_id),
          credentialPublicKey: base64url.toBuffer(authenticator.public_key),
          counter: authenticator.counter,
        },
      });
      
      if (!verification.verified) {
        return false;
      }
      
      // Update counter
      await this.db.query(
        'UPDATE user_authenticators SET counter = $1 WHERE credential_id = $2',
        [verification.authenticationInfo.newCounter, response.id]
      );
      
      return true;
    } catch (error) {
      console.error('WebAuthn authentication verification error:', error);
      return false;
    }
  }
}

// Express middleware for WebAuthn
export const setupWebAuthn = (app: express.Application): WebAuthnService => {
  const webAuthnService = new WebAuthnService();
  
  app.use(express.json());
  app.use(session({
    secret: process.env.SESSION_SECRET || 'your-session-secret',
    resave: false,
    saveUninitialized: true,
  }));
  
  // Registration endpoints
  app.post('/webauthn/register/options', async (req, res) => {
    try {
      const { username } = req.body;
      
      if (!username) {
        return res.status(400).json({ message: 'Username is required' });
      }
      
      // For simplicity, create a user if it doesn't exist
      const userResult = await pool.query(
        'INSERT INTO users (username) VALUES ($1) ON CONFLICT (username) DO UPDATE SET username = $1 RETURNING id',
        [username]
      );
      
      const userId = userResult.rows[0].id;
      
      // Generate registration options
      const options = await webAuthnService.generateRegistrationOptions(userId, username);
      
      // Store user ID in session
      (req.session as any).userId = userId;
      
      res.json(options);
    } catch (error) {
      console.error('WebAuthn registration options error:', error);
      res.status(500).json({ message: 'Failed to generate registration options' });
    }
  });
  
  app.post('/webauthn/register/verify', async (req, res) => {
    try {
      const userId = (req.session as any).userId;
      
      if (!userId) {
        return res.status(400).json({ message: 'Registration session expired' });
      }
      
      const response = req.body as RegistrationResponseJSON;
      
      const verified = await webAuthnService.verifyRegistration(userId, response);
      
      if (verified) {
        // Set authenticated user in session
        (req.session as any).isAuthenticated = true;
        (req.session as any).userId = userId;
        
        res.json({ message: 'Registration successful' });
      } else {
        res.status(400).json({ message: 'Registration failed' });
      }
    } catch (error) {
      console.error('WebAuthn registration verification error:', error);
      res.status(500).json({ message: 'Registration failed' });
    }
  });
  
  // Authentication endpoints
  app.post('/webauthn/login/options', async (req, res) => {
    try {
      const { username } = req.body;
      
      if (!username) {
        return res.status(400).json({ message: 'Username is required' });
      }
      
      const { options, userId } = await webAuthnService.generateAuthenticationOptions(username);
      
      // Store user ID in session
      (req.session as any).userId = userId;
      
      res.json(options);
    } catch (error) {
      console.error('WebAuthn login options error:', error);
      res.status(500).json({ message: 'Failed to generate authentication options' });
    }
  });
  
  app.post('/webauthn/login/verify', async (req, res) => {
    try {
      const userId = (req.session as any).userId;
      
      if (!userId) {
        return res.status(400).json({ message: 'Authentication session expired' });
      }
      
      const response = req.body as AuthenticationResponseJSON;
      
      const verified = await webAuthnService.verifyAuthentication(userId, response);
      
      if (verified) {
        // Set authenticated user in session
        (req.session as any).isAuthenticated = true;
        (req.session as any).userId = userId;
        
        res.json({ message: 'Authentication successful' });
      } else {
        res.status(400).json({ message: 'Authentication failed' });
      }
    } catch (error) {
      console.error('WebAuthn authentication verification error:', error);
      res.status(500).json({ message: 'Authentication failed' });
    }
  });
  
  return webAuthnService;
};

Advantages of Passwordless Authentication

  1. Security: Eliminates password-related vulnerabilities
  2. User experience: Simplified login process
  3. Reduced support: No more password resets
  4. Phishing resistance: WebAuthn is highly resistant to phishing

Disadvantages of Passwordless Authentication

  1. Complexity: More complex to implement than password-based auth
  2. Fallback methods: May still need alternative methods
  3. Adoption challenges: Users may be unfamiliar with new methods
  4. Device dependency: Often requires specific device capabilities

Choosing the Right Pattern

When selecting an authentication pattern for your TypeScript application, consider these factors:

Security Requirements

  • High security needs: Consider WebAuthn, OIDC with MFA, or JWT with short expiry
  • Standard security: JWT, session-based, or OAuth 2.0 are good choices
  • Consumer applications: Magic links or social login via OAuth 2.0

Application Architecture

  • Microservices: JWT or OIDC work well for distributed systems
  • Monolithic: Session-based authentication is often simplest
  • Mobile/SPA: JWT or OAuth 2.0 for easy cross-platform integration

User Experience

  • Frictionless UX: Magic links or social login
  • Enterprise users: OIDC with SSO capabilities
  • Security-conscious users: WebAuthn or multi-factor authentication

Development Complexity

  • Simple implementation: Session-based authentication
  • Standard complexity: JWT implementation
  • More complex: OIDC, WebAuthn, or custom OAuth 2.0

Security Best Practices

Regardless of the authentication pattern you choose, follow these security best practices:

1. Transport Security

  • Always use HTTPS: Encrypt all authentication traffic
  • Secure cookies: Use the Secure and HttpOnly flags
  • HSTS headers: Implement HTTP Strict Transport Security

2. Token Security

  • Short-lived tokens: Set appropriate expiration times
  • Token storage: Store tokens securely (httpOnly cookies preferred over localStorage)
  • Refresh token rotation: Rotate refresh tokens after use

3. Implementation Security

  • Input validation: Validate all user inputs
  • Rate limiting: Implement rate limiting on authentication endpoints
  • Logging and monitoring: Track authentication events for security analysis

4. Password Security (if used)

  • Strong hashing: Use bcrypt, Argon2, or scrypt with appropriate settings
  • Password policies: Encourage strong passwords without excessive complexity
  • MFA: Implement multi-factor authentication where possible

5. Security Headers

// Example of implementing security headers in Express
import helmet from 'helmet';

app.use(helmet()); // Adds various security headers
app.use(helmet.contentSecurityPolicy()); // Configures Content Security Policy
app.use(helmet.xssFilter()); // Sets X-XSS-Protection header

Choosing the Right Authentication Pattern for Your TypeScript Project

The authentication pattern you choose can significantly impact your application's security, scalability, and user experience. Here are recommendations tailored for TypeScript projects:

Authentication Pattern Best For TypeScript Advantages
JWT APIs, SPAs, microservices Strong payload typing, interface-based validation
OIDC Enterprise apps, compliance requirements Complex schema modeling, type-safe token handling
Magic Links Consumer apps, reduced friction Secure token modeling, type-safe email templates
OAuth 2.0 Social login, platform integration Clear protocol flow typing, error handling
Session-Based Monolithic apps, traditional web Type-safe session management
WebAuthn High-security applications Complex data structure handling

TypeScript Authentication Best Practices

Beyond choosing the right pattern, here are some authentication best practices that leverage TypeScript's strengths:

  1. Use Literal Types for Authorization Roles:

    type UserRole = 'admin' | 'editor' | 'viewer'; // Better than string
    
  2. Create Custom Type Guards:

    function isAuthenticated(req: Request): req is AuthenticatedRequest {
      return req.user !== undefined;
    }
    
  3. Leverage Utility Types:

    // Prevent JWT payload tampering in type-safe way
    type JWTPayload = Readonly<{
      sub: string;
      exp: number;
      iat: number;
      roles: ReadonlyArray<UserRole>;
    }>;
    
  4. Never Trust External Input:

    // Validate with zod or similar
    const userSchema = z.object({
      email: z.string().email(),
      password: z.string().min(8)
    });
    
  5. Use Environment Variables with Type Safety:

    // In a config.ts file
    interface Config {
      jwtSecret: string;
      tokenExpiry: number;
      // ...
    }
    
    export const config: Config = {
      jwtSecret: process.env.JWT_SECRET || throwConfigError('JWT_SECRET'),
      tokenExpiry: parseInt(process.env.TOKEN_EXPIRY || '3600'),
      // ...
    };
    
    function throwConfigError(variable: string): never {
      throw new Error(`Missing required config variable: ${variable}`);
    }
    

Authentication Libraries with Strong TypeScript Support

The following libraries provide excellent TypeScript support for authentication:

  1. jose - Comprehensive JWT implementation
  2. openid-client - Certified OIDC implementation
  3. zod - Runtime validation with static type inference
  4. passport-typescript - Type definitions for Passport.js
  5. @simplewebauthn/server - WebAuthn implementation

Parting Thoughts

Authentication isn't just about implementing a protocol—it's about using the type system to enforce security constraints and make impossible states impossible. This is where TypeScript truly shines.

While types can't prevent all security issues, they give us guardrails that catch many common mistakes before deployment. Combined with proper testing and security reviews, strongly-typed authentication can be both developer-friendly and highly secure.

Remember: The best authentication system is one that's appropriately complex for your needs—no more, no less. Start simple, add complexity as requirements dictate, and let the type system guide your implementation.

Happy authenticating!