Implementing OAuth2 Client Credentials in OpenAPI 3.1
Machine-to-machine integrations do not have a human to click through a consent screen, so they rely on the OAuth2 client_credentials flow: a service presents its own client_id and client_secret to a token endpoint and receives an access token. This guide is a task-focused walkthrough of declaring, validating, and shipping that flow in an OpenAPI 3.1 document. It sits under the Security Schemes & OAuth Flows section of the broader OpenAPI & AsyncAPI Schema Authoring pillar, and assumes you already have an identity provider that issues tokens via RFC 6749.
Problem & Context
When the client_credentials flow is documented incorrectly, the failure is rarely visible at authoring time. The spec parses, the portal renders, and the breakage only surfaces when a developer tries to authorize from the rendered docs and gets a 401 or 403. The three common root causes are a missing or relative tokenUrl, scope names that do not match the identity provider’s registered scopes, and security requirements applied with the wrong granularity so that public endpoints demand a token they should not.
OpenAPI 3.1 raises the stakes slightly because it aligns with JSON Schema Draft 2020-12 and is stricter about the shape of the securitySchemes object. The clientCredentials flow requires tokenUrl and scopes; supplying authorizationUrl (which belongs to the authorizationCode flow) is an error. Getting the declaration right once, then enforcing it with a linter, is far cheaper than debugging a broken authorize button after release.
Step-by-Step Solution
1. Declare the security scheme
Add an oauth2 scheme under components.securitySchemes and nest the flow under flows.clientCredentials. The tokenUrl must be an absolute URI pointing at an RFC 6749 token endpoint. Declare scopes as an object that maps scope names to human-readable descriptions.
# openapi.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
title: M2M Service API
version: 1.0.0
components:
securitySchemes:
m2m_oauth:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://auth.example.com/oauth2/token
scopes:
read:public: Access public resources
write:internal: Modify internal records
If your provider does not use scopes, set scopes: {} — an empty object is valid and keeps strict validators happy.
2. Apply security globally and per operation
Reference the scheme in a root-level security array to make it the default for every operation, listing the scopes the document expects:
security:
- m2m_oauth: [read:public, write:internal]
Then override at the operation level where access differs. An empty array (security: []) marks an endpoint as public and cancels the global requirement:
paths:
/internal/data:
post:
summary: Submit internal records
operationId: createRecord
security:
- m2m_oauth: [write:internal]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RecordInput'
responses:
'201':
description: Record created
/public/status:
get:
summary: Check service health
operationId: getStatus
security: []
responses:
'200':
description: System operational
The scope strings in an operation’s security array must be a subset of the scopes declared in the scheme, and every scope must exist in the provider. A typo here produces a 403 Forbidden at runtime, not a parse error, which is exactly why the next step matters.
3. Lint the spec in CI
Add a Spectral rule that fails the build when a clientCredentials flow lacks a tokenUrl:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
oauth2-token-url-required:
description: clientCredentials flow must define a tokenUrl
severity: error
given: $.components.securitySchemes[*].flows.clientCredentials
then:
field: tokenUrl
function: truthy
Run the linter:
npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
When tokenUrl is missing, Spectral exits non-zero and prints:
openapi.yaml
6:11 error oauth2-token-url-required clientCredentials flow must define a tokenUrl
✖ 1 problem (1 error, 0 warnings)
Wire this into a pull-request workflow so a missing token URL can never merge.
4. Render and verify in the portal
Build static documentation and confirm the auth UI behaves:
npx @redocly/cli build-docs openapi.yaml --output dist/index.html
Open dist/index.html and look for the authorize control. Portal generators that parse securitySchemes (Redoc, Swagger UI, Scalar) render a form prompting for client_id and client_secret for a client_credentials scheme. A successful exchange returns a token and the “Authorize” state turns active.
Authorized: m2m_oauth (client_credentials)
Token acquired, expires in 3600s
If the exchange fails, the browser console will usually show a CORS or 415 error rather than a spec problem — see Gotchas below.
Complete Working Example
A single self-contained openapi.yaml that declares the scheme, applies it globally, exposes one protected and one public operation, and references a request schema:
# openapi.yaml — copy/paste runnable with @redocly/cli and spectral
openapi: 3.1.0
info:
title: M2M Service API
version: 1.0.0
description: Machine-to-machine API secured with the OAuth2 client_credentials flow.
servers:
- url: https://api.example.com
components:
securitySchemes:
m2m_oauth:
type: oauth2
description: Client credentials flow for service-to-service calls.
flows:
clientCredentials:
tokenUrl: https://auth.example.com/oauth2/token
scopes:
read:public: Access public resources
write:internal: Modify internal records
schemas:
RecordInput:
type: object
required: [name]
properties:
name:
type: string
payload:
type: ['object', 'null']
security:
- m2m_oauth: [read:public]
paths:
/internal/data:
post:
summary: Submit internal records
operationId: createRecord
security:
- m2m_oauth: [write:internal]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RecordInput'
responses:
'201':
description: Record created
'403':
description: Token lacks the write:internal scope
/public/status:
get:
summary: Check service health
operationId: getStatus
security: []
responses:
'200':
description: System operational
Lint and build it:
npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
npx @redocly/cli build-docs openapi.yaml --output dist/index.html
Gotchas & Edge Cases
Relative or templated token URLs silently break the authorize button. Some teams reuse a server variable in tokenUrl, but most portal token clients require an absolute URI at parse time. Always write the full https:// endpoint; if it varies per environment, generate environment-specific spec files in CI rather than templating the URL.
The token endpoint must accept form-encoded bodies and allow the portal origin. RFC 6749 mandates application/x-www-form-urlencoded for the token request. A provider that only accepts JSON returns 415 Unsupported Media Type, and a missing CORS allowance on the portal domain produces a preflight failure that looks like a 401. Configure the provider to allow the portal origin and the form content type before blaming the spec.
Mixing flow keywords fails validation. The clientCredentials flow takes only tokenUrl, scopes, and the optional refreshUrl. Adding authorizationUrl (which belongs to authorizationCode) makes the document invalid under the 3.1 schema. If you need an interactive flow alongside the machine flow, declare a second scheme rather than overloading one.
FAQ
Can I define clientCredentials without scopes?
Yes. Scopes are optional under RFC 6749. Use an empty scopes object so strict JSON Schema validators pass while signalling that the identity provider does not require any scope.
How do I keep client_secret out of the spec?
Never put a real secret in the YAML. Use placeholder values like YOUR_CLIENT_SECRET in examples and document the secure retrieval workflow separately, injecting real values at request time in the portal.
Why does my portal return 401 during an automated token fetch?
Check that the token endpoint allows the portal origin via CORS, that it accepts application/x-www-form-urlencoded bodies, and that client_id and client_secret match the provider registration exactly, case included.