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.

Client credentials token exchange A service posts client_id and client_secret to the token endpoint, receives an access token, then calls the API with a bearer token. Service (client) Token endpoint Protected API POST id + secret access_token Bearer token request

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.