Skip to content
web-securityintermediate#api-security#appsec#authorization#owasp#api-gateway

API Security Best Practices: Building the Defensive Program

How to secure an API end to end: inventory of shadow and zombie endpoints, token validation, where authorization decisions live, schema enforcement, rate-limit budgets, gateway placement, and the logs that catch what you missed.

An API security program is a small number of controls applied uniformly across every endpoint you expose. The failures are rarely exotic. They are one route that skipped the ownership check, one version that was deprecated in a document and never switched off, one field that arrives as an object where the code expected a string.

This guide covers the defensive side: how to build the inventory, where to put authentication and authorization, what a request schema buys you, how to size a rate limit from real numbers, which controls belong at the gateway, and what to log so a missed check is visible. The attack side lives elsewhere in the corpus. Object and function level authorization abuse is covered in API Abuse Explained, token forgery and confusion in JWT Attacks Explained, flow and redirect abuse in OAuth Attacks Explained, browser-origin policy in CORS Explained, schema-level query abuse in GraphQL Attacks Explained, and limiter algorithms, keys and 429 responses in API Rate Limiting Explained. This page assumes you have read or will read those and asks a different question: what does the defending team actually build.

Start with the inventory, because everything else is scoped to it

Every control below applies to a list of endpoints. If the list is wrong, the coverage is wrong, and the gap is invisible in every report you generate. Three kinds of endpoint go missing.

Shadow endpoints serve production traffic and appear in no specification. They arrive from a hotfix that shipped a debug route, a mobile client that calls something the web client does not, a partner integration built under deadline, or a framework that auto-generates routes nobody declared.

Zombie endpoints are older versions still deployed and still answering. They predate your current authorization helpers, your current validation middleware, and your current logging. An attacker who finds /api/v1/users/{id} after you hardened /api/v3 gets the pre-hardening behaviour.

Third-party and internal endpoints are the ones you did not write. A vendor SDK mounted at a path in your domain, an admin console on the same ingress, a metrics endpoint that leaks configuration.

The reliable discovery method is passive reconciliation rather than active scanning. Your gateway, load balancer, or service mesh already records every request that arrived. Aggregate a period of those logs into path templates, then diff against the paths your OpenAPI specification declares.

A worked reconciliation

The method is a 30-day window of gateway access logs, with numeric and UUID segments normalized to a placeholder and distinct templates counted. The figures below are a constructed illustration of the shape that diff takes on a mid-sized product API, not measurements from a named system.

SourceDistinct path templates
Observed in gateway logs, 30 days412
Declared in the OpenAPI 3.1 specification261
Raw difference151
Of which: method or trailing-slash variants of a declared path96
Genuinely undeclared surface55

Now classify the 55. In this illustration, 31 belong to a v1 that was documented as deprecated two years ago and still serves 4,200 requests a day, mostly from one mobile build that was never forced to upgrade. Eighteen are internal administrative routes that a shared ingress made publicly resolvable, and they were only ever protected by the assumption that nobody outside the office would find them. Six are debug or health routes, of which two return build metadata including dependency versions and a hostname.

That table is the whole program in miniature. The 31 zombie routes need a sunset with an enforced date, the one the failure modes below describe. The 18 internal routes need to come off the public ingress and get their own authentication path. The 6 debug routes need to be removed or authenticated. None of that work is hard. Not knowing they existed is what made them dangerous, and the same reconnaissance techniques described in the OSINT reconnaissance guide, certificate transparency logs, JavaScript bundle analysis, mobile app decompilation, are what an outsider uses to build the same list from outside your perimeter.

Make the diff a build step

Run the reconciliation on a schedule and fail loudly when the undeclared count grows. A weekly job that diffs observed templates against the committed specification turns inventory from a project into a metric. Teams that do this once during an audit are back to an unknown surface within a quarter.

Authentication: prove the caller, at the edge

Authentication answers one question per request and should be answered once, consistently, before the request reaches business logic. Pick the credential type by caller.

