Skip to main content

Command Palette

Search for a command to run...

Express Middleware: Custom Authentication

Published
10 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Traditional Authentication Approaches Fail in 2025

The authentication landscape has fundamentally shifted. Session-based authentication with server-side storage creates bottlenecks in containerized environments where pods scale dynamically and share no state. Redis-backed sessions introduce network latency and single points of failure. Cookie-based approaches struggle with cross-origin requests in modern micro-frontend architectures where the UI, API gateway, and backend services operate on different domains.

Passport.js, while still functional, wasn't designed for the current reality of multi-tenant SaaS platforms where each tenant might use different identity providers, require custom claim validation, or need real-time permission revocation. The strategy pattern becomes unwieldy when you need to support OAuth 2.0, SAML, API keys, service-to-service JWT tokens, and custom hardware security module (HSM) integrations simultaneously.

More critically, compliance requirements have intensified. GDPR, CCPA, and emerging AI regulations require audit trails showing exactly when authentication decisions were made, what data was accessed, and how tokens were validated. Generic authentication libraries rarely provide the granular logging and observability hooks needed for compliance automation.

Modern Architecture for Express Custom Authentication Middleware

Production-grade custom authentication middleware in 2025 follows a layered architecture that separates token validation, claims extraction, permission evaluation, and audit logging into composable functions. This approach enables testing individual components, swapping authentication strategies without touching business logic, and maintaining consistent security policies across multiple services.

Here's a TypeScript implementation demonstrating current best practices:

import { Request, Response, NextFunction } from 'express';
import { verify, JwtPayload } from 'jsonwebtoken';
import { createHash } from 'crypto';

interface AuthenticatedRequest extends Request {
  user?: {
    id: string;
    email: string;
    roles: string[];
    tenantId: string;
    permissions: string[];
  };
  authContext?: {
    tokenHash: string;
    issuedAt: number;
    expiresAt: number;
    authMethod: string;
  };
}

interface TokenValidationResult {
  valid: boolean;
  payload?: JwtPayload;
  error?: string;
  revoked?: boolean;
}

class TokenValidator {
  private revokedTokenCache: Map<string, number>;
  private publicKeyCache: Map<string, string>;

  constructor() {
    this.revokedTokenCache = new Map();
    this.publicKeyCache = new Map();
  }

  async validateToken(token: string): Promise<TokenValidationResult> {
    const tokenHash = createHash('sha256').update(token).digest('hex');

    // Check revocation list with cache
    if (this.revokedTokenCache.has(tokenHash)) {
      return { valid: false, error: 'Token revoked', revoked: true };
    }

    try {
      // Extract kid from header for key rotation support
      const header = JSON.parse(
        Buffer.from(token.split('.')[0], 'base64').toString()
      );

      const publicKey = await this.getPublicKey(header.kid);

      const payload = verify(token, publicKey, {
        algorithms: ['RS256', 'ES256'],
        issuer: process.env.JWT_ISSUER,
        audience: process.env.JWT_AUDIENCE,
        clockTolerance: 30 // 30 second clock skew tolerance
      }) as JwtPayload;

      // Validate custom claims
      if (!payload.sub || !payload.tenant_id) {
        return { valid: false, error: 'Missing required claims' };
      }

      return { valid: true, payload };
    } catch (error) {
      return { 
        valid: false, 
        error: error instanceof Error ? error.message : 'Invalid token' 
      };
    }
  }

  private async getPublicKey(kid: string): Promise<string> {
    if (this.publicKeyCache.has(kid)) {
      return this.publicKeyCache.get(kid)!;
    }

    // Fetch from JWKS endpoint with retry logic
    const response = await fetch(
      `${process.env.JWKS_URI}`,
      { signal: AbortSignal.timeout(2000) }
    );

    const jwks = await response.json();
    const key = jwks.keys.find((k: any) => k.kid === kid);

    if (!key) {
      throw new Error(`Public key not found for kid: ${kid}`);
    }

    // Convert JWK to PEM format
    const publicKey = this.jwkToPem(key);
    this.publicKeyCache.set(kid, publicKey);

    return publicKey;
  }

