Security Schemes & OAuth Flows in OpenAPI 3.1
Authentication is the part of an API contract that fails loudest and latest. A missing tokenUrl, a mistyped scope, or a public operation that silently inherits a global security requirement all produce confusing 401 and 403 responses at runtime instead of at review time. This guide is part of OpenAPI & AsyncAPI Schema Authoring, and it covers how to define securitySchemes and OAuth2 flows in OpenAPI 3.1, map grant types to operations, enforce completeness with Spectral in CI, and render an accurate “Authorize” experience in your developer portal.
Scope: this page covers the OpenAPI 3.1 components.securitySchemes object, all four OAuth2 flow types, API key and HTTP bearer schemes, and how portal generators consume them. It does not cover identity-provider configuration (registering clients, issuing tokens) or runtime token validation in your gateway — those live in your IdP and middleware, not the spec. For the deep dives, see the machine-to-machine guide on implementing OAuth2 client credentials, the API key authentication guide, and the user-facing authorization code with PKCE guide.
Use this quick reference to pick a scheme before you write any YAML:
Scheme type |
Use for | Key fields | Portal rendering |
|---|---|---|---|
oauth2 / clientCredentials |
Backend-to-backend (M2M) | tokenUrl, scopes |
Token form, scope checkboxes |
oauth2 / authorizationCode |
User-facing web and native apps | authorizationUrl, tokenUrl, scopes |
Redirect Authorize button |
apiKey |
Simple partner/script access | name, in (header/query/cookie) |
Single key input field |
http / bearer |
Pre-issued JWTs or opaque tokens | scheme: bearer, bearerFormat |
Bearer token input |
mutualTLS |
High-assurance partner channels | (none; transport-level) | Note only — no UI |
Prerequisites & Environment Setup
Pin your tooling so contributors lint against the same rules. Install Node 20 LTS and these packages locally and in CI:
# Pin versions — Spectral rule semantics drift between majors
npm install --save-dev @stoplight/spectral-cli@6
npm install --save-dev @redocly/cli@2 # v2.x; redoc-cli is deprecated
node --version # expect v20.x
npx spectral --version # expect 6.x
Set the identity-provider endpoints once as environment variables so example specs and CI stay environment-agnostic. Never hardcode a production tokenUrl in a fixture you lint:
export AUTH_ISSUER="https://auth.example.com"
export AUTH_TOKEN_URL="${AUTH_ISSUER}/oauth2/token"
export AUTH_AUTHORIZE_URL="${AUTH_ISSUER}/oauth2/authorize"
You need a securitySchemes block defined under components before any operation can reference it, and your scope names must match exactly what your IdP issues — a single character mismatch surfaces only at runtime as a 403. Align path-level overrides with the conventions in Structuring OpenAPI Paths.
Core Configuration
Define every scheme once in components.securitySchemes, then reference it from the root security array (the default for all operations) or per-operation. The annotated block below covers all common schemes:
# openapi.yaml
openapi: 3.1.0 # 3.1 required for JSON Schema 2020-12 alignment
info:
title: Example API
version: "1.0.0"
components:
securitySchemes:
OAuthUser: # name you reference in `security` arrays
type: oauth2
description: User-facing login via authorization code + PKCE
flows:
authorizationCode:
authorizationUrl: https://auth.example.com/oauth2/authorize # browser redirect target
tokenUrl: https://auth.example.com/oauth2/token # token exchange endpoint
refreshUrl: https://auth.example.com/oauth2/token # optional; enables refresh
scopes: # scope name : human-readable description
read:orders: Read a user's orders
write:orders: Create and modify orders
OAuthService:
type: oauth2
description: Machine-to-machine integrations
flows:
clientCredentials:
tokenUrl: https://auth.example.com/oauth2/token # no authorizationUrl for M2M
scopes:
batch:process: Run batch jobs
ApiKeyAuth:
type: apiKey
in: header # header | query | cookie
name: X-API-Key # the exact header/param clients must send
BearerAuth:
type: http
scheme: bearer # lowercase per RFC 7235
bearerFormat: JWT # advisory hint shown in the portal
security:
- OAuthUser: [read:orders] # global default applied to every operation
Two rules govern how requirements combine. Items in the top-level security list are alternatives (logical OR) — a client may satisfy any one. Multiple keys inside a single list item are combined (logical AND). To make an operation public, override the global default explicitly:
paths:
/health:
get:
summary: Liveness probe
security: [] # empty array = no auth; overrides global requirement
responses:
'200': { description: OK }
/orders:
post:
summary: Create an order
security:
- OAuthUser: [write:orders] # this operation needs the write scope specifically
responses:
'201': { description: Created }
Define payload schemas referenced by these operations as reusable components per Defining JSON Schema Components so auth and payload validation stay consistent end to end.
Integration Pattern
Validation belongs in CI, gated on every pull request, so a non-compliant security block can never merge. The workflow below installs pinned tooling and fails the build on any error-severity Spectral finding.
# .github/workflows/security-lint.yml
name: Spec Security Validation
on:
pull_request:
paths: ['openapi.yaml', '.spectral.yaml']
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Lint security schemes
run: npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
- name: Build portal docs (smoke test)
run: npx @redocly/cli build-docs openapi.yaml --output dist/index.html
The ruleset enforces that every OAuth2 scheme declares a flow, that flows carry the URLs the portal needs, and that no operation is left without an explicit security decision:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
oauth2-flows-required:
description: Every oauth2 scheme must declare at least one flow.
severity: error
given: "$.components.securitySchemes[?(@.type=='oauth2')]"
then: { field: flows, function: truthy }
oauth2-token-url-required:
description: OAuth2 flows must define tokenUrl for the portal Try-It panel.
severity: error
given: "$.components.securitySchemes[*].flows[*]"
then: { field: tokenUrl, function: truthy }
operation-security-defined:
description: Every operation must set security explicitly (use [] for public).
severity: warn
given: "$.paths[*][get,post,put,patch,delete]"
then: { field: security, function: defined }
Advanced Options
Operation-level scope narrowing. Declare the broadest scope set on the scheme, then request only the minimum scope per operation in its security array. This keeps the principle of least privilege visible in the contract and lets the portal show exactly which scopes a “Try It” call needs.
Custom scope-registry validation. Maintain scopes.json as the single source of truth and write a custom Spectral function that asserts every scope used in securitySchemes and in operation security exists in the registry. This is the most effective guard against scope drift across services and the answer to most M2M 403 mysteries.
Cookie-based API keys with CSRF awareness. apiKey supports in: cookie, useful for browser sessions, but cookie auth requires CSRF protection that the spec cannot express. Document the required X-CSRF-Token header as a separate parameter and note the SameSite policy in the scheme description so portal users understand the full handshake.
The scheme is only half the contract
securitySchemes declares what kinds of credential exist; security declares which ones apply where. Both halves are needed, and the second is the one that is routinely wrong — usually because a global security block was written once and never revisited as operations were added with different requirements.
Two shapes deserve to be written deliberately rather than inherited. An empty security: [] on an operation means “no authentication required” and is how a public endpoint opts out of a global requirement — writing nothing at all means it inherits the global rule, which is the opposite. And multiple entries in the array are alternatives, any one of which suffices, whereas multiple keys inside one entry must all be satisfied together. Those two are easy to swap and produce a portal that tells readers something untrue about what they need.
Flows describe who is holding the credential
The OAuth2 flow you declare should follow from who is at the other end, not from what is easiest to document. A user in a browser who must consent belongs in the authorization code flow with PKCE. A backend service acting as itself belongs in client credentials, where there is nobody to consent and no browser to redirect. A script or a partner integration with a long-lived secret is often better served by an API key than by an OAuth2 flow that nothing about the situation requires.
The implicit flow is deliberately absent from that list. It exists in the specification and is no longer recommended for anything, because it returns tokens through the browser’s address bar where they end up in history and referrer headers. Declaring it in a specification published today teaches readers a pattern they should not adopt, which is a cost even when nothing uses it.
Verification & Testing
Confirm the spec is valid and the auth UI renders before merging. Run the linter and inspect the exit code:
npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
echo $? # 0 = clean; 1 = error-severity violations found
Build the portal locally and open it: the “Authorize” dialog should list every scheme, show scope checkboxes for OAuth2 flows, and present a single input for apiKey/bearer schemes.
npx @redocly/cli build-docs openapi.yaml --output dist/index.html
npx @redocly/cli lint openapi.yaml # cross-check structural validity
Finally, do a manual round-trip in the rendered “Try It” panel using a real test token from a non-production IdP. A successful authorized request that returns 200 confirms the tokenUrl, scopes, and header placement all match what the gateway expects.
Scopes are documentation, and they are usually wrong
Declared scopes appear in the rendered authorize dialog, in generated SDK documentation, and in whatever access-request process your consumers go through — which means an inaccurate scope list produces support tickets from people who requested exactly what the documentation told them to and still got a 403.
Two failures account for most of it. The first is scopes declared globally but enforced per operation, so the specification says a token needs invoices:read while the implementation also requires customers:read for an embedded field. The second is drift in the other direction: scopes that were tightened in the implementation and never removed from the document, so consumers request more access than they need and your security review inherits the discrepancy.
Both are worth testing rather than reviewing. A short check that requests each operation with a minimally scoped token and asserts it succeeds, and with the scope removed and asserts it fails, converts the declared scope list from a claim into a verified fact. It is the same shape as any contract test and catches a class of problem that no amount of reading the document will.
Scope naming deserves the same care as path naming, and for the same reason: the names are public, they appear in consumer-facing dialogs, and renaming one is a breaking change for anyone whose stored authorisation references it. A small, predictable vocabulary — resource plus action — ages far better than scopes named after internal services or after whichever team introduced them.
What the specification should not contain
A security scheme describes how to authenticate, not what the credentials are. Two things routinely end up in specifications that should never be there.
Real credentials in examples are the obvious one, and they reach further than people expect: a specification is fetched by browsers, cached by CDNs, committed to repositories, and copied into other tools. A token that appeared once in an example is public permanently, and removing it from the document does nothing — only rotating it at the source does.
Less obvious is the internal token endpoint. Declaring the authorisation and token URLs of an identity provider that is only reachable inside your network tells external readers to send requests somewhere they cannot reach, and produces a portal whose authorize dialog is guaranteed to fail. Where the endpoints genuinely differ per environment, express that with the servers array and environment-specific documents rather than by publishing the internal one and hoping readers infer the substitution.
Troubleshooting
Security scheme "X" is not defined — An operation or root security array references a scheme name that does not exist under components.securitySchemes. Names are case-sensitive; check for a typo or a scheme defined in a $ref’d file that was not bundled. Run @redocly/cli bundle first if your spec is split across files.
Portal “Authorize” button missing or inert — The OAuth2 flow is missing authorizationUrl (for authorizationCode) or tokenUrl. Portal generators render the dialog only when the URLs needed to perform the exchange are present. Add both and re-run the oauth2-token-url-required rule.
Runtime 403 insufficient_scope despite valid token — The scope string in the spec does not byte-match the scope your IdP issued, or the operation requests a scope the token was not granted. Compare the token’s scope claim against the operation security entry and your scope registry.
Using securityDefinitions instead of components.securitySchemes — securityDefinitions is the OpenAPI 2.0 (Swagger) field. OpenAPI 3.x parsers ignore it, so the portal shows no auth at all. Migrate the block under components.securitySchemes and update flow field names (flow → flows, scopes mapping unchanged).
Making the authorize dialog actually work
The portal’s authorize dialog is generated entirely from securitySchemes and security, which means it is the fastest way to find out whether those two blocks are correct — and the fastest way for a reader to discover that they are not. A dialog that offers a flow your identity provider does not support, or that omits a scheme an operation actually requires, is a visible defect that reflects on the whole reference.
Exercise it as part of the portal’s checks rather than by hand. Loading the reference, opening the dialog, and asserting that the expected schemes appear takes a few lines in a headless browser and catches the case where a specification edit silently changed what readers are asked for. Pair it with an actual authenticated request through the try-it console against a sandbox, because a dialog that renders correctly and produces a token the API rejects is a worse experience than one that is obviously broken.
The remaining piece is what a reader is expected to do before any of this works. A specification cannot express “register an application in the developer portal first”, and readers arriving at a reference page rarely know it. A sentence in the operation description, or a link to the credential-issuing flow from the security section, removes the most common first-run failure — which is not a misconfigured scheme at all, but a reader with no credentials trying to use a dialog that assumes they have some.
FAQ
How do I handle multiple OAuth2 flows in a single API spec?
Declare more than one flow under a single oauth2 securityScheme, such as authorizationCode and clientCredentials together, or define separate schemes per consumer type. Reference the appropriate scheme and scopes at the operation level so each endpoint advertises only the grant it actually accepts.
Can I validate scope names across microservices automatically?
Yes. Keep a central scope registry in a JSON file and write a custom Spectral function that checks every scope in securitySchemes and every security requirement against that registry during CI. This blocks scope drift before it reaches the gateway and prevents runtime 403 errors.
Should I still use the OAuth2 password grant?
No. The resource owner password credentials grant is removed in OAuth 2.1 and should not appear in new specs. Use clientCredentials for machine-to-machine traffic and authorizationCode with PKCE for any flow involving a human user.
How does AsyncAPI security differ from OpenAPI security?
AsyncAPI declares securitySchemes at the components and servers level with protocol bindings such as Kafka SASL or MQTT, rather than HTTP grant types. Map those bindings to your broker’s native authentication instead of reusing the OpenAPI OAuth flow objects directly.
Related
- OpenAPI & AsyncAPI Schema Authoring — parent overview
- Implementing OAuth2 client credentials in OpenAPI 3.1
- Documenting API key auth in OpenAPI
- OAuth2 authorization code with PKCE in OpenAPI
- Structuring OpenAPI Paths — applying per-operation
securityoverrides