Module P-6·17 min read

12-factor app config, Helmet security headers, express-rate-limit, CORS, HTTPS enforcement, secure cookie flags, and secrets management patterns.

Written by Jatin Jain Saraf · Architect of SupraScan

Module P-6 — Configuration, Security Hardening, and Rate Limiting

What this module covers: An API that works on your machine is not a production API. This module covers the 12-factor approach to configuration so secrets never end up in source control, the HTTP security headers that close the most common attack vectors, CORS configured correctly so browsers can reach your API, rate limiting that stops brute-force attacks, and the secure cookie flags that protect tokens from XSS. These are not optional polish — they are the baseline that separates hobby projects from production systems.


12-Factor Configuration: No Secrets in Code

The Twelve-Factor App methodology's rule on configuration: store config in the environment, never in the code. Every value that changes between environments (dev, staging, production) is configuration. Every credential is configuration.

# Wrong — committing a secret, even if you delete it later, it's in git history
const DB_URL = 'postgresql://admin:supersecret@prod-db:5432/myapp';
 
# Right — read from the environment
const DB_URL = process.env.DATABASE_URL;

dotenv for local development

npm install dotenv
# .env — never commit this file
DATABASE_URL=postgresql://localhost:5432/myapp_dev
JWT_ACCESS_SECRET=local-dev-secret-at-least-32-characters-long
JWT_REFRESH_SECRET=local-refresh-secret-also-32-characters-long
PORT=3000
NODE_ENV=development
REDIS_URL=redis://localhost:6379
# .env.example — commit this file, no real values
DATABASE_URL=postgresql://localhost:5432/myapp_dev
JWT_ACCESS_SECRET=generate-with-node-e-crypto-randomBytes-64-hex
JWT_REFRESH_SECRET=generate-with-node-e-crypto-randomBytes-64-hex
PORT=3000
NODE_ENV=development
REDIS_URL=redis://localhost:6379
# .gitignore — must include
.env
.env.local
.env.*.local

Load dotenv once, at the very start of your application:

// src/index.ts — first line before any other imports
import 'dotenv/config';
 
// or equivalently:
import dotenv from 'dotenv';
dotenv.config();

Config validation with Zod

Raw process.env is untyped — every value is string | undefined. Validate it at startup so the app fails fast with a clear error instead of silently failing later:

// src/config/env.ts
import { z } from 'zod';
 
const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_ACCESS_SECRET: z.string().min(32, 'JWT_ACCESS_SECRET must be at least 32 characters'),
  JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
  JWT_ACCESS_EXPIRY: z.string().default('15m'),
  JWT_REFRESH_EXPIRY: z.string().default('7d'),
  REDIS_URL: z.string().url().optional(),
  CORS_ORIGIN: z.string().default('http://localhost:5173'),
  RATE_LIMIT_WINDOW_MS: z.coerce.number().default(15 * 60 * 1000), // 15 min
  RATE_LIMIT_MAX: z.coerce.number().default(100),
});
 
// Parse immediately — if invalid, throw with clear error message and exit
const parsed = envSchema.safeParse(process.env);
 
if (!parsed.success) {
  console.error('❌ Invalid environment configuration:');
  console.error(parsed.error.format());
  process.exit(1);
}
 
export const env = parsed.data;
// env.PORT is number, not string | undefined
// env.DATABASE_URL is string, not string | undefined

Import env instead of process.env everywhere:

// Before
const port = parseInt(process.env.PORT ?? '3000');
 
// After
import { env } from './config/env.js';
const port = env.PORT; // already a number

Beyond .env: Secrets Managers in Production

.env files solve secrets management for local development — they keep credentials out of source control on your laptop. They do not solve it in production. A .env file sitting on a server is a plaintext file with no access control, no rotation, and no audit trail: anyone who can read the filesystem (a misconfigured backup, a debug endpoint that shells out, a compromised dependency) reads every secret at once.

Production systems fetch secrets from a dedicated secrets manager at startup instead of a file:

// src/config/secrets.ts — AWS Secrets Manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
 
const client = new SecretsManagerClient({ region: process.env.AWS_REGION });
 
export async function loadSecrets() {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: 'myapp/production' }),
  );
  return JSON.parse(response.SecretString!);
  // { DATABASE_URL, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, ... }
}
// src/index.ts — fetch secrets before the Zod-validated env module is loaded
const secrets = await loadSecrets();
Object.assign(process.env, secrets);
// now import './config/env.js' as usual — it parses process.env same as before

HashiCorp Vault is the equivalent for teams not on AWS — same idea, different client (node-vault), with the added benefit of short-lived, automatically-rotated credentials (a database password that expires in an hour instead of living in a file forever).

What this buys you over .env in production:

  • Access control — IAM/Vault policies decide who can read which secret, not filesystem permissions.
  • Rotation — rotate a compromised credential without redeploying every service that reads a .env file.
  • Audit trail — every read is logged: which service, which secret, when.

.env for local dev, a secrets manager for anything deployed — they're two halves of the same practice, not competing approaches.


Helmet: HTTP Security Headers

Helmet sets HTTP response headers that tell browsers how to handle your content safely. It prevents a class of attacks that have nothing to do with your application logic.

npm install helmet
import helmet from 'helmet';
 
app.use(helmet());

That one line sets these headers (among others):

HeaderWhat it does
Content-Security-PolicyRestricts which resources the browser can load — blocks inline script injection
X-Frame-OptionsPrevents clickjacking (your page can't be embedded in an iframe)
X-Content-Type-Options: nosniffPrevents MIME type sniffing — browser uses declared content type
Strict-Transport-SecurityForces HTTPS for future requests (HSTS)
Referrer-PolicyControls how much URL info is sent in the Referer header
X-Permitted-Cross-Domain-PoliciesBlocks Adobe Flash/Acrobat cross-domain requests

CSP exists to restrict what a browser rendering your response as HTML is allowed to load and execute. A pure JSON API never asks a browser to render its response as HTML, so CSP has nothing to protect there — it's not a matter of "relaxing" the directives, it's that the entire header is largely moot. (A directive list that just restates self for every source — as an earlier version of this section showed — isn't looser than Helmet's own default policy; it's the same policy with fewer entries typed out.)

For a pure JSON API, pick one of two honest options instead of a fake "relaxed" config:

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.