  private jwkToPem(jwk: any): string {
    // Implementation depends on key type (RSA/EC)
    // Use node-jose or similar library in production
    return jwk.x5c ? `-----BEGIN CERTIFICATE-----\n${jwk.x5c[0]}\n-----END CERTIFICATE-----` : '';
  }

  async checkRevocation(tokenHash: string): Promise<boolean> {
    // Check against distributed revocation list (Redis/DynamoDB)
    // Implementation depends on infrastructure
    return this.revokedTokenCache.has(tokenHash);
  }
}

class PermissionEvaluator {
  async evaluatePermissions(
    userId: string,
    tenantId: string,
    requiredPermissions: string[]
  ): Promise<string[]> {
    // Fetch user permissions from cache or database
    // In production, use a distributed cache with TTL
    const userPermissions = await this.fetchUserPermissions(userId, tenantId);

    return requiredPermissions.filter(perm => 
      userPermissions.includes(perm) || userPermissions.includes('*')
    );
  }

  private async fetchUserPermissions(
    userId: string, 
    tenantId: string
  ): Promise<string[]> {
    // Implementation: Query permission service or database
    // Cache results with 5-minute TTL
    return [];
  }
}

export function createAuthMiddleware(options: {
  requiredPermissions?: string[];
  allowServiceTokens?: boolean;
  skipRevocationCheck?: boolean;
}) {
  const validator = new TokenValidator();
  const permissionEvaluator = new PermissionEvaluator();

  return async (
    req: AuthenticatedRequest,
    res: Response,
    next: NextFunction
  ) => {
    const startTime = Date.now();

    try {
      // Extract token from Authorization header
      const authHeader = req.headers.authorization;

      if (!authHeader?.startsWith('Bearer ')) {
        return res.status(401).json({
          error: 'Missing or invalid authorization header',
          code: 'AUTH_HEADER_INVALID'
        });
      }

      const token = authHeader.substring(7);
      const tokenHash = createHash('sha256').update(token).digest('hex');

      // Validate token structure and signature
      const validationResult = await validator.validateToken(token);

      if (!validationResult.valid) {
        return res.status(401).json({
          error: validationResult.error,
          code: validationResult.revoked ? 'TOKEN_REVOKED' : 'TOKEN_INVALID'
        });
      }

      const payload = validationResult.payload!;

      // Check revocation list unless explicitly skipped
      if (!options.skipRevocationCheck) {
        const isRevoked = await validator.checkRevocation(tokenHash);
        if (isRevoked) {
          return res.status(401).json({
            error: 'Token has been revoked',
            code: 'TOKEN_REVOKED'
          });
        }
      }

      // Evaluate permissions if required
      if (options.requiredPermissions?.length) {
        const grantedPermissions = await permissionEvaluator.evaluatePermissions(
          payload.sub!,
          payload.tenant_id,
          options.requiredPermissions
        );

        if (grantedPermissions.length !== options.requiredPermissions.length) {
          return res.status(403).json({
            error: 'Insufficient permissions',
            code: 'PERMISSION_DENIED',
            required: options.requiredPermissions,
            granted: grantedPermissions
          });
        }
      }

      // Attach user context to request
      req.user = {
        id: payload.sub!,
        email: payload.email,
        roles: payload.roles || [],
        tenantId: payload.tenant_id,
        permissions: payload.permissions || []
      };

      req.authContext = {
        tokenHash,
        issuedAt: payload.iat!,
        expiresAt: payload.exp!,
        authMethod: payload.auth_method || 'jwt'
      };

      // Log authentication event for audit trail
      await logAuthEvent({
        userId: req.user.id,
        tenantId: req.user.tenantId,
        endpoint: req.path,
        method: req.method,
        timestamp: new Date(),
        duration: Date.now() - startTime,
        success: true
      });

      next();
    } catch (error) {
      await logAuthEvent({
        endpoint: req.path,
        method: req.method,
        timestamp: new Date(),
        duration: Date.now() - startTime,
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error'
      });

      return res.status(500).json({
        error: 'Authentication service error',
        code: 'AUTH_SERVICE_ERROR'
      });
    }
  };
}

