Internet Protocols · Identity Protocols

Scopes Are Strings, Consent Is Policy: Where Access Limits Actually Live

Why the scope parameter is only a request, how authorization servers narrow or refuse it, and what incremental authorization costs in state tracking.

The scope parameter looks like an access control list. It is a space-delimited set of case-sensitive strings in the authorization request, and the granted scope comes back in the token response. Treating the string as the security boundary is the most common misunderstanding in OAuth deployments, because the specification assigns the authorization server discretion over the request and gives the resource server no obligation to read the scope at all. The parameter’s job is to describe an intent, and the roles that act on it are what turn that intent into an enforceable grant.

The parameter is a request, not a demand

RFC 6749 defines scope as a way for the client to specify the access it wants. The authorization server may grant the requested scope, grant a subset, or grant something different, and it may ignore the request entirely if its own policy says otherwise. The scope value in the token response is the authoritative record of what was granted.

Three outcomes are possible and all are legal. The server grants exactly what was asked. It grants less, in which case the client must cope with a token that does less than expected. Or it rejects the request with invalid_scope, which is the correct response when the client asked for something the resource owner cannot grant at all, as opposed to something they declined.

The distinction between “less than asked” and “refused” matters for client logic. A client that receives a narrower scope than requested should degrade its feature set rather than treat the login as failed. A client that receives invalid_scope should not retry with the same value. Confusing the two produces either a broken application or a loop of failed authorization requests.

The specification deliberately does not define a registry of scope values or a syntax beyond the delimiter. Scope strings are application-defined, and the only normative constraints are that they are case-sensitive, delimited by a single space (ASCII 0x20), and must not contain that delimiter. Everything else, including the convention of resource:operation or urn:-prefixed names, is deployment convention.

Why resource servers usually cannot enforce scopes alone

A scope string is meaningless without a shared interpretation between the authorization server that issued it and the resource server that reads it. If the resource server trusts the token to carry scopes, it must parse the token; if it does not parse the token, it must call an introspection endpoint to learn the scopes, or it must trust a proxy that already did.

This is the axis on which token formats are chosen. A self-contained token carries the granted scopes as claims, so a resource server can enforce them without a network call, at the cost of accepting whatever the issuer asserted. An opaque token forces a lookup, which lets the authorization server return the current scope set including any narrowing that happened after issuance, at the cost of a round trip per request or a cache with a staleness window.

The enforcement point is also not necessarily the resource server. A gateway in front of a set of services can validate once and forward a normalised set of claims, which reduces the number of places that must understand the scope vocabulary. The trade is that the gateway becomes part of the trust boundary, and a service that accepts direct traffic bypasses it.

The authorization server’s discretion is exercised through the consent interface, and the granularity of that interface determines what the user actually agrees to. RFC 6749 says nothing about presentation. It requires that the authorization server obtain a decision from the resource owner, and it defines the error access_denied for a refusal, but the mapping from scope strings to human-readable statements is entirely implementation-defined.

The practical failure is a scope set that is too coarse to consent to meaningfully. A single mail scope that covers reading, sending, and deleting forces the user to accept all three to get one. Splitting it into mail.read, mail.send, and mail.delete gives the user a real choice and gives the resource server a real check. Splitting too far produces a consent screen with twenty checkboxes, which users accept without reading, so the granularity has an optimum that depends on how distinguishable the operations are.

A second failure is scope inflation by the client. A client that requests the maximum scope it might ever need, rather than the scope the current operation requires, trains users to approve broad access and makes the resulting token more dangerous if leaked. Incremental authorization, described below, is the mechanism for avoiding this.

Consent also has a lifetime. A grant recorded once may be reused on subsequent authorization requests from the same client without prompting again, which is what makes a third-party login tolerable. The conditions for reuse are deployment policy: same client, same resource owner, and a scope set that is a subset of what was previously approved. Any new scope requires a new decision.

Incremental authorization and its bookkeeping

RFC 6749 does not include a mechanism for requesting additional scope on an existing grant. The common approach, sometimes called incremental authorization, is to run a fresh authorization request with the union of previously granted and newly needed scopes, and rely on the authorization server to prompt only for the difference if it recognises the existing grant.

The cost is on the client. It must track which scopes it currently holds for a given user, because the token response to the second request reports the total granted scope, not the increment. If the client stores only the latest response, it loses the ability to reason about what it can do; if it stores the union itself, it can diverge from what the server actually granted.

A second cost is user confusion. A second consent prompt for an application the user already approved looks like a new application unless the interface distinguishes it, and the natural response is to approve without reading. Some deployments avoid the problem by requesting the full scope set at first login and accepting the broader token, trading least privilege for a single prompt.

A third cost is that the refresh token is reissued or retained, depending on how the authorization server models the grant. If the refresh token is bound to a scope set, adding scope requires a new refresh token, which invalidates the old one. If it is bound to the grant rather than the scope, the access tokens derived from it carry the scope, and the refresh token’s authority grows silently. The first model is more predictable; the second is simpler to implement.

Audience restriction as a second dimension

Scope answers “what may be done”; audience answers “to whom”. RFC 8707 defines the resource parameter, which lets a client indicate the resource server the token is intended for, and defines the aud claim in JWT access tokens as the intended recipient set. A token with the right scope but the wrong audience must be rejected by the resource server even though the scope check passes.

The attack this prevents is token reuse across services. Without audience restriction, a token issued to a client for one API is accepted by any API that trusts the same issuer, so a compromise of the least-protected service yields a credential valid at the most-protected one. With it, each service checks that it is named in aud, and a token minted for a photo service is useless at a mail service.

RFC 6750’s insufficient_scope error is the resource server’s way of reporting a scope shortfall, and the WWW-Authenticate header may carry a scope parameter listing what would have sufficed. RFC 6750 defines no equivalent for an audience mismatch; a resource server that receives a token for another audience should return invalid_token without explaining why, since the explanation is useful mainly to an attacker.

Where scope enforcement breaks down

The first breakdown is a resource server that checks for the presence of a scope but not its value. A token carrying photos.read and a handler that checks only that some scope exists will serve a write request. Scope checks must be per-operation, and the operation-to-scope mapping must be explicit, which means the mapping lives in code and drifts from the documentation unless it is generated.

The second is a client that assumes the granted scope equals the requested scope. The authorization server may narrow, and the client’s feature detection must be based on the response, not the request. A client that enables a UI feature because it asked for a scope will show a control that fails when used.

The third is an authorization server that treats scope strings as free-form and matches them case-insensitively. Scope values are case-sensitive per RFC 6749, so Photos.Read and photos.read are different strings. An implementation that folds case has a set of scopes larger than the set it documents.

The fourth is the interaction with refresh token rotation: a rotated refresh token inherits the scope of its predecessor, so a scope granted in error persists across every subsequent rotation until the grant is revoked. Scope errors are therefore long-lived unless the revocation endpoint is used deliberately.

References

  1. RFC 6749: The OAuth 2.0 Authorization Framework — IETF
  2. RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage — IETF
  3. RFC 8707: Resource Indicators for OAuth 2.0 — IETF
  4. RFC 9700: Best Current Practice for OAuth 2.0 Security — IETF