Security is one part of API design I refuse to treat as an afterthought, and I refuse to leave it undocumented. If a consumer has to email support to figure out how to authenticate against my API, I’ve already failed at design. In this article, I’m going to walk through exactly how I use OpenAPI’s security features to describe authentication and authorization clearly, precisely, and in a way that both humans and tools can act on.
This builds directly on the reusable components I covered in the previous article, since security schemes live in that same components section.
The Two Layers of API Security in OAS
OpenAPI separates security into two layers, and understanding this separation early saves a lot of confusion:
- Security Schemes — the type of authentication mechanism available (API key, OAuth2, bearer token, etc.), defined once in
components.securitySchemes. - Security Requirements — where those schemes are actually required, applied globally or per-operation using the
securityfield.
I always define the scheme first, then decide where it applies.
Security Scheme Types I Use Most
API Key
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
I use API keys most often for simpler, server-to-server integrations — partner APIs, internal microservices, or low-risk public data APIs. The in field can be header, query, or cookie, but I almost always choose header, since API keys in query strings tend to leak into server logs and browser history.
HTTP Bearer (Typically JWT)
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
This is my default for APIs consumed by web or mobile apps where a user logs in and receives a token. The bearerFormat field is purely descriptive — it tells readers what kind of token to expect, even though it’s not enforced by the spec itself.
HTTP Basic
components:
securitySchemes:
BasicAuth:
type: http
scheme: basic
I use this rarely, mostly for internal tooling or quick prototypes, since basic auth sends credentials on every request and needs HTTPS to be even minimally safe. I almost never recommend it for production public APIs.
OAuth2
components:
securitySchemes:
OAuth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://auth.example.com/oauth/authorize
tokenUrl: https://auth.example.com/oauth/token
refreshUrl: https://auth.example.com/oauth/refresh
scopes:
orders:read: Read access to orders
orders:write: Create and update orders
orders:admin: Full administrative access to orders
This is the one I spend the most time getting right, because OAuth2 has four different “flows,” and picking the wrong one for your use case creates real security and usability problems:
authorizationCode— the standard flow for web and mobile apps where a real user logs in through a browser redirect. This is what I use for consumer-facing apps.clientCredentials— for machine-to-machine communication with no human user involved, like a backend service calling another backend service.implicit— largely deprecated now in favor of authorization code with PKCE; I don’t recommend it for new APIs.password— also discouraged, since it requires the client app to directly handle the user’s username and password, which defeats much of the purpose of OAuth2’s delegation model.
components:
securitySchemes:
OAuth2ClientCredentials:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://auth.example.com/oauth/token
scopes:
reports:read: Read access to reporting data
OpenID Connect
components:
securitySchemes:
OidcAuth:
type: openIdConnect
openIdConnectUrl: https://auth.example.com/.well-known/openid-configuration
For APIs built on top of an identity provider that publishes a full OIDC discovery document, this single field lets tools automatically discover the authorization URL, token URL, supported scopes, and more, without me having to redefine all of it manually.
Mutual TLS
components:
securitySchemes:
MutualTLS:
type: mutualTLS
I reach for this in high-security financial or healthcare integrations, where both client and server present certificates to verify each other’s identity. It’s less common, but OAS 3.1 supports describing it explicitly, which I always appreciate when I encounter it in a partner’s API documentation.
Applying Security Requirements
Once schemes are defined, I decide where they apply using the top-level security field (global default) and per-operation security overrides.
Global Security
security:
- BearerAuth: []
This says: “by default, every operation in this API requires a bearer token.” I set this as the sensible default for almost every private or partner API I design.
Per-Operation Overrides
paths:
/health:
get:
summary: Health check
security: []
responses:
'200':
description: Service is healthy
/orders:
post:
summary: Create an order
security:
- OAuth2: [orders:write]
responses:
'201':
description: Order created
Notice two things here. First, /health explicitly overrides with an empty array security: [], meaning “no authentication required” — I always do this explicitly rather than relying on ambiguity, because a reader shouldn’t have to guess whether a public endpoint is intentionally open or accidentally unsecured. Second, /orders requires the orders:write scope specifically, not just any valid token — this is where scope-based authorization gets described precisely.
Multiple Accepted Schemes (OR Logic)
security:
- BearerAuth: []
- ApiKeyAuth: []
Listing multiple entries in the top-level array means “any one of these is acceptable” — a consumer can authenticate with either a bearer token or an API key.
Combined Requirements (AND Logic)
security:
- BearerAuth: []
ApiKeyAuth: []
Putting multiple schemes inside the same array entry means both are required simultaneously — useful for APIs that require, say, both a user token and a separate application-level API key.
Documenting Scopes Meaningfully
I’ve reviewed OAuth2 API specs where every scope description just says “Access to X.” That’s not useful. I always try to describe scopes the way I’d explain them to a new partner developer on a call:
scopes:
orders:read: "View order details, including status and item lists. Does not include customer payment information."
orders:write: "Create new orders and update their status. Does not permit cancellation or refunds."
orders:admin: "Full control over orders, including cancellation, refunds, and viewing payment details. Intended for internal support tooling only."
This kind of clarity prevents partners from over-requesting scopes “just in case,” which is itself a security risk (the principle of least privilege, described from the API design side).
Describing Rate Limiting and Abuse Protection
OpenAPI doesn’t have a dedicated rate-limiting keyword, but I always document expected behavior anyway, usually through response headers and a 429 response defined as a reusable component.
components:
responses:
TooManyRequests:
description: Rate limit exceeded. Retry after the time specified in the Retry-After header.
headers:
Retry-After:
schema:
type: integer
description: Number of seconds to wait before retrying
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
paths:
/orders:
post:
responses:
'429':
$ref: '#/components/responses/TooManyRequests'
Even though rate limiting is enforced outside the spec (usually at a gateway or middleware layer), documenting it inside the OAS document means consumers know to expect and handle it gracefully, instead of being surprised in production.
Describing Sensitive Data Handling
Security in an API isn’t only about who can call an endpoint — it’s also about what data gets exposed. I use a few conventions consistently:
CreditCard:
type: object
properties:
last4:
type: string
description: Last four digits of the card number only
brand:
type: string
fullNumber:
type: string
writeOnly: true
description: >
Full card number, accepted on input only. Never returned
in any API response.
writeOnly: true is one of the most important, most underused fields for describing security-sensitive data. It tells every consumer and every tool: this field can be sent to the API, but it will never come back in a response. I use this constantly for passwords, full card numbers, and any other secret that should flow in one direction only.
The inverse, readOnly: true, is what I use for server-generated identifiers and timestamps, as I mentioned in the JSON Schema article — it prevents consumers from mistakenly thinking they should (or can) set those fields themselves.
A Full Realistic Example
Let me put it all together into a single coherent security setup, the way I’d actually structure it for a mid-sized partner-facing API.
components:
securitySchemes:
OAuth2:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://auth.example.com/oauth/token
scopes:
orders:read: "View order details"
orders:write: "Create and update orders"
security:
- OAuth2: [orders:read]
paths:
/health:
get:
security: []
responses:
'200':
description: OK
/orders:
get:
summary: List orders
responses:
'200':
description: A list of orders
post:
summary: Create an order
security:
- OAuth2: [orders:write]
responses:
'201':
description: Order created
This document tells a complete, unambiguous security story: health checks are public, reading orders needs a basic read scope (inherited from the global default), and creating orders needs an explicit write scope.
Common Security Documentation Mistakes
- Leaving
securityundefined entirely, forcing consumers to guess whether an endpoint needs auth. - Vague scope descriptions that don’t actually explain what access is being granted.
- Not marking sensitive fields with
writeOnly, leading to accidental exposure of secrets in generated response examples. - Forgetting to document rate limits and their associated
429responses. - Using
implicitorpasswordOAuth2 flows for new APIs, whenauthorizationCode(with PKCE) orclientCredentialsare safer, more modern choices.
Where This Fits Into the Bigger Picture
Documenting security clearly in your OAS file is what lets frontend teams, partners, and security reviewers understand your API’s access model without a single meeting. It also feeds directly into actual security architecture decisions, which I explore much more broadly — beyond just what OAS can express — in the next article in this series, “Designing a Secure API.”
Key Takeaways
- Separate security schemes (the mechanism) from security requirements (where it’s enforced).
- Choose the right OAuth2 flow deliberately —
authorizationCodefor user-facing apps,clientCredentialsfor machine-to-machine. - Always explicitly set
security: []on genuinely public endpoints rather than leaving it ambiguous. - Write real, specific scope descriptions, not generic placeholders.
- Use
writeOnlyandreadOnlyto describe the direction sensitive data is allowed to flow. - Document expected rate-limiting behavior with a reusable
429response.
A precisely documented security model in your OpenAPI spec does more than satisfy a checklist — it’s often the first thing a security-conscious partner or reviewer checks before they trust your API at all.
