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.
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.
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).
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