Securing REST APIs beyond authentication

Most breaches in real APIs are not broken crypto. They are CORS misconfiguration, mass assignment and a server that fetches a URL you gave it.

CORS, correctly

// CORS is a browser control, not an authorisation control.
const ALLOWED = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

app.use(function cors(req, res, next) {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.set("Access-Control-Allow-Origin", origin);   // echo the exact origin
    res.set("Vary", "Origin");                        // or caches will mix them up
    res.set("Access-Control-Allow-Credentials", "true");
    res.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE");
    res.set("Access-Control-Allow-Headers", "Content-Type,Authorization,Idempotency-Key");
    res.set("Access-Control-Max-Age", "600");
  }
  if (req.method === "OPTIONS") return res.status(204).end();
  return next();
});
  • Never reflect an arbitrary Origin while also sending Allow-Credentials: true — that combination lets any site read authenticated responses.
  • A wildcard origin with credentials is rejected by browsers anyway, so a working configuration is always an explicit list.
  • Always send Vary: Origin so a shared cache does not serve one site's CORS headers to another.
  • CORS protects the browser's users, not your API. A curl client ignores it entirely, so authorisation must be server side.

Input handling and the classic injection risks

RiskHow it happensControl
Mass assignmentBinding the request body straight onto a modelAn explicit allowlist of writable fields per operation
BOLA / IDORReading an object by id without an ownership checkScope every query by the caller's tenant or subject
SSRFThe server fetches a URL from the request bodyAn allowlist of hosts and schemes, no redirects, no link-local ranges
InjectionString-concatenated queries or shell commandsParameterised queries and argument arrays
Excessive data exposureReturning the whole entityAn explicit response schema, never the internal model
Log leakageLogging full request bodiesRedact tokens, passwords, card data and personal fields
NoSQL injectionPassing an operator object from JSON into a queryReject non-scalar values where a scalar is expected
from flask import request, jsonify
import ipaddress, socket
from urllib.parse import urlparse

WRITABLE = {"name", "email", "locale"}     # the allowlist, not a blocklist

def update_profile(user, body: dict):
    # Mass assignment defence: only copy what the caller may write
    changes = {k: v for k, v in body.items() if k in WRITABLE}
    if not changes:
        raise ValueError("no writable fields supplied")
    if "email" in changes and not looks_like_email(changes["email"]):
        raise ValueError("invalid email")
    user.update(changes)

ALLOWED_HOSTS = {"cdn.partner.example", "images.partner.example"}

def fetch_partner_image(raw_url: str) -> bytes:
    u = urlparse(raw_url)
    if u.scheme not in ("https",):
        raise ValueError("https only")
    if u.hostname not in ALLOWED_HOSTS:
        raise ValueError("host not allowed")

    # Resolve first and reject anything that is not a public address
    for family, _, _, _, sockaddr in socket.getaddrinfo(u.hostname, 443):
        ip = ipaddress.ip_address(sockaddr[0])
        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
            raise ValueError("address not routable")

    return http_get(raw_url, allow_redirects=False, timeout=5)
⚠️
An owner check hidden inside a serializer, a decorator or an ORM default is not a control — it is a convention. Put the authorisation decision in the query that loads the object, so that a forgotten check returns no data rather than someone else's data.

FAQ

Is an API key enough to protect an internal API?
Only if the network also constrains who can reach it. Treat the key as one factor and add mTLS or a network policy, because keys leak through logs, repositories and screenshots.
Should I return 403 or 404 when a caller cannot see an object?
Return 404 when revealing existence is itself sensitive, and 403 when the caller legitimately knows the object exists. Be consistent within a resource, and never alternate in a way that leaks.

Authentication and authorisation Handling uploads and binary payloads

Last refreshed 2026-09-18.