CallerCredentialNotes
Browser front endShort-lived access token from an OIDC provider, refresh handled by the auth layerCookie storage with HttpOnly, Secure, SameSite where the token rides a cookie
Mobile appOAuth 2.0 authorization code with PKCE, short-lived access tokenNever embed a high-privilege key in a binary the user controls
Partner serverClient credentials grant, or mutual TLS client certificateAudience-restricted token naming your service
Internal serviceMutual TLS plus a short-lived, audience-restricted tokenIdentity per workload, not per cluster
Automation and scriptsScoped API key with an expiry and a named ownerRotate on schedule and immediately on exposure

The flow mechanics live in OAuth 2.0 Explained and the identity-protocol comparison in SAML vs OAuth vs OIDC. What matters for the defensive build is the validation, because a token you accept without checking is a token an attacker can supply.

The token validation checklist, with values

Validating a JWT access token means running an explicit list of checks and failing closed on any of them. Written out with real values for a service called orders-api:

1. Signature verifies against a key from the issuer's JWKS
   jwks_uri:  https://login.example.com/.well-known/jwks.json
   cache TTL: 900 seconds, refresh on unknown kid, rate-limited to 1 refresh / 60s
2. alg is in the allowlist {RS256, ES256}     -> reject "none"; this issuer signs
                                                 asymmetrically, so reject HS*
3. kid names a key currently published in the JWKS
4. iss == "https://login.example.com/"        -> exact string match, trailing slash included
5. aud contains "https://api.example.com/orders"
6. exp > now                                  -> clock skew tolerance 60s, no more
7. nbf <= now + 60s
8. iat is not further in the past than the max token lifetime you issue (900s)
9. scope contains "orders:read" for GET, "orders:write" for POST/PATCH/DELETE
10. sub is present and maps to an active principal

Two lines carry most of the weight. Line 2 pins the algorithm to what you expect, which closes the algorithm-confusion family described in JWT Attacks Explained and mandated by RFC 8725. Line 5 pins the audience, so a token minted for the marketing API cannot be replayed against the orders API. A surprising number of production services verify the signature and the expiry and skip both.

For mutual TLS, the equivalent list is: the client certificate chains to your private CA, the certificate is not on the current revocation list, the subject or SAN maps to a known workload identity, and the certificate lifetime is short enough that revocation lag is bounded. The mechanics are in What Is mTLS, and machine-to-machine traffic is where it fits best because there is no human enrollment cost.

Authorization: centralize the policy, decide inside the service

Every check in the previous section establishes who is calling and which broad scopes they hold. Whether this caller may read order 1025 is a separate decision, and it is where the expensive breaches happen. The design question is where that decision lives. Object-level and function-level authorization failures as attacks, along with excessive data exposure, are covered in API Abuse Explained.

Three placements, with the tradeoff each one buys:

In the handler. Each endpoint writes its own check. Simple to start, impossible to audit across a surface of 400 route templates, and the failure mode is silent: a new endpoint ships without a check and nothing complains.

In a shared middleware or decorator. Every route declares the permission it requires and the framework refuses to serve a route that declares nothing. This is the biggest single improvement available to most codebases, because it converts "hope every handler checks" into "the router will not start with an undeclared route". It handles function-level authorization well. Object-level authorization stays out of its reach, because the middleware runs before the object is loaded.

In a policy engine with a service-side data fetch. The policy is written once, in a declarative language, and evaluated with the attributes the service supplies: the subject, the action, the resource, the resource owner, the tenant. The policy is auditable in one place and the decision still happens where the ownership data is.

The practical shape for most teams is the second and third together. Declare the required permission on every route so nothing ships unguarded, then perform the object-level check immediately after loading the object and before returning it:

order = repo.load(order_id)              # load first
authorize(subject, "orders:read", order) # then decide, using order.tenant_id and order.owner_id
return serialize(order, fields_for(subject.role))

Note the third line. Field-level shaping belongs in the same place, because the role that may read an order is often not the role that may read the card fingerprint on it. Serializing the whole record and trusting the client is the read side of the property-level failure, and binding the whole request body onto the record is the write side, covered in Mass Assignment Explained. Bind an explicit list of writable fields per role and the class closes.

