Production Engineering/
Lesson

Most security incidents don't happen because of exotic zero-day vulnerabilities. They happen because of boring, preventable mistakes: a secret accidentally committed to Git, a missing Content-Security-Policy header, a route that accepts any input without validation. This lesson gives you a practical checklist you can apply to any production application.

HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. security headers

Security headers are instructions you send to the browser along with every response. They tell the browser what it's allowed to do, and what it isn't. Think of them as guardrails built into the browser itself.

The essential headers

The helmet package for Node.js sets most of these automatically, but understanding what they do is essential for configuring them correctly.

import express from 'express';
import helmet from 'helmet';

const app = express();

// helmet() sets sensible defaults, but you should customize CSP
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],   // no inline scripts
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", 'data:', 'https:'],
        connectSrc: ["'self'"],
      },
    },
    hsts: {
      maxAge: 31536000,          // 1 year in seconds
      includeSubDomains: true,
      preload: true,
    },
  })
);
HeaderWhat it doesRisk if missing
Content-Security-PolicyControls which resources the browser can loadXSS attacks can run arbitrary scripts
Strict-Transport-SecurityForces HTTPS for future requestsDowngrade attacks expose traffic
X-Frame-OptionsPrevents your page from being embedded in iframesClickjacking attacks
X-Content-Type-OptionsStops browsers from guessing file typesMIME-sniffing attacks
Referrer-PolicyControls what's sent in the Referer headerLeaks sensitive URLs to third parties
Permissions-PolicyRestricts access to browser APIs (camera, mic)Malicious scripts can access hardware
Content-Security-Policy is the most powerful header but also the most complex to configure. Start with default-src 'self' and loosen it only where needed. Using 'unsafe-inline' for scripts defeats most of the protection CSP provides.

Testing your headers

You can check your headers quickly using curl or with free online tools like securityheaders.com.

# Check what headers your server returns
curl -I https://your-app.com

# Look for the security-relevant ones
curl -sI https://your-app.com | grep -i -E "content-security|strict-transport|x-frame|x-content-type"
02

Input validation and sanitizationWhat is sanitization?Cleaning user input by removing or escaping dangerous characters before using it in HTML, SQL, or other contexts.

Your application should never trust data that comes from outside. That means query parameters, request bodies, route parameters, cookies, and even headers. You validate to ensure data has the expected shape, and you sanitize to ensure it can't be used to attack your system.

Validation with ZodWhat is zod?A TypeScript-first schema validation library that validates data at runtime while automatically inferring static TypeScript types from the schema.

import { z } from 'zod';

const createUserSchema = z.object({
  username: z
    .string()
    .min(3)
    .max(30)
    .regex(/^[a-zA-Z0-9_]+$/),   // no special characters
  email: z.string().email(),
  age: z.number().int().min(13).max(120),
});

app.post('/users', (req, res) => {
  const result = createUserSchema.safeParse(req.body);

  if (!result.success) {
    // Return validation errors, not a generic 500
    return res.status(400).json({ errors: result.error.flatten() });
  }

  // result.data is now fully typed and validated
  const { username, email, age } = result.data;
});
Never use raw req.body or req.params in a database query or shell command. Even something as innocent as a user ID parameter can be used for injection if you don't validate it first.

SQL injectionWhat is sql injection?An attack where user input is inserted directly into a database query, letting the attacker read, modify, or delete data. Parameterized queries prevent it. prevention

Always use parameterized queries. Never build SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables. strings by concatenating user input.

// DANGEROUS - never do this
const query = `SELECT * FROM users WHERE id = ${req.params.id}`;

// SAFE - parameterized query
const user = await db.prepare(
  'SELECT * FROM users WHERE id = ?'
).bind(req.params.id).first();
03

Secrets management

A hardcoded APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. key in your source code is a ticking time bomb. It only takes one accidental commitWhat is commit?A permanent snapshot of your staged changes saved in Git's history, identified by a unique hash and accompanied by a message describing what changed. to a public repositoryWhat is repository?A project folder tracked by Git that stores your files along with the complete history of every change, inside a hidden .git directory., and that secret is compromised forever, even if you delete the commit, it likely already exists in someone's clone or in GitHub's cache.

The right pattern

# .env file - never commit this
DATABASE_URL=postgres://user:password@host/db
JWT_SECRET=your-super-secret-key-here
STRIPE_SECRET_KEY=sk_live_...

# .gitignore - always include this
.env
.env.local
.env.*.local
// Access secrets through environment variables
const jwtSecret = process.env.JWT_SECRET;

if (!jwtSecret) {
  throw new Error('JWT_SECRET environment variable is required');
}
Validate that required environment variables exist at startup time. This is far better than discovering a missing secret in production when a request fails at 2am.
04

Rate limitingWhat is rate limiting?Restricting how many requests a client can make within a time window. Prevents brute-force attacks and protects your API from being overwhelmed.

Rate limiting protects your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. from being overwhelmed, intentionally or accidentally. It prevents brute-force login attacks, credential stuffingWhat is credential stuffing?An automated attack that tries username/password pairs leaked from one breach against many other services., and abuse of expensive AI endpoints.

import rateLimit from 'express-rate-limit';

// General API rate limit
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                   // max 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' },
});

// Stricter limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Too many login attempts.' },
});

app.use('/api/', apiLimiter);
app.use('/auth/login', authLimiter);
app.use('/auth/register', authLimiter);
05

Quick reference

CategoryTool / approachPriority
Security headershelmet (Node.js)High
Input validationzod, joi, yupHigh
SQL injectionParameterized queriesCritical
Secrets managementEnvironment variables + .gitignoreCritical
Rate limitingexpress-rate-limitHigh
HTTPSLet's Encrypt / CloudflareCritical
Dependency auditingnpm auditMedium
Error handlingCatch-all middleware, no stack tracesHigh