Skip to content

OAuth scopes

Gate a rule on the scope a token carries with .requires_scope(...) — the refusal is an RFC 6750 insufficient_scope challenge naming what to request.

A user hands an MCP client a token. It should be able to read their posts and nothing else — not because of who they are (they are an admin) but because of what this token was delegated.

Roles and scopes answer different questions, and both have to hold:

  • Roles say who the caller is. They come from your identity model and drive the conditions in policies.
  • Scopes say how much of that identity this particular token may exercise. They only ever narrow — an admin token minted without posts:write cannot write posts.

Declare the scope on the rule it conditions

Section titled “Declare the scope on the rule it conditions”
crates/features/src/authz/ability.rs (from the demo)
impl AbilityFactory for AuthzAbility {
type Actor = Claims;
fn define(&self, actor: &Claims, ab: &mut AbilityBuilder) {
ab.can(Action::Read, post::Entity)
.when(|p| p.eq(post::Column::OrgId, actor.org_id))
.requires_scope("posts:read");
ab.can(Action::Manage, post::Entity)
.when(|p| p.eq(post::Column::OrgId, actor.org_id))
.requires_scope("posts:write");
}
}

That is the whole declaration. When the caller’s token does not carry the scope, the rule is withheld — not added at all. Every layer then refuses together, exactly as it would for a rule you never wrote: the class gate says no, the query pre-filter matches nothing, and the response mask has nothing to expose.

Call it more than once to require all the named scopes. Scopes are opaque tokens compared exactly (RFC 6749 §3.3) — posts:* is a value you may mint, never a pattern the framework expands.

Scopes reach the ability layer from the principal. Implement PrincipalIdentity::scopes on your claims type, and read the standard space-delimited scope claim with the serde helper the framework ships:

crates/features/src/authn/claims.rs (from the demo)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: Option<Uuid>,
pub org_id: Uuid,
pub roles: Vec<Role>,
#[serde(
default,
rename = "scope",
with = "nest_rs::authn::scope::space_delimited",
skip_serializing_if = "Vec::is_empty"
)]
pub scopes: Vec<String>,
pub exp: u64,
}
impl PrincipalIdentity for Claims {
fn actor_id(&self) -> Option<String> {
self.sub.map(|sub| sub.to_string())
}
fn scopes(&self) -> Option<&[String]> {
Some(&self.scopes)
}
}

The authentication guard publishes the result and the ability guard reads it. You write nothing else.

A client that cannot tell “you may never do this” from “come back with a wider token” either gives up too early or retries the same token forever. So the two refusals are distinct on every transport.

On HTTP, WS and MCP, a scope refusal is a 403 carrying the RFC 6750 §3.1 challenge — provided the app is a protected resource server:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
resource_metadata="https://api.example.com/.well-known/oauth-protected-resource",
scope="posts:write"

The client now knows the exact scope to request and the document to request it from. An ordinary 403 — the caller may not do this at all — deliberately carries no challenge: advertising a recovery that cannot succeed is worse than a plain refusal.

On GraphQL there is no 403 to enrich, so the same facts ride the error frame the transport already speaks:

{
"errors": [{
"message": "insufficient_scope",
"extensions": { "code": "INSUFFICIENT_SCOPE", "requiredScopes": ["posts:write"] }
}]
}

The scopes your rules require and the scopes your resource server advertises must be the same set:

.env
NESTRS_OAUTH_RESOURCE__SCOPES_SUPPORTED=posts:read,posts:write,audio:transcode

A scope a rule requires but the metadata document omits is a dead end — the client is told to request something discovery never names. The framework reports it at warn (reason="scope_not_advertised") the first time such a refusal is emitted; keeping the names in one constants.rs and reading them from both sides is what stops it happening.

Raising a scope refusal from your own guard

Section titled “Raising a scope refusal from your own guard”

A non-CRUD route gated by a capability-only guard raises the same denial directly:

crates/features/src/audio/guard.rs
Err(Denial::insufficient_scope(["audio:transcode"], "forbidden"))

Every transport renders it the same way — the challenge is written once, at the transport edge, so a guard never formats a header itself.