API security comes down to three checks on every request: who is calling (authentication), whether they may touch this specific object and field (authorization), and whether the request stays inside the limits you set (validation and rate limits). Most real API breaches are not exotic exploits. They are a missing ownership check on one endpoint, a token accepted without checking its audience, or an export endpoint with no page size cap. This checklist closes those gaps, with code you can adapt.
Key takeaways
- Enforce object-level authorization in the data access layer, on every query. Broken Object Level Authorization has been first in both editions of the OWASP API Security Top 10.
- Validate tokens fully: signature against a pinned algorithm, issuer, audience and expiry. A token minted for another service must fail.
- Reject unknown fields in request bodies and return explicit response shapes. Mass assignment and data over-exposure are the same bug in two directions.
- Rate limit by identity and by cost, not only by IP, and decide in advance whether the limiter fails open or closed.
- Deny by default at the router. A new route without an access policy should fail CI, not ship open.
- Keep an inventory. The endpoint you forgot is the one nobody is patching.
The threat model in one table
The OWASP API Security Top 10 (2023 edition) is the most useful shared vocabulary for API risk. Here it is mapped to what the bug looks like in code and the control that actually stops it.
| ID | Risk | What it looks like | Primary control |
|---|---|---|---|
| API1 | Broken Object Level Authorization | GET /invoices/:id returns any invoice | Scope every query by owner or tenant |
| API2 | Broken Authentication | Token accepted without an audience check, no login throttling | Full token validation, per-account limits |
| API3 | Broken Object Property Level Authorization | Client sets role in a PATCH body, response includes passwordHash | Strict input schemas, explicit output shapes |
| API4 | Unrestricted Resource Consumption | ?limit=1000000, unbounded GraphQL depth | Pagination caps, cost limits, timeouts |
| API5 | Broken Function Level Authorization | A regular user calls /admin/users | Deny-by-default route policies |
| API6 | Unrestricted Access to Sensitive Business Flows | Bots buy all the stock or mass-create accounts | Flow-specific limits and friction |
| API7 | Server Side Request Forgery | A webhook URL field fetches 169.254.169.254 | Resolve-then-check, egress controls, no redirects |
| API8 | Security Misconfiguration | Stack traces in errors, permissive CORS | Hardened defaults, config tests |
| API9 | Improper Inventory Management | An old /v1 still live and unpatched | Spec-driven inventory, sunset dates |
| API10 | Unsafe Consumption of APIs | Trusting a partner API response blindly | Validate third-party data like user input |
Notice how few of these a WAF can fix. A WAF sees a well-formed request with a valid token asking for invoice 12345. Only your code knows that invoice 12345 belongs to someone else.
Authorization first: BOLA is the bug you probably have
Authentication middleware tells you the caller is user 42. It says nothing about whether invoice 12345 belongs to user 42. That check lives in business logic, so it gets forgotten one endpoint at a time, usually on the endpoint added in a hurry.
Scope the query, not the response
This passes code review more often than it should:
// Any logged-in user can read any invoice.
app.get("/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
if (!invoice) return res.status(404).end();
res.json(invoice);
});The fix puts the tenant into the query itself and selects only the fields the client needs:
app.get("/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, orgId: req.auth.orgId },
select: { id: true, number: true, amountCents: true, status: true, dueDate: true },
});
// Same answer for "does not exist" and "not yours", so there is no enumeration oracle.
if (!invoice) return res.status(404).json({ title: "Not Found", status: 404 });
res.json(invoice);
});Fetch-then-compare (if (invoice.orgId !== req.auth.orgId)) also works, but it is one more line to forget. When the scope is part of the query, the safe path is the only path.
Random UUIDs make enumeration harder, but they are not authorization. IDs leak through URLs, logs and screenshots. Treat unguessable IDs as defense in depth, never as the control.
Make the database enforce tenancy
For multi-tenant systems on PostgreSQL, row-level security turns a forgotten WHERE clause into an empty result instead of a data leak:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- binds the table owner too
CREATE POLICY tenant_isolation ON invoices
USING (org_id = current_setting('app.org_id', true)::uuid)
WITH CHECK (org_id = current_setting('app.org_id', true)::uuid);Set the tenant per request, inside the transaction:
BEGIN;
SELECT set_config('app.org_id', $1, true); -- true means local to this transaction
SELECT id, number, amount_cents FROM invoices WHERE id = $2;
COMMIT;Three details matter here. The third argument to set_config scopes the value to the transaction, which is what you want behind PgBouncer in transaction pooling mode; a session-level SET would leak one tenant's context to the next request that borrows the connection. The true in current_setting returns NULL when the value is missing, so the policy matches nothing and fails closed. And superusers or roles with BYPASSRLS skip policies entirely, so the application must never connect as one.
Prove it with a two-user test
The single most valuable API security test is cheap to write: create an object as one tenant, read it as another, expect 404.
import { test } from "node:test";
import assert from "node:assert/strict";
import { login } from "./helpers.js"; // returns an access token for a seeded test user
const API = process.env.API_URL ?? "http://localhost:3000";
test("tenant B cannot read tenant A's invoice", async () => {
const a = await login("alice@tenant-a.test");
const b = await login("bob@tenant-b.test");
const created = await fetch(`${API}/invoices`, {
method: "POST",
headers: { authorization: `Bearer ${a}`, "content-type": "application/json" },
body: JSON.stringify({ amountCents: 1200, dueDate: "2026-12-01" }),
}).then((r) => r.json());
const res = await fetch(`${API}/invoices/${created.id}`, {
headers: { authorization: `Bearer ${b}` },
});
assert.equal(res.status, 404);
});Once one exists, generate the rest from your OpenAPI spec: every route with an ID in the path gets a cross-tenant read, update and delete test. Scanners are poor at finding BOLA because they do not know which objects belong to whom. Your test suite does.
Function-level authorization: deny by default
Function-level bugs happen when an admin route is protected by obscurity or by a middleware someone forgot to attach. The structural fix is a route table where every entry must declare a policy, and startup fails if one does not:
type Policy = "public" | "authenticated" | { role: "admin" | "billing" };
const routes: { method: "get" | "post" | "patch" | "delete"; path: string; policy: Policy; handler: Handler }[] = [
{ method: "get", path: "/health", policy: "public", handler: health },
{ method: "get", path: "/invoices/:id", policy: "authenticated", handler: getInvoice },
{ method: "delete", path: "/users/:id", policy: { role: "admin" }, handler: deleteUser },
];
for (const r of routes) {
if (!r.policy) throw new Error(`Route ${r.method} ${r.path} has no policy`);
app[r.method](r.path, enforce(r.policy), r.handler);
}Most frameworks have an equivalent: a global guard in NestJS, router-level dependencies in FastAPI, anyRequest().authenticated() in Spring Security. Pick the mechanism that makes "public" an explicit choice somebody typed, never the result of forgetting something. Where you can, also serve admin APIs from a separate, internal-only ingress so they are not reachable from the internet at all.
Authentication: validate the whole token
Do not build your own authentication protocol. Use OAuth 2.0 and OpenID Connect through an identity provider (Auth0, Okta, Entra ID, Cognito, Keycloak or similar). RFC 9700, the OAuth 2.0 Security Best Current Practice published in January 2025, recommends the authorization code flow with PKCE for every client type and tells you to stop using the implicit and password grants.
JWT validation that actually validates
With the jose library in Node.js, a complete verification looks like this:
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com/",
audience: "https://api.example.com",
algorithms: ["RS256"], // pinned: never trust the alg in the token header
clockTolerance: 30, // seconds of skew allowed between hosts
maxTokenAge: "15m",
});
return payload;
}Each option exists because of a real attack class, which RFC 8725 (JWT Best Current Practices) documents:
| Check | What it prevents |
|---|---|
| Signature with a pinned algorithm list | alg: none tokens and HS256 versus RS256 key confusion |
iss (issuer) | Tokens minted by a different IdP or tenant |
aud (audience) | A token issued for service A replayed against service B |
exp, nbf, max age | Stolen tokens that stay useful indefinitely |
| Keys only from your own JWKS | Attacker-supplied keys through jku, x5u or embedded jwk headers |
The audience check is the one teams skip most. In a microservice estate, every internal service trusts the same IdP. Without aud, a token meant for the low-risk notifications service works against the billing API.
JWT or opaque tokens
| Self-contained JWT | Opaque token with introspection | |
|---|---|---|
| Validation | Local, no network call | Call to the auth server (RFC 7662), usually cached |
| Revocation | Waits for expiry unless you keep a denylist | Immediate |
| Readable if leaked | Yes, claims are only base64url encoded | No |
| Good fit | First-party apps, high internal request volume | Third-party clients, high-risk scopes |
For most first-party products the practical answer is short-lived JWT access tokens plus refresh token rotation, so a stolen refresh token is detected the moment both parties use it. For payment and admin APIs, consider sender-constrained tokens with DPoP (RFC 9449) or mutual TLS (RFC 8705), which make a stolen bearer token useless without the client's private key.
API keys are credentials, so treat them like passwords
API keys identify a calling application, not a person. They are fine for server-to-server integrations if you handle them properly:
import { randomBytes, createHash } from "node:crypto";
export function newApiKey() {
const key = `acme_live_${randomBytes(32).toString("base64url")}`; // 256 bits of randomness
const hash = createHash("sha256").update(key).digest("hex");
return { key, hash, prefix: key.slice(0, 14) }; // show the key once, store only hash and prefix
}A fast hash is correct here. Argon2 and bcrypt exist to protect low-entropy human passwords; a 256-bit random key cannot be brute forced, and an indexed SHA-256 lookup keeps authentication fast. The prefix lets secret scanners and your log redaction spot leaked keys. Scope each key, allow two active keys per client so rotation needs no downtime, set an expiry, and accept keys only in a header, never a query string.
Input validation: reject what you did not ask for
Mass assignment (API3) is what happens when a handler passes the request body straight to the ORM. The client adds "role": "admin" or "orgId": "..." and the database obliges. Validate against a strict schema that rejects unknown keys:
import { z } from "zod";
const UpdateProfile = z
.object({
displayName: z.string().trim().min(1).max(80),
timezone: z.string().max(64),
marketingOptIn: z.boolean(),
})
.partial()
.strict(); // "role" or "orgId" in the body fails with 400 instead of being ignored
app.patch("/me", requireAuth, async (req, res) => {
const parsed = UpdateProfile.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ title: "Invalid body", issues: parsed.error.issues });
await db.user.update({ where: { id: req.auth.userId }, data: parsed.data });
res.status(204).end();
});Rejecting is better than silently stripping because the 400 is a signal. A client that sends role to a profile endpoint is either buggy or probing, and you want both in your logs. The trade-off is compatibility: strict schemas break old clients when fields are removed, so version the change.
The rest of the input surface:
- Path and query parameters need the same rigor as bodies. Cap
limiton the server. Allowlistsortandfilterfields: sorting by a hidden column such aspasswordResetTokenleaks its value one comparison at a time. - Parameterized queries, always. ORMs keep raw escape hatches (Prisma's
$queryRawUnsafe, string-builtknex.raw). Grep for them in CI. - Body size and content type. Enforce a body size limit and reject content types you do not handle. Express's JSON parser defaults to
100kb; raise it per route when you need to, never globally. - Output shapes. Never
res.json(user)on an ORM model. Define what each endpoint returns, ideally from the same OpenAPI spec, so adding a column to the table does not add it to the API. - Errors. Return a consistent problem format (RFC 9457) with no stack traces, SQL fragments or internal hostnames.
If you are choosing between API styles, the security trade-offs differ. GraphQL concentrates authorization in resolvers and needs cost limits that REST does not; we compare them in REST vs GraphQL.
Rate limiting and resource consumption
Rate limits do three jobs: slow down credential attacks, keep one client from starving others, and cap the cost of expensive operations. One global per-IP limit does none of them well.
| Layer | Keyed by | Protects against | Where it runs |
|---|---|---|---|
| Edge | IP or IP range | Floods, crude scraping | CDN, WAF or load balancer |
| Identity | User, API key or tenant | Abusive or broken clients | Gateway or app middleware |
| Endpoint | Identity plus route | Login, OTP and password reset brute force | Application |
| Cost | Identity plus estimated cost | Exports, search, GraphQL queries | Application |
A login endpoint limit at the proxy, in nginx:
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
limit_req_status 429;
server {
location = /api/auth/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://api;
}
}Per-IP limits alone will not stop credential stuffing, which spreads attempts across many addresses. Add limits keyed on the target account (failed attempts per username) and respond with progressive delays or a step-up challenge rather than hard lockouts, which let an attacker lock your users out on purpose.
Return 429 Too Many Requests (defined in RFC 6585) with a Retry-After header so well-behaved clients back off instead of retrying in a tight loop.
GraphQL needs cost limits, not request limits. One HTTP request can carry hundreds of aliased login mutations, and a per-request limiter counts it once. Enforce maximum depth, maximum aliases and a complexity budget per operation. For first-party clients, persisted queries (an allowlist of operations the server will execute) are the strongest control and make introspection settings mostly irrelevant.
Bound everything that can grow. Cap page sizes server-side, prefer cursor pagination, set request timeouts, and set a database statement_timeout so one pathological query cannot hold a connection forever.
SSRF: every field that accepts a URL
Webhooks, "import from URL", avatar URLs, PDF renderers and link previews all make your server fetch something a user chose. If that something is http://169.254.169.254/ on a cloud VM, the attacker may walk away with instance credentials. We took apart a real case in LMDeploy CVE-2026-33626, where an image URL field became an SSRF endpoint.
Check the resolved address, not the string:
import { lookup } from "node:dns/promises";
import { BlockList } from "node:net";
const blocked = new BlockList();
for (const [net, prefix] of [
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.168.0.0", 16],
] as const) blocked.addSubnet(net, prefix, "ipv4");
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");
blocked.addAddress("::1", "ipv6");
export async function resolveSafeTarget(raw: string) {
const url = new URL(raw);
if (url.protocol !== "https:") throw new Error("https only");
const addrs = await lookup(url.hostname, { all: true });
for (const a of addrs) {
if (blocked.check(a.address, a.family === 6 ? "ipv6" : "ipv4")) throw new Error("blocked destination");
}
// Connect to this address with the original Host header, and do not follow redirects.
return { url, address: addrs[0].address };
}Connecting to the address you checked, instead of resolving the hostname again, defeats DNS rebinding. Refusing redirects (or re-checking every hop) stops an allowed host from bouncing you to an internal one. Also handle IPv4-mapped IPv6 addresses such as ::ffff:127.0.0.1. Then add a network control so code bugs are not the only barrier: run fetchers in a subnet with no route to internal services, and on AWS require IMDSv2, whose session token comes from a PUT request that a simple GET-based SSRF cannot make.
Logging and detection
Log security events as structured data with a request or trace ID, and decide up front what each one should trigger.
| Event | Record | Alert on |
|---|---|---|
| Authentication failure | Account or key ID attempted, IP, user agent, reason | Spikes per account or per network range |
| Not found on ID routes | Caller, route, ID | One caller hitting many distinct IDs (enumeration) |
| Validation rejected | Route and field names, never values | Unknown-field rejections, which usually mean probing |
| Rate limit hit | Key, route | Sustained hits from one identity |
| Key or role change | Who, what, from where | Every occurrence, to an append-only audit log |
| Response volume | Bytes and rows per identity per route | Large deviations from that identity's normal |
The second row is worth dwelling on. With scoped queries, a BOLA attempt does not look like an authorization failure; it looks like a stream of 404s from one authenticated user. That pattern is cheap to alert on and rarely benign.
Never log Authorization headers, cookies, API keys or bodies of authentication routes. Redact at the logger, not by convention. In pino, for example, redact: ["req.headers.authorization", "req.headers.cookie"] does it centrally.
Inventory and lifecycle
You cannot protect endpoints you do not know about. Keep the OpenAPI spec as the source of truth, generate validation from it, and diff it against the routes your gateway actually sees. Anything in traffic but not in the spec is a shadow endpoint and goes to the top of the list.
Retire old versions on a schedule and announce it with the Sunset header (RFC 8594). In CI, run a spec-driven dynamic scan against staging on every merge:
docker run --rm -v "$PWD:/zap/wrk" ghcr.io/zaproxy/zaproxy:stable \
zap-api-scan.py -t https://staging.example.com/openapi.json -f openapi -r zap-report.htmlScanners catch misconfiguration and injection, not business logic, which is why the two-user tests and a periodic manual test matter. See our DevSecOps pipeline guide for where each scan belongs and the penetration testing methodology for what a manual API test covers.
What goes wrong in production
These are the failures that show up after launch, usually at an inconvenient hour.
- The new endpoint nobody wired a policy on. A route added during an incident fix ships without authorization. Deny-by-default routing makes this a failed build instead of a breach.
- The limiter sees one IP. Behind a load balancer, every request appears to come from the balancer, so the per-IP limit throttles everyone at once. The opposite mistake is worse: Express's
trust proxyset totruetrusts whateverX-Forwarded-Forthe client sends, so an attacker rotates their apparent IP per request. Trust exactly the number of proxy hops you run. - The limiter's store goes down. When Redis is unavailable, the limiter either blocks all traffic or allows all of it. Decide per route before it happens: login should fail closed with an in-process fallback, ordinary reads can fail open.
- Signing key rotation breaks every service. The IdP publishes a new key, and services with a hand-rolled JWKS cache reject every new token until someone restarts them. Use a client that refetches on an unknown
kidwith a cooldown, and publish new keys before signing with them. - Clock drift rejects fresh tokens. A container with a drifting clock fails
nbfandiatchecks intermittently. Run time sync on every host and allow a small tolerance. - Tokens in URLs. WebSocket handshakes and download links are the usual culprits, and the token lands in access logs. Use short-lived, single-purpose signed URLs.
- CORS that reflects any origin with credentials. Browsers refuse
*with credentials, so someone "fixes" it by echoing the request'sOrigin. That lets any website make authenticated calls as your users. Use an explicit allowlist.
Frequently Asked Questions
What are the most important API security best practices?
Enforce object-level authorization on every query, validate tokens fully (signature, issuer, audience, expiry), reject unexpected input fields, and rate limit by identity and by cost. Add deny-by-default routing so a new endpoint cannot ship without an access policy, and keep an accurate API inventory. Those controls cover the most common causes of API breaches listed in the OWASP API Security Top 10.
What is BOLA in API security?
Broken Object Level Authorization (BOLA) is when an API reads or changes an object without checking that the caller may access that specific object, for example a request for invoice 123 returning another customer's invoice. It is ranked first in the OWASP API Security Top 10. The fix is to scope every database query by the caller's user or tenant ID and to prove it with an automated test that uses two accounts.
Should an API return 403 or 404 for a resource the user cannot access?
For object-level access, return 404 when the caller is not allowed to see the object, the same response as for an object that does not exist. A 403 confirms that the object exists, which helps an attacker enumerate valid IDs. Use 403 for function-level denials where the endpoint itself is not secret, such as a regular user calling an admin action.
Are API keys secure enough for authentication?
API keys identify a calling application, not a user, so they suit server-to-server integrations but should not be the only control protecting user data. Treat them like passwords: generate them with a cryptographically secure random generator, store only a hash, scope them, send them in a header and support rotation with two active keys. For user-facing access, use OAuth 2.0 with short-lived tokens.
Are JWTs safe for API authentication?
Yes, if every service validates the signature against a pinned algorithm list and checks the issuer, audience and expiry claims. The known weaknesses are revocation, because a JWT stays valid until it expires, and algorithm confusion when a library trusts the token's own header. Keep access tokens short-lived, rotate refresh tokens, and use opaque tokens with introspection where immediate revocation matters.
How should I rate limit an API?
Layer the limits: per IP at the edge, per user or API key in the application, stricter per-route limits on login and password reset, and cost-based limits on expensive operations such as exports and GraphQL queries. Return 429 with a Retry-After header. Behind a load balancer, make sure the limiter sees the real client IP and does not trust forwarding headers the client can set.
Need help securing your APIs?
We build APIs and then run them, so we review them the way an attacker and an on-call engineer would: authorization on every route, token handling, limits, and the logs you will want when something looks wrong. Senior engineers do the work, and if we built it, we're on the pager for it.