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:writecannot write posts.
Declare the scope on the rule it conditions
Section titled “Declare the scope on the rule it conditions”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.
Tell the framework what the token carries
Section titled “Tell the framework what the token carries”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:
#[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.
What the refused caller receives
Section titled “What the refused caller receives”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 ForbiddenWWW-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"] } }]}Advertise every scope you gate on
Section titled “Advertise every scope you gate on”The scopes your rules require and the scopes your resource server advertises must be the same set:
NESTRS_OAUTH_RESOURCE__SCOPES_SUPPORTED=posts:read,posts:write,audio:transcodeA 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:
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.
Going further
Section titled “Going further”- Split deployment — the discovery document the challenge points the client at.
- Policies — the role-driven conditions a scope narrows.
- Row-level filtering — what a withheld rule means for the query.