Scope the permissions themselves with least privilege: orders:read for one tenant is a far smaller prize than a token that can call every endpoint. Tenant isolation deserves its own line in the policy, evaluated on every query, because a cross-tenant read is the finding that turns a single-customer incident into a regulatory one.

Schema validation on the way in and on the way out

A request schema is the cheapest control in this guide. Declaring that quantity is an integer between 1 and 500 and that email is a string of at most 254 characters costs one line each and closes a long tail of bugs before any handler runs.

Enforce four things at the edge:

  1. Types. The single most valuable check, because it stops a structure arriving where a scalar belongs. A field declared as a string can never carry the operator object that drives NoSQL injection.
  2. Bounds. Maximum body size, maximum array length, maximum string length, maximum nesting depth. Depth limits matter for JSON and matter more for XML, where an unbounded parser is the XXE injection surface.
  3. Unknown fields. Reject them by default. Silently ignoring an unrecognized field is how a client discovers that is_admin is accepted somewhere down the stack.
  4. Content type. Accept the one type the endpoint parses. An endpoint that will happily parse XML because a library supports it has an attack surface its authors never considered, and the same reasoning applies to any endpoint that deserializes objects, as insecure deserialization sets out.

Response schemas are worth the extra effort on any endpoint that returns user data, because they catch the field you added to the database model in a hurry and never meant to publish. A contract test that fails when a response contains a field the schema does not declare is a leak detector.

The field-level rules, allowlists, canonicalization order, and where encoding belongs, are covered in the input validation guide. Do not duplicate that logic per handler; put the schema at the boundary and let each handler assume the shape.

Rate limits and quotas as a budget

A rate limit is a budget for one endpoint, sized from the traffic that endpoint actually sees. Pull the per-client distribution, set the ceiling above the 99th percentile of legitimate use with headroom, and give the one integration partner sitting far above that line a documented higher tier rather than lifting the ceiling for everyone. The unit matters as much as the number: requests for cheap reads, records returned for list endpoints, since one request with limit=1000 costs what a thousand requests cost, query complexity for GraphQL, and a daily quota for expensive operations such as report generation or bulk export. Limiter algorithms, the key you count against, the rollout sequence and the response headers are covered in API Rate Limiting Explained.

A rate limit bounds the damage from a missing ownership check without replacing it, because the first request still leaks the record it reaches. Treat limits as the control that turns a leak into a slow leak and buys the time to find it.

Where each control belongs

Gateways are useful because they make a control uniform across every service behind them. They are misleading when a team assumes the gateway is handling something it structurally cannot.

ControlGatewayServiceWhy
TLS termination and cipher policyYesInternal hop tooOne place to enforce TLS 1.2 minimum and HSTS
Token signature, issuer, audience, expiryYesRe-verifyCheap to verify twice, and the service may be reachable directly
Route-level access (which client may reach which path)YesNoNeeds no application data
Object-level authorizationNoYesOnly the service knows who owns the record
Field-level read and write shapingNoYesDepends on role and on the loaded object
Request schema and size capsYesYesGateway for coarse caps, service for business rules
Rate limiting and quotasYesYes for cost-basedGateway sees all traffic, service knows query cost
CORS policyYesConsistentOne allowlist beats per-service drift, see CORS Explained
Security headersYesYesReference list in HTTP Security Headers
Audit logging of decisionsPartialYesThe gateway logs the status; the service knows the decision

Two structural rules go with that table. First, make the gateway the only path in. A service reachable on its pod IP or an internal load balancer that a compromised neighbour can call has all of the gateway's controls bypassed, which is the point network segmentation makes for API infrastructure. Second, keep internal APIs off any ingress that resolves publicly, since a server-side request forgery bug in one public service turns your internal API into a public one, as SSRF Explained shows in detail.

Log the authorization decision

Most API logs record method, path, status, and latency. That set cannot tell you about the request that succeeded and should not have, because a wrongly-permitted read returns 200 exactly like a correct one.

Add four fields to every request log line:

  • Subject identity, the sub claim or workload identity, never the raw token.
  • Resource identifier, the object the request touched.
  • Tenant or owner of that resource, so a cross-tenant access is a comparison rather than an investigation.
  • Authorization outcome and the rule that produced it, so a policy change can be traced to the behaviour it altered.

