Security Best Practices
Reviewed & published by Brayan K
Securing a Node API means layering defenses — secure HTTP headers with Helmet, controlled cross-origin access with CORS, rate limiting, input validation, and keeping secrets out of your code — so common attacks are blocked before they reach your logic.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll see what Helmet's headers do, how to allowlist origins with CORS, how rate limiting throttles abuse, why you must validate and sanitize input, how to keep secrets in environment variables, and a tour of the OWASP basics that tie it all together.
What You'll Learn in This Lesson
- What Helmet's security headers protect against
- Configuring CORS with an origin allowlist (not '*')
- How rate limiting throttles brute-force and abuse
- Validating and sanitizing user input
- Keeping secrets in environment variables, not code
- The OWASP basics every API should cover
1️⃣ Secure Headers (Helmet)
Browsers honour special HTTP response headers that harden a site. Helmet sets a sensible bundle of them in one line. Key ones: X-Content-Type-Options: nosniff stops MIME-sniffing, X-Frame-Options: DENY blocks clickjacking, Strict-Transport-Security forces HTTPS, and a Content-Security-Policy restricts where resources may load from. The model below builds that same header set.
// Helmet sets HTTP response headers that make common attacks harder.
// Here's a plain-Node model that builds the same kind of header set.
function securityHeaders() {
return {
'X-Content-Type-Options': 'nosniff', // don't guess MIME types
'X-Frame-Options': 'DENY', // block clickjacking via iframes
'Strict-Transport-Security': 'max-age=15552000', // force HTTPS
'Content-Security-Policy': "default-src 'self'", // restrict resource origins
};
}
const headers = securityHeaders();
for (const [name, value] of Object.entries(headers)) {
console.log(`${name}: ${value}`);
}2️⃣ Helmet, CORS & Rate Limiting in Express
In a real app these are three middlewares you add early. helmet() sets the headers above; cors() controls which origins may call your API — always allowlist specific origins instead of '*'; and express-rate-limit caps requests per IP so brute-force and abuse hit a 429. Capping the JSON body size adds one more guard.
// The real production stack, wired into Express.
// Install once: npm install helmet cors express-rate-limit
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const app = express();
// 1) Helmet — sets the secure response headers shown above (and more).
app.use(helmet());
// 2) CORS — control WHICH origins may call your API. Allowlist, don't use '*'.
app.use(cors({
origin: ['https://app.example.com'], // only this site may call us
methods: ['GET', 'POST'],
}));
// 3) Rate limiting — cap requests per IP to blunt brute-force/abuse.
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per IP per window -> then 429
}));
app.use(express.json({ limit: '10kb' })); // also cap body size3️⃣ How Rate Limiting Works
Rate limiting is easiest to understand as a token bucket: each client starts with N tokens, every request spends one, and when the bucket is empty further requests are rejected with 429 Too Many Requests until it refills. The runnable model below gives each client 3 tokens, so the 4th request onward is blocked — exactly what express-rate-limit does per IP.
// Rate limiting caps how many requests a client may make in a window.
// This is a simple token-bucket: each client gets N tokens; each request
// spends one; blocked when empty.
function makeLimiter(limit) {
const buckets = new Map(); // clientId -> tokens left
return function allow(clientId) {
if (!buckets.has(clientId)) buckets.set(clientId, limit);
const left = buckets.get(clientId);
if (left <= 0) return false; // 429 Too Many Requests
buckets.set(clientId, left - 1);
return true;
};
}
const allow = makeLimiter(3); // 3 requests allowed per client
for (let i = 1; i <= 5; i++) {
const ok = allow('1.2.3.4');
console.log(`Request ${i}: ${ok ? 'ALLOWED' : 'BLOCKED (429)'}`);
}Your turn. The sanitizer below works once you fill in the single blank marked ___. Follow the 👉 hint, then run it.
// 🎯 YOUR TURN — replace each ___ then run it.
function sanitize(input) {
// strip angle brackets so injected <script> tags can't run
return input.replace(___, ''); // 👉 a regex matching < or > globally: /[<>]/g
}
console.log(sanitize('Hello')); // Hello
console.log(sanitize('<script>alert(1)</script>')); // scriptalert(1)/script
// ✅ Expected output:
// Hello
// scriptalert(1)/scriptMini-Challenge: Validate a Signup
No blanks — just a brief and an outline. Write input validation that rejects a bad email and a too-short password with clear error messages. Build it, run it, and compare with the example output.
// 🎯 MINI-CHALLENGE: validate a signup payload
// 1. validateSignup(body) collects errors into an array.
// 2. email must be a string containing "@" -> else 'valid email required'
// 3. password must be a string, length >= 8 -> else 'password must be at least 8 characters'
// 4. Return the errors array (empty means valid).
// 5. Print results for a good body and a bad body.
//
// ✅ Example output:
// Good: []
// Bad: [ 'valid email required', 'password must be at least 8 characters' ]
// your code hereCommon Errors (and the fix)
- ❌ Hardcoding secrets in code — keys committed to git leak forever. ✅ Load them from process.env and keep .env in .gitignore.
- ❌ CORS set to '*' on an authenticated API — any site can call it. ✅ Allowlist your own origins.
- ❌ Trusting client input — unchecked data enables injection and XSS. ✅ Validate types/shape and sanitize before storing or rendering.
- ❌ No rate limiting on login — invites brute-force attacks. ✅ Add express-rate-limit, with a shared store across instances.
- ❌ Building SQL with string concatenation — classic SQL injection. ✅ Use parameterized queries or an ORM that escapes inputs for you.
Pro Tips
- 💡 Defense in depth: no single layer is enough — combine headers, CORS, limits, validation, and secret management.
- 💡 Validate with a schema library: tools like Zod or Joi make input rules declarative and reusable.
- 💡 Keep dependencies patched: run npm audit and update regularly — most breaches exploit known, fixed vulnerabilities.
📋 Quick Reference — Security Layers
| Concern | Tool |
|---|---|
| Secure headers | app.use(helmet()) |
| Origin control | cors({ origin: [...] }) |
| Throttle abuse | express-rate-limit |
| Validate input | Zod / Joi schema |
| Secrets | process.env (.env) |
| SQL safety | parameterized queries |
| Audit deps | npm audit |
🎉 Lesson Complete!
- ✅ Helmet sets secure headers (nosniff, frame options, HSTS, CSP) in one line
- ✅ CORS should allowlist specific origins, never blanket '*' on authenticated APIs
- ✅ Rate limiting caps requests per client and returns 429 when exceeded
- ✅ Always validate and sanitize input — never trust the client
- ✅ Keep secrets in environment variables, out of source control
- ✅ Defense in depth plus npm audit covers the OWASP basics
Practice quiz
What does the Helmet middleware do?
- Encrypts the database
- Sets secure HTTP response headers
- Rate limits requests
- Validates input schemas
Answer: Sets secure HTTP response headers. Helmet sets a bundle of security-related response headers in one line.
Which header value stops browsers from MIME-sniffing responses?
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- Strict-Transport-Security
- Content-Security-Policy
Answer: X-Content-Type-Options: nosniff. X-Content-Type-Options: nosniff tells browsers not to guess the MIME type.
Why shouldn't you set CORS to allow all origins ('*') on an authenticated API?
- It is slower
- It breaks JSON parsing
- Any site could trigger requests on behalf of a logged-in user
- It disables HTTPS
Answer: Any site could trigger requests on behalf of a logged-in user. A wildcard origin lets any website call your API, dangerous for cookie/session-based auth.
What HTTP status does a rate limiter return once the cap is exceeded?
- 403 Forbidden
- 401 Unauthorized
- 500 Server Error
- 429 Too Many Requests
Answer: 429 Too Many Requests. Rate limiting returns 429 Too Many Requests when a client exceeds its allowance.
Where should secrets like API keys and DB passwords live?
- In environment variables, not in source code
- Hardcoded in the main file
- In a public README
- In the git commit message
Answer: In environment variables, not in source code. Load secrets from process.env (e.g. a gitignored .env) so they never get committed.
What attack does rate limiting most directly help blunt?
- MIME sniffing
- Brute-force password guessing
- Clickjacking
- CSS injection
Answer: Brute-force password guessing. Capping requests per client limits how many password attempts an attacker can make.
Which approach prevents classic SQL injection?
- String concatenation of inputs
- Logging the query
- Parameterized queries or an ORM that escapes inputs
- Disabling the database
Answer: Parameterized queries or an ORM that escapes inputs. Parameterized queries keep user input as data, never as executable SQL.
What does the express-rate-limit 'max' option set?
- The response timeout
- The body size limit
- The number of CORS origins
- The number of requests per IP per window
Answer: The number of requests per IP per window. max caps how many requests a single IP may make within windowMs before getting a 429.
What should you do with untrusted client input before storing or rendering it?
- Validate and sanitize it
- Trust it for speed
- Email it to yourself
- Cache it in Redis
Answer: Validate and sanitize it. Always validate types and shape and sanitize input to defend against injection and XSS.
What does the X-Frame-Options: DENY header protect against?
- SQL injection
- Clickjacking via iframes
- Slow database queries
- Expired TLS certificates
Answer: Clickjacking via iframes. Denying framing prevents your pages being embedded in a malicious iframe (clickjacking).
Continue this course
- Previous: Caching with Redis (ioredis)
- Next: Building CLI Tools (commander) — Parse commands, options, and flags to ship a real CLI
- Quick reference: Node.js cheat sheet
Frequently asked questions
What does Helmet actually do?
Helmet is Express middleware that sets a bundle of HTTP response headers known to reduce common web vulnerabilities. By default it sends headers like X-Content-Type-Options: nosniff (stops browsers from MIME-sniffing a response into something executable), X-Frame-Options / frame-ancestors (prevents your pages being embedded in a malicious iframe — clickjacking), Strict-Transport-Security (tells browsers to always use HTTPS), and a Content-Security-Policy (restricts where scripts, styles, and other resources may load from, mitigating cross-site scripting). You add it with one line, app.use(helmet()), and it gives you sensible secure defaults you'd otherwise have to set by hand.
Why shouldn't I just set CORS to allow all origins?
Setting Access-Control-Allow-Origin to '*' tells browsers that any website may make requests to your API. For a truly public, read-only, unauthenticated API that can be fine, but for anything with cookies, sessions, or authenticated actions it's dangerous: a malicious site could trigger requests on behalf of a logged-in user. The safer approach is an allowlist — pass cors({ origin: ['https://app.example.com'] }) so only your own front ends are permitted, and restrict methods and headers to what you actually use. CORS is a browser-enforced policy, so this is specifically about protecting your users' browsers from cross-origin abuse.
How does rate limiting protect my API?
Rate limiting caps how many requests a single client (usually identified by IP) can make in a time window, returning HTTP 429 Too Many Requests once the cap is hit. This blunts several attacks and problems: brute-forcing passwords or tokens (an attacker can only try so many times), credential-stuffing, scraping, and accidental or deliberate denial-of-service from a runaway client. In Express, express-rate-limit does this in a few lines (windowMs plus max); behind multiple servers you back it with a shared store like Redis so the limit is enforced across all instances. It's a cheap, high-value layer that every public endpoint should have.
Why is it dangerous to put secrets in my code?
Hardcoding API keys, database passwords, or JWT signing secrets directly in source code means they get committed to version control, where they live in history forever and leak the moment the repo is shared, forked, or made public — automated bots scan GitHub for exactly these strings within minutes. The fix is to load secrets from environment variables (process.env, often via a .env file that's listed in .gitignore and never committed), and in production from a secrets manager. This keeps credentials out of the codebase, lets each environment use different values, and means a leaked repo doesn't hand attackers the keys to your database.