DEV Community

TJ Coding
TJ Coding

Posted on

A Guide to Production-Ready Security, Logging and Architecture in the express app

It is easy to write an Express app. It is hard to write an Express app that survives production.

A "Hello World" tutorial won't tell you how to trace a specific bug through 10,000 requests per second, or how to prevent your server from leaking stack traces to hackers.

Here is a blueprint for hardening your Node.js/Express architecture using modern patterns like AsyncLocalStorage, structured logging, and strict validation.


1. Traceability: The AsyncLocalStorage Pattern

In a complex app, passing a requestId or logger instance down through every controller, service, and database repository is messy (backend "prop drilling").

Node.js offers a native solution: AsyncLocalStorage (ALS). It allows you to store data that is unique to the current asynchronous execution context (i.e., the current request) without passing arguments.

The Setup

First, create a storage file.

// src/lib/store.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export interface RequestContext {
  requestId: string;
  userId?: string;
}

export const storage = new AsyncLocalStorage<RequestContext>();

export const getRequestId = () => {
  const store = storage.getStore();
  return store?.requestId || 'system';
};
Enter fullscreen mode Exit fullscreen mode

The Middleware

Next, create a middleware that generates a unique ID (UUID) for every incoming request and wraps the rest of the request lifecycle in the store.

// src/middleware/context.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { storage } from '../lib/store';

export const contextMiddleware = (req: Request, res: Response, next: NextFunction) => {
  const requestId = (req.headers['x-request-id'] as string) || uuidv4();

  // Attach to response header so the client knows the ID too
  res.setHeader('x-request-id', requestId);

  // Run the rest of the app within the context
  storage.run({ requestId }, () => {
    next();
  });
};
Enter fullscreen mode Exit fullscreen mode

Now, anywhere in your code (even deep inside a database helper), you can call getRequestId() to know exactly which request triggered the code.


2. Structured Logging with Context

console.log is insufficient for production. You need structured logging (JSON) so tools like Datadog, CloudWatch, or ELK can parse it.

Using libraries like Pino combined with our ALS setup allows us to automatically inject the requestId into every single log line without manually adding it.

// src/lib/logger.ts
import pino from 'pino';
import { getRequestId } from './store';

export const logger = pino({
  mixin() {
    // Automatically adds requestId to every log object
    return { requestId: getRequestId() };
  },
  transport: {
    target: process.env.NODE_ENV === 'development' ? 'pino-pretty' : undefined,
  },
});
Enter fullscreen mode Exit fullscreen mode

Usage:

logger.info("User logged in"); 
// Output: { "level": 30, "time": 123456, "msg": "User logged in", "requestId": "abc-123" }
Enter fullscreen mode Exit fullscreen mode

3. Security: The "Low Hanging Fruit" Headers

Do not deploy without Helmet. It sets various HTTP headers to secure your app against common attack vectors like XSS (Cross-Site Scripting) and Clickjacking.

import helmet from 'helmet';

// Enable standard security headers
app.use(helmet());

// Specifically configure Content Security Policy (CSP) if you serve UI
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "trusted-cdn.com"],
    },
  })
);
Enter fullscreen mode Exit fullscreen mode

Rate Limiting

Prevent brute-force attacks and DoS. While this is often better handled at the Load Balancer (AWS ALB) or Nginx level, having an application-level safety net is best practice.

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  standardHeaders: true, 
  legacyHeaders: false,
});

app.use(limiter);
Enter fullscreen mode Exit fullscreen mode

4. Input Validation: Trust Nothing

TypeScript types are erased at runtime. You cannot rely on TS interfaces to validate incoming JSON bodies. Use a runtime schema validator like Zod.

Create a generic middleware to enforce schema compliance:

// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';

export const validate = (schema: AnyZodObject) => 
  (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({ errors: error.errors });
      }
      next(error);
    }
  };
Enter fullscreen mode Exit fullscreen mode

5. Centralized Error Handling

Never send a raw 500 stack trace to the client. It exposes your internal file structure and potential vulnerabilities.

  1. Create a custom AppError class to distinguish between Operational Errors (bad input, not found) and Programmer Errors (bugs).
  2. Use a global error handling middleware at the end of your app.
// src/middleware/errorHandler.ts
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
  const requestId = getRequestId();

  // Log the error with the trace ID for debugging
  logger.error({ err, requestId }, "Unhandled Error");

  // Send a sanitized response to the user
  res.status(500).json({
    status: 'error',
    message: 'Internal Server Error',
    requestId, // Handy for the user to give to customer support
  });
};
Enter fullscreen mode Exit fullscreen mode

6. Graceful Shutdown

When you deploy a new version to AWS/Heroku/Kubernetes, the orchestrator sends a SIGTERM signal. If you don't handle this, Express kills active connections immediately, resulting in dropped requests for users.

const server = app.listen(3000);

const gracefulShutdown = () => {
  logger.info("SIGTERM received. Shutting down gracefully...");

  server.close(() => {
    logger.info("HTTP server closed.");

    // Close Database connections here
    // await db.disconnect();

    process.exit(0);
  });

  // Force close after 10s if connections hang
  setTimeout(() => process.exit(1), 10000);
};

process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
Enter fullscreen mode Exit fullscreen mode

Summary Checklist

  1. Traceability: Implemented AsyncLocalStorage with UUIDs.
  2. Logging: Structured JSON logs (Pino) auto-tagged with Request IDs.
  3. Security: Helmet headers and Rate Limiting active.
  4. Validation: Zod schemas for all inputs.
  5. Failures: Global error handler sanitizes output.
  6. Lifecycle: Graceful shutdown handles deployments smoothly.

Top comments (0)