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

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 size

3️⃣ 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)/script

Mini-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 here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Security Layers

ConcernTool
Secure headersapp.use(helmet())
Origin controlcors({ origin: [...] })
Throttle abuseexpress-rate-limit
Validate inputZod / Joi schema
Secretsprocess.env (.env)
SQL safetyparameterized queries
Audit depsnpm audit

🎉 Lesson Complete!

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

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.