With those, three detections become straightforward: a subject whose tenant differs from the resource tenant on a 200 response, a subject sweeping ascending resource identifiers, and a subject reaching an endpoint whose required permission it has never held before. Feed them into the process described in the threat hunting guide rather than leaving them as dashboard panels nobody opens.

Keep secrets out of the logs. Tokens, keys, Authorization headers, and request bodies on auth endpoints belong in a redaction list that is tested, because a log aggregator is usually readable by more people than the database is.

The failure modes

Deprecation without a shutdown date. A version marked deprecated in the documentation and still answering requests is a live version with a stale codebase. Give every deprecation a date, a Sunset header, a traffic countdown you watch, and a final switch-off. The 31 zombie routes in the reconciliation above existed because step four never happened.

Documentation as the inventory. The specification records what the team meant to ship. Traffic records what it shipped. Reconcile them or the inventory is fiction.

Authorization tested with one account. A test suite that logs in as one user and asserts 200 will pass on an API with no ownership checks at all. Every authorization test needs a second account and an assertion that user B receives 403 on user A's object.

Keys with no owner and no expiry. An API key issued in 2022 to an integration that was decommissioned in 2023 is a valid credential nobody is watching. Every key needs a named owner, an expiry, and a scope narrower than "everything".

Trusting the network. Internal, east-west, behind-the-VPN and same-cluster all describe where a request came from. Authorization is a separate decision that still has to be made on each request. NIST SP 800-207 makes the same point at architecture level: verify each request rather than the network position it arrived from.

Assuming the gateway did it. Read the gateway configuration and confirm which controls are enabled on each route, rather than which controls the product supports.

The verdict

If you do four things and nothing else, do these. Build a real inventory by reconciling traffic against your specification, and run it on a schedule. Validate tokens against the full checklist, with the audience and algorithm pinned. Declare a required permission on every route so nothing ships unguarded, and make the object-level check happen next to the object load. Put a schema on every endpoint.

Those four cover the surface that the OWASP API Security Top 10 keeps at the top edition after edition, and they scale: each one is a property you can assert about every endpoint on the observed surface rather than a review you perform on one. The remaining work, rate-limit budgets, gateway placement, decision logging and deprecation with teeth, is what keeps the four honest as the surface grows.

Frequently asked questions

How often should the API inventory be refreshed? Continuously if your gateway logs allow it, weekly at minimum. The undeclared-endpoint count is a useful metric to trend, because it rises quietly during fast delivery periods and a threshold alert catches the drift before an audit does.

Is an OpenAPI specification a security control? It becomes one when it is enforced. A specification that only generates documentation is a description. A specification that a gateway or middleware uses to reject undeclared paths, unknown fields, and out-of-range values is a control, and it is the same file doing both jobs.

Do internal APIs need the same controls as public ones? They need authentication, authorization and logging for the same reasons. What changes is the credential type, since mutual TLS and workload identity are practical between services and impractical for consumer browsers, and the rate-limit shape, since internal callers have predictable volumes that make anomaly detection easier.

How do I secure an API that has to serve anonymous traffic? Split the surface. Anonymous endpoints get strict rate limits by IP and by fingerprint, aggressive caching, minimal response fields, and no capability to mutate state. Everything that touches user data sits behind authentication. The mistake to avoid is one handler that serves both cases and decides which to be based on whether a token happened to be present.

What is the difference between an API key and an access token? An API key is a long-lived shared secret that identifies a client and usually carries a fixed scope. An access token is short-lived, issued by an authorization server, carries claims about subject, audience and scope, and retires itself at expiry, so short lifetimes limit the damage even where no revocation mechanism exists. Keys are simpler to issue and worse to leak, so restrict them to server-side automation with a named owner, a scope, and rotation.

Where does WebAuthn fit in API security? At the human authentication step that precedes token issuance rather than on the API calls themselves. A phishing-resistant login, as described in WebAuthn Explained, raises the cost of stealing the session that mints the tokens. The API still validates the token it receives.

Sources & further reading