async function logAuthEvent(event: any): Promise<void> {
  // Send to observability platform (DataDog, New Relic, etc.)
  // Store in audit log database for compliance
  console.log('[AUTH]', JSON.stringify(event));
}

This implementation addresses several critical requirements: key rotation through JWKS endpoint integration, token revocation checking with caching to minimize latency, fine-grained permission evaluation, and comprehensive audit logging. The middleware is composable—you can chain multiple instances with different permission requirements for complex authorization scenarios.

Implementing Rate Limiting and Brute Force Protection

Authentication middleware must include protection against credential stuffing and brute force attacks. Modern implementations use distributed rate limiting with sliding windows:

import { createClient } from 'redis';

class RateLimiter {
  private redis: ReturnType<typeof createClient>;

  constructor(redisUrl: string) {
    this.redis = createClient({ url: redisUrl });
    this.redis.connect();
  }

  async checkRateLimit(
    identifier: string,
    maxAttempts: number,
    windowSeconds: number
  ): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
    const key = `ratelimit:auth:${identifier}`;
    const now = Date.now();
    const windowStart = now - (windowSeconds * 1000);

    // Remove old entries and count recent attempts
    await this.redis.zRemRangeByScore(key, 0, windowStart);
    const attempts = await this.redis.zCard(key);

    if (attempts >= maxAttempts) {
      const oldestAttempt = await this.redis.zRange(key, 0, 0, {
        REV: false
      });
      const resetAt = parseInt(oldestAttempt[0]) + (windowSeconds * 1000);

      return { allowed: false, remaining: 0, resetAt };
    }

    // Add current attempt
    await this.redis.zAdd(key, { score: now, value: now.toString() });
    await this.redis.expire(key, windowSeconds);

    return {
      allowed: true,
      remaining: maxAttempts - attempts - 1,
      resetAt: now + (windowSeconds * 1000)
    };
  }
}

export function createRateLimitMiddleware(
  rateLimiter: RateLimiter,
  options: { maxAttempts: number; windowSeconds: number }
) {
  return async (req: Request, res: Response, next: NextFunction) => {
    // Use IP address or user identifier
    const identifier = req.ip || req.headers['x-forwarded-for'] as string;

    const result = await rateLimiter.checkRateLimit(
      identifier,
      options.maxAttempts,
      options.windowSeconds
    );

    res.setHeader('X-RateLimit-Limit', options.maxAttempts);
    res.setHeader('X-RateLimit-Remaining', result.remaining);
    res.setHeader('X-RateLimit-Reset', result.resetAt);

    if (!result.allowed) {
      return res.status(429).json({
        error: 'Too many authentication attempts',
        code: 'RATE_LIMIT_EXCEEDED',
        resetAt: new Date(result.resetAt).toISOString()
      });
    }

    next();
  };
}

Common Pitfalls and Edge Cases

Token Timing Attacks: Comparing tokens using standard string equality enables timing attacks. Always use constant-time comparison functions from the crypto module. The example above uses SHA-256 hashing before comparison, which provides additional protection.

Clock Skew Issues: Distributed systems have clock drift. The clockTolerance option in JWT verification prevents legitimate tokens from being rejected due to minor time differences between issuer and validator. Set this to 30-60 seconds in production.

Key Rotation Failures: When rotating signing keys, both old and new keys must be valid during the transition period. Implement a grace period where tokens signed with the previous key remain valid for at least 24 hours. Cache JWKS responses but implement cache invalidation when rotation is detected.

Memory Leaks in Token Caches: Unbounded caches for revoked tokens or public keys cause memory exhaustion. Implement LRU eviction policies and set maximum cache sizes. Use Redis or Memcached for distributed deployments rather than in-memory caches.

Missing Audit Trails: Authentication events must be logged before and after validation, including failures. Many implementations only log successful authentications, making security incident investigation impossible. Log IP addresses, user agents, and request metadata for forensic analysis.

Synchronous Blocking Operations: Fetching public keys or checking revocation lists synchronously blocks the event loop. All external calls must be asynchronous with appropriate timeouts. The example uses AbortSignal.timeout() to prevent hanging requests.

Insufficient Error Information: Generic "Unauthorized" responses make debugging difficult. Return specific error codes (TOKEN_EXPIRED, TOKEN_REVOKED, PERMISSION_DENIED) while avoiding information disclosure that could aid attackers.

