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,
},
})
);| Header | What it does | Risk if missing |
|---|---|---|
Content-Security-Policy | Controls which resources the browser can load | XSS attacks can run arbitrary scripts |
Strict-Transport-Security | Forces HTTPS for future requests | Downgrade attacks expose traffic |
X-Frame-Options | Prevents your page from being embedded in iframes | Clickjacking attacks |
X-Content-Type-Options | Stops browsers from guessing file types | MIME-sniffing attacks |
Referrer-Policy | Controls what's sent in the Referer header | Leaks sensitive URLs to third parties |
Permissions-Policy | Restricts 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"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;
});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();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');
}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);Quick reference
| Category | Tool / approach | Priority |
|---|---|---|
| Security headers | helmet (Node.js) | High |
| Input validation | zod, joi, yup | High |
| SQL injection | Parameterized queries | Critical |
| Secrets management | Environment variables + .gitignore | Critical |
| Rate limiting | express-rate-limit | High |
| HTTPS | Let's Encrypt / Cloudflare | Critical |
| Dependency auditing | npm audit | Medium |
| Error handling | Catch-all middleware, no stack traces | High |