Documenting OAuth2 Scopes in OpenAPI Security Schemes
Problem: an integrator asks which scope they need for POST /orders and the honest answer is “try one and see what happens.” The scopes exist in the identity provider’s configuration, the middleware compares strings against them, and the API document mentions neither. This guide is part of Securing API Contracts with Auth Schemes and covers the scope layer specifically: designing a taxonomy that survives growth, declaring it in the contract, and keeping the issuer aligned with it.
Root Cause: Scopes Designed Endpoint by Endpoint
Scope taxonomies decay in a predictable way. The first endpoint gets read_orders. The second gets orders_read because a different developer wrote it. Then a specific report needs orders_read_summary, and within a year the token contains twenty-three scopes, three of which are synonyms and none of which anyone dares remove.
The fix is to design the axis before the names: scopes describe a resource and an action, never an operation.
Step 1: Declare the Flow and Its Scopes
The scope map lives in exactly one place — the security scheme — with a description per scope written for the person who has to approve it.
components:
securitySchemes:
oauth2:
type: oauth2
description: |
Authorization-code flow with PKCE for user-facing clients, and
client-credentials for server-to-server integrations.
flows:
authorizationCode:
authorizationUrl: https://auth.example.com/authorize
tokenUrl: https://auth.example.com/token
refreshUrl: https://auth.example.com/token
scopes:
orders:read: View orders belonging to the signed-in customer.
orders:write: Create, update and cancel the signed-in customer's orders.
orders:admin: View and modify orders belonging to any customer.
customers:read: View the signed-in customer's profile and addresses.
customers:write: Update the signed-in customer's profile and addresses.
invoices:read: Download invoices for the signed-in customer.
clientCredentials:
tokenUrl: https://auth.example.com/token
scopes:
orders:admin: Full order access for backend integrations.
invoices:read: Invoice retrieval for reconciliation jobs.
Notice that the two flows expose different scope sets. A browser client has no business requesting orders:admin, and declaring it only under clientCredentials documents that fact where tooling can see it.
Why the descriptions matter: in an authorization-code flow these strings are what the user sees on the consent screen. “Access your orders” is a description someone can evaluate; “orders:read scope” is not.
Step 2: Apply the Least Scope Per Operation
security:
- oauth2: [orders:read] # the document-wide default
paths:
/orders:
get:
summary: List the caller's orders
# inherits orders:read
post:
summary: Place an order
security:
- oauth2: [orders:write] # replaces the default
/orders/{id}/refund:
post:
summary: Refund an order
security:
- oauth2: [orders:write, orders:admin] # BOTH required
/admin/orders:
get:
summary: List orders across all customers
security:
- oauth2: [orders:admin]
The refund case is the one to think twice about. Two scopes in a single entry means both must be present — appropriate for a genuinely privileged action, but a signal to check whether the operation is doing too much.
Step 3: Keep the Issuer and the Document in Step
The specification’s scope list and the identity provider’s registered scopes are two copies of one fact. Compare them in CI:
#!/usr/bin/env bash
# scripts/check-scopes.sh
set -euo pipefail
# Every scope the document mentions, from both flows.
yq -r '.components.securitySchemes.oauth2.flows[].scopes | keys | .[]' openapi.yaml \
| sort -u > /tmp/spec-scopes.txt
# Every scope the identity provider will actually issue.
curl -s -H "authorization: Bearer $IDP_ADMIN_TOKEN" \
https://auth.example.com/admin/scopes | jq -r '.[].name' | sort -u > /tmp/idp-scopes.txt
if ! diff -u /tmp/idp-scopes.txt /tmp/spec-scopes.txt; then
echo "::error::the document and the identity provider disagree about scopes"
exit 1
fi
echo "scopes aligned: $(wc -l < /tmp/spec-scopes.txt) registered"
A scope in the document that the issuer will never grant is a permanent 403 waiting for a consumer to discover; a scope the issuer grants but the document never mentions is unaudited access.
Verification
# The consent screen shows human-readable descriptions, not identifiers.
yq -r '.components.securitySchemes.oauth2.flows.authorizationCode.scopes' openapi.yaml
# orders:read: View orders belonging to the signed-in customer.
# ...
# A token without the write scope is rejected, and told which scope is missing.
curl -s -XPOST https://api.example.com/v2/orders \
-H "authorization: Bearer $READ_TOKEN" -d '{}' | jq -r '.code, .detail'
# INSUFFICIENT_SCOPE
# This operation requires: orders:write.
# No operation requires a scope the document does not declare.
comm -13 /tmp/spec-scopes.txt \
<(yq -r '.paths[][].security[]?.oauth2[]?' openapi.yaml | sort -u)
# (empty)
Edge Cases and Caveats
- Scope strings with spaces. The
scopeclaim is space-delimited, so a scope name containing a space is unparseable. Colons, dots and underscores are safe; spaces and commas are not. - Token size. Every scope adds bytes to a bearer token that travels on every request. Twenty scopes on a JWT with a large claim set can push headers past a proxy’s limit — another reason to keep the taxonomy small.
- Scope creep on refresh. A refresh token exchange must never widen scope. Verify that your issuer rejects a refresh request asking for scopes the original grant did not include.
- Renaming a scope. Treat it as a breaking change: issue both names for a deprecation window, accept either in the middleware, and remove the old one only when no live token can still carry it. The same deprecation discipline applies to scopes as to endpoints.
Deciding What Deserves Its Own Scope
The hard part of a scope taxonomy is not naming — it is deciding where the boundaries fall. Three questions settle most cases.
Would you ever grant one without the other? If every integration that needs invoices:read also needs orders:read, and no integration needs one alone, they are one capability wearing two names. Merge them. Conversely, if a reporting integration needs to read orders and must never write them, the read and write split is real.
Does the distinction survive a conversation with a customer? Scope descriptions appear on consent screens. If you cannot explain the difference between two scopes to a non-engineer in one sentence, a user cannot make an informed decision about granting them, and the finer of the two is not carrying its weight.
Is the risk different? The strongest argument for a separate scope is that the blast radius differs. Reading a customer’s own orders and reading every customer’s orders are the same operation on the same resource with wildly different consequences, which is why orders:read and orders:admin are worth separating even though both are reads.
Where those questions do not clearly separate two candidates, prefer the coarser scope. A taxonomy that is slightly too coarse grants a little more access than strictly necessary; one that is too fine produces tokens nobody can audit, and unauditable access is the larger risk in practice.
Migrating an Existing Scope Set
Most teams arrive at this page with twenty-three scopes already issued. The migration is the same shape as any contract deprecation, with one addition: tokens live longer than deploys.
Map the old names to the new ones at the token-issuing layer first, so a request for read_orders issues a token carrying orders:read. Accept both names in the middleware during the window. Then wait out the longest access-token lifetime plus the longest refresh-token lifetime before removing the old names — a token issued the minute before the mapping changed is still valid, and rejecting it produces a 403 the caller cannot explain.
Documenting Scopes for the Person Requesting Them
The audience for a scope description is not an engineer reading the specification — it is whoever approves the grant, whether that is an end user on a consent screen or a security reviewer approving a partner integration. Write for them: name the data, name the action, and avoid the API’s internal vocabulary. “View orders belonging to the signed-in customer” is evaluable; “read access to the orders resource” is a restatement of the identifier.
Frequently Asked Questions
How granular should scopes be?
One scope per resource and action — orders:read, orders:write — is the level that scales. Finer granularity produces tokens carrying dozens of scopes that no operator can audit; coarser granularity forces you to grant more access than an integration needs.
Can an operation require more than one scope?
Yes. Listing two scopes in one security entry means both are required. Use it sparingly: an operation needing three scopes is usually an operation doing three things and worth splitting.
Do scope names in the spec have to match the identity provider exactly?
Yes, character for character, because the middleware compares strings. Generate the identity provider’s scope registration from the same list where possible, and add a CI check comparing the two when it is not.
What should a scope description say?
What the holder can do and to whose data, in one sentence a non-engineer can evaluate — the description is what appears on the consent screen a user actually reads. Read every scope description and rewrite the vague ones.
Should read scopes be implied by write scopes?
No. Implicit hierarchies are invisible in the token and have to be reimplemented in every service. If a caller needs both, issue both; the explicitness costs a few characters and removes a class of authorization bug.