Best Practices for Production Deployment

Implement Health Checks: Authentication middleware should expose health check endpoints that verify connectivity to token validation services, permission databases, and revocation lists. Include these in Kubernetes liveness and readiness probes.

Use Structured Logging: Log authentication events in JSON format with consistent field names. Include correlation IDs that trace requests across microservices. This enables automated security monitoring and compliance reporting.

Cache Aggressively: Permission lookups and public key fetches are expensive. Cache results with appropriate TTLs—5 minutes for permissions, 1 hour for public keys. Implement cache warming on application startup to prevent cold start latency.

Monitor Authentication Latency: Set up alerts when authentication middleware latency exceeds 100ms at the 95th percentile. High latency indicates problems with external dependencies or cache misses.

Implement Circuit Breakers: When permission services or revocation lists are unavailable, decide whether to fail open (allow requests) or fail closed (deny requests) based on security requirements. Use circuit breakers to prevent cascading failures.

Test Token Expiration Scenarios: Write integration tests that verify behavior when tokens expire mid-request, when refresh tokens are used, and when revocation occurs between validation and request processing.

Document Security Assumptions: Clearly document which threats the middleware protects against and which require additional controls. For example, the middleware validates tokens but doesn't prevent replay attacks—that requires additional nonce validation.

Implement Gradual Rollouts: Deploy authentication changes using feature flags. Start with a small percentage of traffic and monitor error rates before full rollout. Authentication bugs affect all users simultaneously.

FAQ

What is the best way to handle token refresh in Express custom authentication middleware?

Implement refresh token rotation where each refresh operation issues a new refresh token and invalidates the old one. Store refresh tokens in a secure, encrypted database with expiration timestamps. The authentication middleware should only validate access tokens—refresh logic belongs in a separate endpoint that exchanges refresh tokens for new access token pairs.

How does custom authentication middleware scale in serverless environments in 2025?

Serverless functions require stateless authentication. Use JWT tokens with all necessary claims embedded, cache public keys in environment variables or parameter stores, and leverage edge caching for JWKS endpoints. Avoid database lookups in the hot path—pre-compute permissions and include them in token claims. Cold start latency is critical; initialize validation components outside the handler function.

When should you avoid building custom authentication middleware?

Avoid custom middleware when your requirements align with standard OAuth 2.0 or OpenID Connect flows and you can use managed identity providers like Auth0, AWS Cognito, or Azure AD B2C. Custom middleware makes sense for complex multi-tenant scenarios, legacy system integration, or when compliance requires specific audit trails that managed services don't provide.

What are the security implications of caching authentication decisions?

Caching creates a window where revoked tokens remain valid until cache expiration. For high-security scenarios, set cache TTLs to 1-5 minutes and implement cache invalidation when revocation events occur. Use Redis pub/sub to broadcast revocation events across all service instances. Balance security requirements against performance—every cache miss adds 50-200ms latency.

How do you test custom authentication middleware effectively?

Write unit tests for token validation logic using test fixtures with expired, malformed, and valid tokens. Create integration tests that verify behavior with real identity providers in staging environments. Use contract testing to ensure middleware correctly handles all token formats your identity provider issues. Test failure scenarios: network timeouts, invalid signatures, missing claims, and revoked tokens.

Best way to implement multi-tenancy in Express authentication middleware?

Include tenant ID in JWT claims and validate it against the requested resource. Implement tenant isolation at the middleware level by checking that req.user.tenantId matches the tenant ID in the request path or headers. Store tenant-specific configuration (allowed identity providers, permission models) in a configuration service and load it during middleware initialization.

How to handle authentication in WebSocket connections with Express?

Validate tokens during the WebSocket upgrade request using the same middleware. Store the authenticated user context in the WebSocket connection object. Implement periodic token revalidation for long-lived connections—check token expiration every 5 minutes and close connections with expired tokens. Send a warning message 2 minutes before expiration to allow clients to refresh tokens gracefully.

Conclusion

Building production-grade express custom authentication middleware requires careful attention to security, performance, and operational concerns that extend far beyond basic token validation. The architecture presented here