The API security best practices that matter most follow the OWASP API Security Top 10 2023: check that the caller owns every object it asks for, validate tokens properly, return only the properties the caller may see, limit how much work one client can trigger, and check permissions per function, not only per login. Some of these controls are visible in an OpenAPI spec, through securitySchemes, per-operation scopes and 401 and 403 responses. The most common failures, object and property authorization, live in your handler code, so a spec can document them but never prove them. The table below maps each risk to its control and shows where, if anywhere, the spec reflects it.
The OWASP API Top 10 2023, risk by risk
| OWASP 2023 risk | Main control | Visible in OpenAPI? |
|---|---|---|
| API1 Broken Object Level Authorization | Check ownership of every object ID in the data query | Partly: 403 or 404 responses, uuid IDs |
| API2 Broken Authentication | Strict token validation, no credentials in URLs | Yes: securitySchemes and security |
| API3 Broken Object Property Level Authorization | Explicit response schemas, readOnly fields, no mass assignment | Yes: schemas, readOnly, additionalProperties |
| API4 Unrestricted Resource Consumption | Rate limits, page size caps, upload limits, timeouts | Partly: 429 responses, maximum on limit parameters |
| API5 Broken Function Level Authorization | Per-operation permissions, deny by default | Yes: scopes per operation |
| API6 Unrestricted Access to Sensitive Business Flows | Per-flow limits and abuse detection on purchases, sign-ups, messages | No |
| API7 Server Side Request Forgery | Allowlist outbound hosts, block private ranges | Partly: format: uri on URL inputs |
| API8 Security Misconfiguration | HTTPS only, tight CORS, generic error bodies | Partly: https server URLs |
| API9 Improper Inventory Management | Every deployed host and version in the spec, old ones retired | Yes: servers and paths are the inventory |
| API10 Unsafe Consumption of APIs | Validate third-party responses like user input | No, unless you keep specs for the APIs you call |
Declare authentication with OpenAPI securitySchemes
An undeclared auth scheme is a security problem in its own right: client generators cannot add the header, testers guess, and reviewers cannot see which operations are public. OpenAPI 3.x defines five scheme types under components.securitySchemes: apiKey (with name and in, where in is query, header or cookie), http (with scheme such as bearer or basic, and an optional bearerFormat), oauth2 (with flows and scopes), openIdConnect (with openIdConnectUrl), and, from OpenAPI 3.1, mutualTLS.
The security requirement rules are easy to get wrong. A security array lists alternatives, and the client needs to satisfy only one of them. Several schemes inside one requirement object must all be satisfied. An empty object {} in the array makes auth optional. A security array on an operation overrides the top-level one, and an empty array security: [] removes auth for that operation. That last rule is how a health check becomes public, and also how an endpoint becomes public by accident, so search every spec change for security: [] during review.
openapi: 3.0.3
info:
title: Invoices API
version: 1.0.0
servers:
- url: https://api.example.com
security:
- OAuth2:
- invoices:read
paths:
/v1/health:
get:
summary: Liveness check
security: []
responses:
"200":
description: Service is up
/v1/invoices/{id}:
get:
summary: Get one invoice owned by the caller
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: The invoice
"401":
description: Missing or invalid access token
"403":
description: Token lacks the invoices:read scope
"404":
description: No invoice with this id belongs to the caller
/v1/invoices/{id}/refunds:
post:
summary: Refund an invoice
security:
- OAuth2:
- invoices:write
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"201":
description: Refund created
"401":
description: Missing or invalid access token
"403":
description: Token lacks the invoices:write scope
components:
securitySchemes:
OAuth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://auth.example.com/authorize
tokenUrl: https://auth.example.com/token
scopes:
invoices:read: Read invoices
invoices:write: Create refundsThree details in this spec carry the security design. The refund operation asks for its own scope, so a read-only token cannot move money (API5). Every protected operation documents 401 and 403, which tells client authors which failure means "log in again" and which means "you are not allowed". The ID is a uuid, which OWASP recommends for record IDs because sequential numbers are easy to guess, though an unguessable ID never replaces an ownership check. For a minimal bearer-token version of the same pattern, see the OpenAPI authentication example.
API1: check ownership in the query, not after it
Broken object level authorization is first on the OWASP list because every endpoint that takes an ID from the client is a candidate. One of OWASP's scenarios is a store platform where /shops/{shopName}/revenue_data.json returned any shop's revenue to any logged-in user once the attacker learned the URL pattern. The fix is to make ownership part of the lookup itself, so a missing check cannot slip in later.
AvoidAuthenticated, but not authorized
app.get("/v1/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
if (!invoice) return res.status(404).end();
res.json(invoice);
});Any valid token can read any invoice by changing the ID.
PreferOwnership is part of the lookup
app.get("/v1/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoices.findOne({
id: req.params.id,
tenantId: req.auth.tenantId,
});
if (!invoice) return res.status(404).end();
res.json(toPublicInvoice(invoice));
});Another tenant's invoice returns the same 404 as a missing one, so the response does not confirm that the ID exists. toPublicInvoice picks the fields this caller may see (API3).
Returning 404 rather than 403 for someone else's object is a design choice: it hides whether the ID exists. Document it in the spec, as the 404 description above does, so client developers do not file it as a bug.
API2: authentication and token handling
- Send tokens in the Authorization header. RFC 6750 says bearer tokens should not be passed in page URLs, because browsers, servers and proxies do not protect URLs in history and logs. The same reasoning applies to an apiKey scheme with in: query.
- Validate every token on every request: signature with an allowlist of algorithms, issuer, audience, and expiry. Fetch signing keys from the issuer rather than hard-coding them, so key rotation does not need a deploy.
- Use the RFC 6750 error codes: invalid_token with 401 for expired or malformed tokens, insufficient_scope with 403 when the token is valid but lacks a scope. RFC 9110 requires a WWW-Authenticate header on every 401.
- Rate-limit login, token and password-reset endpoints separately and more strictly than the rest of the API.
API3 and API5: properties and functions
API3 covers two mirror-image bugs. Excessive data exposure is a response that includes fields the caller should not see, usually because a handler serializes the whole database row. Mass assignment is a request that sets fields the caller should not control, such as role or balance, because the handler copies the whole body into the model. In the spec, give responses explicit properties, mark server-owned fields readOnly so they are not accepted on writes, and use separate request and response schemas when they differ. Then test that the server keeps the promise: validating responses against the schema with additionalProperties set to false catches a leaked field the first time it appears.
API5 is the same idea one level up: a regular user calling an admin function. Give each operation the narrowest OAuth scope it needs, as the refund operation does, deny by default in the gateway or middleware, and do not rely on admin routes being unlisted. Listing them in an internal spec is better than hiding them, because an unlisted route is also an unreviewed one.
API4 and API6: limit the work one client can cause
OWASP lists the limits to set for API4: execution timeouts, memory, file descriptors, processes, upload size, operations per request, records per page, and spending limits on paid third-party services. Several of these belong in the spec: a maximum on page size parameters, a maxLength on strings, and a documented 429 response. The API rate limiting guide covers algorithms and the headers that tell clients their quota. API6 needs limits per business flow rather than per request, such as how many accounts one device can create in an hour. Those rules depend on your product, and no spec field expresses them.
API7 to API10: outbound calls, configuration, inventory
- API7 SSRF: when the API fetches a URL the client supplies, such as a webhook target or an image import, allowlist hosts and schemes, resolve the address, and reject private and loopback ranges before connecting.
- API8 misconfiguration: list only https server URLs, set CORS to known origins, and keep stack traces out of error bodies.
- API9 inventory: treat the spec as the list of what is deployed. Every host in servers and every version in paths should match production, and a retired version should be removed from both.
- API10 unsafe consumption: validate responses from third-party APIs against their schema before you use them, with the same care as user input.
What tools can and cannot check
A design score is the wrong instrument for security. The spec above scores 95.6 on APIForge's deterministic score, losing points for a health check with no error response and a 4-segment refund path. We then deleted every security block and the securitySchemes component, and the score stayed at 95.6, because none of its seven categories reads auth. Use the OpenAPI validator to catch structural errors in the spec, and review the security sections by hand.
For runtime checks, the API testing workbench reads the operation's security requirement, fills in the API key name and location from the matching scheme, and sends requests with bearer, basic or API key auth. That makes a manual BOLA test quick: sign in as user A, request an invoice ID that belongs to user B, and confirm the API returns 404. Repeat with a token that lacks invoices:write against the refund operation and confirm 403. If Swagger UI sends no token after you click Authorize, Swagger UI Authorize not sending the bearer token shows nine spec setups and the header each one produced.