OAuth2 Authorization Code + PKCE in OpenAPI 3.1
The authorization code flow with PKCE is the recommended OAuth2 flow for browser single-page apps, mobile apps, and any public client that cannot keep a secret — but OpenAPI 3.1 gives you no pkce field, so documenting it correctly means combining the flows.authorizationCode object with a precise description and per-operation scope requirements. This guide is part of Security Schemes & OAuth Flows within the broader OpenAPI & AsyncAPI Schema Authoring section.
Problem & Context
A naive spec models a public client as if it were a confidential one — using the implicit flow (now discouraged) or the client credentials flow, which is wrong for user-facing auth. Worse, many specs declare authorizationCode but forget that PKCE has no representation in OpenAPI, so the docs never tell the reader (or the portal) that a code_challenge is mandatory:
# BEFORE — flow declared, but URLs are relative and PKCE is undocumented
components:
securitySchemes:
oauthCode:
type: oauth2
flows:
authorizationCode:
authorizationUrl: /authorize # relative URL — breaks portal try-it
tokenUrl: /token # relative URL
scopes: {} # no scopes documented
The fixes are concrete: authorizationUrl and tokenUrl must be absolute URIs, scopes must enumerate every scope the API uses, and the PKCE expectation must be stated in description because the spec format itself cannot encode it. Where the client credentials flow needs only tokenUrl, the authorization code flow additionally requires authorizationUrl to send the user through an interactive consent screen.
Step-by-Step Solution
1. Declare the oauth2 scheme with authorizationCode
Set type: oauth2 and nest the flow under flows.authorizationCode:
components:
securitySchemes:
oauthCode:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://auth.example.com/oauth2/authorize
tokenUrl: https://auth.example.com/oauth2/token
refreshUrl: https://auth.example.com/oauth2/token # optional
scopes: {} # filled in step 2
Expected behavior: the flow object now validates against the OpenAPI 3.1 schema. The authorization code flow requires both authorizationUrl and tokenUrl; omitting either is a lint error.
2. Define scopes
Populate scopes as a map of scope name to human description. Every scope referenced by any operation must appear here:
scopes:
read:profile: Read the signed-in user's profile
write:profile: Update the signed-in user's profile
read:orders: List the user's orders
Expected behavior: portals render a checkbox per scope in the Authorize dialog, and linters can verify operation-level scopes resolve to this list.
3. Document the PKCE requirement
OpenAPI 3.1 has no pkce key, so state the requirement in description. This is the only place a reader learns that a plain authorization-code request without a challenge will be rejected:
oauthCode:
type: oauth2
description: >
Authorization Code flow with PKCE (RFC 7636). Public clients MUST send
a code_challenge using the S256 method on the /authorize request and the
matching code_verifier on the /token request. Requests without a valid
code_challenge are rejected with error=invalid_request.
flows:
authorizationCode:
# ...urls and scopes as above
Expected behavior: human readers and SDK authors now know to implement the challenge/verifier pair; the authorization server enforces it at runtime.
4. Require scopes per operation
Reference the scheme in each operation’s security array, listing exactly the scopes that operation needs:
paths:
/me:
get:
summary: Get my profile
operationId: getProfile
security:
- oauthCode: [read:profile] # token must carry read:profile
responses:
'200': { description: Profile }
patch:
summary: Update my profile
operationId: updateProfile
security:
- oauthCode: [write:profile] # stricter scope for writes
responses:
'200': { description: Updated }
Expected behavior: a token granted only read:profile succeeds on GET /me but is rejected with 403 on PATCH /me. Scope names are case-sensitive and must match both this array and the scopes map from step 2.
5. Validate the spec
npx @redocly/cli@v2 lint openapi.yaml
Expected output:
validating openapi.yaml...
openapi.yaml: validated in 47ms
Woohoo! Your API description is valid. 🎉
A relative URL or an unresolved scope name surfaces as a no-server-trailing-slash, oas3-schema, or security-defined violation depending on the fault.
Complete Working Example
A self-contained openapi.yaml fragment: the PKCE-documented scheme, a global default scope, two operations with different scope requirements, and a public endpoint.
# openapi.yaml — OpenAPI 3.1.0, validated with @redocly/cli@v2
openapi: 3.1.0
info:
title: Profile API
version: 2.0.0
servers:
- url: https://api.example.com/v2
components:
securitySchemes:
oauthCode:
type: oauth2
description: >
Authorization Code flow with PKCE (RFC 7636). Public clients MUST send a
code_challenge (S256) on /authorize and the matching code_verifier on
/token. Requests without a valid code_challenge return invalid_request.
flows:
authorizationCode:
authorizationUrl: https://auth.example.com/oauth2/authorize
tokenUrl: https://auth.example.com/oauth2/token
refreshUrl: https://auth.example.com/oauth2/token
scopes:
read:profile: Read the signed-in user's profile
write:profile: Update the signed-in user's profile
read:orders: List the user's orders
# Global default: any read scope authenticates by default
security:
- oauthCode: [read:profile]
paths:
/me:
get:
summary: Get my profile
operationId: getProfile
# inherits global read:profile requirement
responses:
'200':
description: The current user's profile
content:
application/json:
schema: { $ref: '#/components/schemas/Profile' }
'401': { description: Missing or expired token }
patch:
summary: Update my profile
operationId: updateProfile
security:
- oauthCode: [write:profile] # write needs a stronger scope
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Profile' }
responses:
'200': { description: Updated profile }
'403': { description: Token lacks write:profile }
/orders:
get:
summary: List my orders
operationId: listOrders
security:
- oauthCode: [read:orders]
responses:
'200': { description: A page of orders }
/public/config:
get:
summary: Public runtime config
operationId: getPublicConfig
security: [] # explicitly public, no token
responses:
'200': { description: Config blob }
components/schemas:
Profile:
type: object
properties:
id: { type: string }
displayName: { type: string }
required: [id]
Note: place the
Profileschema undercomponents.schemas.Profilein your real file; thecomponents/schemasheading above is shown flat only so the fragment reads top to bottom.
Gotchas & Edge Cases
PKCE cannot be enforced by the spec. Because there is no pkce field, no linter can verify that PKCE is required — the contract lives entirely in description and in the authorization server’s configuration. If you migrate from the implicit flow, also disable implicit grants on the server; leaving them enabled means clients can bypass PKCE entirely.
Global scope is replaced, not merged, per operation. A root-level security: [oauthCode: [read:profile]] does not stack with an operation-level security: [oauthCode: [write:profile]]. The operation entry wins outright, so PATCH /me requires only write:profile, not both. If you need both, list both scopes in the same array entry: oauthCode: [read:profile, write:profile].
Swagger UI needs PKCE turned on explicitly. Swagger UI 5 will run the real handshake from try-it-out only when initialized with usePkceWithAuthorizationCodeGrant: true. Without it, the docs attempt a plain authorization-code exchange and fail against a PKCE-only server. Document this so portal maintainers set the flag during Swagger UI customization.
FAQ
Does OpenAPI 3.1 have a field to declare PKCE?
No. The authorizationCode flow object only carries authorizationUrl, tokenUrl, refreshUrl, and scopes; there is no pkce key. You signal the PKCE requirement in the scheme description and rely on the authorization server to reject requests without a code_challenge.
How do I require different scopes on different operations?
List the scopes in the operation-level security array — for example one entry of oauthCode with the read:profile scope. The authorization server must issue a token containing those scopes for the call to succeed.
Will Swagger UI run the PKCE handshake in try-it-out?
Yes. Swagger UI 5 enables PKCE automatically for the authorizationCode flow when usePkceWithAuthorizationCodeGrant is true, generating the code_verifier and code_challenge for you so you can test the real redirect flow from the docs.
Related
- Security Schemes & OAuth Flows — parent section overview.
- OAuth2 Client Credentials in OpenAPI 3.1 — machine-to-machine flow without a user.
- Documenting API Key Auth in OpenAPI 3.1 — the simplest scheme type.
- OpenAPI & AsyncAPI Schema Authoring — the full spec-authoring guide.