Authorization on GraphQL, WS & MCP
AuthzModule, AuthzGraphqlModule, AuthzWsModule and AuthzMcpModule re-establish the ambient ability at each transport's dispatch point.
HTTP handlers are not the only place a request reaches your code.
GraphQL operations multiplex over a single POST /graphql. WebSocket
messages multiplex over a single connection upgrade. MCP tool calls
multiplex over their own JSON-RPC. For each, the framework ships an
authz bridge that re-establishes the ambient Ability at the right
dispatch point. Your AuthzAbility factory stays the single source of
truth across every transport.
The bridges live in:
nest_rs::authz— the guard itself, under any transport featurenest_rs::authz::http— featurehttp, the HTTP extractors and shapernest_rs::authz::graphql— featuregraphqlnest_rs::authz::mcp— featuremcp; the matching data context isnest_rs::seaorm::mcp::McpDataContext(featuremcp)nest_rs::seaorm::ws— the WS data-context (split avoids a circular dep)
GraphQL, WS and MCP each get a one-import Authz<Transport>Module in
crates/features/src/authz/. Each wraps the framework bridge for its transport
and registers it against the access graph, so an app imports the module and
lists nothing else (see MCP below). HTTP has no
such module: the guard those bridges re-run is AbilityGuard, which answers
every transport, so AuthzModule provides it directly.
You do not write them: nestrs g auth writes authz/module.rs and
authz/guard.rs, and
nestrs g graphql|ws|mcp writes that transport’s bridge alongside the adapter
and wires it into the app. The blocks below are what those generators produce —
read them to understand the wiring, copy them only into a workspace that was not
scaffolded.
The bridges
Section titled “The bridges”| Bridge | Provides | What it bridges |
|---|---|---|
AuthzModule | AuthzGuard (AbilityGuard<AuthzAbility>) | The HTTP request — guard runs on &mut Request, attaches Arc<Ability> |
AuthzGraphqlModule | AuthzGraphqlBridge (dyn OperationGuard), LoaderScope (dyn BatchContext) | Re-runs the HTTP guard chain on /graphql, scopes the operation, snapshots ability around dataloader batches |
AuthzWsModule | WsDataContext (dyn SocketContext) | Re-establishes the pool + ability per WS message — which is what the per-message #[authorize] gate and reply mask read |
AuthzMcpModule | AuthzMcpBridge (McpAbilityBridge<AuthnGuard, AuthzGuard>) as dyn McpOperationGuard, McpDataContext as dyn McpToolContext | Re-runs the HTTP guard chain on the MCP endpoint and installs the ability inside each tool dispatch; the data context adds the executor + per-operation transaction |
A controller imports the matching module along with its feature module. The transports transitively bring every layer the feature needs.
#[module( imports = [ SeaOrmModule::for_root(None), SeaOrmDatabaseModule, AuthnModule, AuthzModule, AuthzGraphqlModule, AuthzWsModule, UsersHttpModule, UsersGraphqlModule, UsersWsModule, ],)]pub struct ApiModule;HTTP — the direct case
Section titled “HTTP — the direct case”HTTP is the simplest because guards run on the actual request before the handler:
#[controller(path = "/users")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct UsersController { /* ... */ }AbilityGuard reads the Claims an AuthnGuard attached, calls
AuthzAbility::define(&actor, &mut builder), and inserts
Arc<Ability> into request extensions. The Authorize shaper later
installs that ability as a task-local via with_ability so
Repo::scoped sees it.
GraphQL — marker guards over a shared dispatch
Section titled “GraphQL — marker guards over a shared dispatch”A GraphQL POST /graphql is one HTTP request that multiplexes many
operations, so per-operation auth cannot run as plain HTTP guards. What
you write is unchanged in spirit: import AuthzGraphqlModule, bind the
marker on the resolver, and declare each operation’s posture:
#[resolver]#[use_guards(AuthzGuard)]pub struct UsersResolver { /* ... */ }
#[operations]impl UsersResolver { /* #[authorize(Action, Entity)] or #[public] per op */ }AuthzGuard is the app’s AbilityGuard<AuthzAbility> alias, and it is what
#[use_guards] binds: it has a check_graphql, so it satisfies the
GraphqlGuard bound #[resolver] emits. An AuthnGuard does not belong
in that list — it has no check_graphql, so on a resolver it would run
nothing; the bridge below already authenticated the request in band.
The module those three providers live in is app code, like the rest of
authz/: nestrs g graphql <feature> writes it into
crates/features/src/authz/graphql/ the first time a resource gets a
GraphQL adapter, and imports it from that adapter’s module.rs. The
four files are the ones sketched below —
demo/crates/features/src/authz/graphql/
is the same thing under version control.
That’s the whole resolver-side surface. HTTP guards run on &mut Request
before the handler; they are the auth chain. GraphQL instead runs
authn/ability in-band per operation, seeds the ability into per-operation
context, and forwards the principal with forward_principal!(Claims).
That forward binds nothing and gates on nothing, because it cannot fire without
the chain that feeds it: it copies Claims off the request only when the authn
guard has attached them, and that guard is reachable only through the module
that declares it. Omit the authz module and the chain never runs, so there is
nothing to copy — the schema is anonymous because it was never authenticated,
not because a marker was missing.
Under the hood: the operation bridge
Section titled “Under the hood: the operation bridge”The seeding is done by GraphqlAbilityBridge, registered as the
dyn GraphqlOperationGuard:
#[injectable]pub struct GraphqlAbilityBridge<A: Guard, G: Guard> { #[inject] auth: Arc<A>, #[inject] ability: Arc<G>,}
impl<A: Guard, G: Guard> GraphqlOperationGuard for GraphqlAbilityBridge<A, G> { fn before<'a>(&'a self, req: &'a mut Request) -> BoxFuture<'a, ()> { /* ... */ } fn around<'a>(&'a self, req: &'a Request, inner: BoxFuture<'a, Response>) -> BoxFuture<'a, Response> { /* ... */ }}before runs the same HTTP guard chain (AuthnGuard, then
AbilityGuard) on the GraphQL request. /graphql carries the Public
marker — one endpoint, posture declared per operation — so an anonymous
caller is admitted here and takes the ability guard’s visitor branch.
The line is held one step later, by the operation’s own posture:
#[authorize(...)] refuses an ability with no principal behind it
(UNAUTHENTICATED), whatever define_visitor granted, and only
#[public] operations read those grants.
around installs the resulting ability via with_ability for the
duration of the operation. The marker itself is a
dyn GraphqlResolverGuard that fails closed if Arc<Ability> is
absent from the operation’s data:
async fn check(&self, ctx: &Context<'_>) -> Result<()> { match ctx.data_opt::<Arc<Ability>>() { Some(_) => Ok(()), None => Err(Error::new("unauthenticated") .extend_with(|_, e| e.set("code", "UNAUTHENTICATED"))), }}The bridge also registers a LoaderScope as dyn BatchContext so
dataloaders that fan out across batches snapshot the ability + pool
executor per batch — relations stay scoped without the resolver
threading anything.
WebSockets — per-message scope
Section titled “WebSockets — per-message scope”A WS connection is one HTTP upgrade; messages arrive over a long- lived socket and dispatch into separate handlers. Each message needs its own ambient ability:
#[module( imports = [AuthzModule, WsModule], providers = [WsDataContext as dyn SocketContext],)]pub struct AuthzWsModule;WsDataContext is a dyn SocketContext that installs the pool
executor and the ambient Ability for every message — no per-message
transaction (mutations are explicit in your service), no shared state
across messages.
Unlike GraphQL, WS has no marker type and no bridge — a gateway is
EdgePosture::Guarded, so its upgrade already carried the real chain and there
is nothing to re-run in band. It reuses the HTTP Guard trait directly. The gateway struct binds the real guards; because the
upgrade is an HTTP GET, they run once on it, and the access graph
validates them like any HTTP binding (omit AuthzWsModule ⇒ the
guards are unreachable ⇒ boot fails). An optional per-message check
binds a real Guard beside the message:
#[gateway(path = "/ws")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct ChatGateway { /* ... */ }
#[messages]impl ChatGateway { #[subscribe_message("users.list")] #[authorize(Read, users::Entity)] async fn list(&self) -> Result<Vec<User>, ServiceError> { Ok(self.svc.list().await?.iter().map(User::from).collect()) }}AuthnGuard + AuthzGuard run at the upgrade — those are real HTTP
guards on a real GET, so they attest HttpGuard, not WsGuard;
#[gateway] emits that bound, and WsGuard is what a guard bound beside
a #[subscribe_message] owes instead. The upgrade’s
task-locals unwind before any message handler runs, so WsDataContext
re-seeds the executor and ability around each message.
Above that, a message declares its posture like an operation on any
other transport, and it is mandatory: #[authorize(Action, Entity)] or
#[public], or it does not compile. #[authorize] emits the class gate
(nest_rs::authz::ws::authorize) before the payload is deserialized and
the reply mask afterwards — so a handler answering with entity rows
writes no masking call. It fails closed when no ambient ability is
present, which is exactly the state of a gateway whose app imported
AuthzModule instead of AuthzWsModule.
The mask acts on the serialized reply, so a withheld column is absent from the frame rather than an error — HTTP’s behaviour, not GraphQL’s, because an envelope promises no schema. See WebSockets / Messages for the full contract.
An optional per-message #[use_guards(...)] still adds event-level
checks; such a guard runs Guard::check_ws_message and must declare
WsGuard — see WebSockets / Guards.
MCP — same shape as HTTP
Section titled “MCP — same shape as HTTP”AuthzMcpModule registers AuthzMcpBridge
(McpAbilityBridge<AuthnGuard, AuthzGuard>) as dyn McpOperationGuard, plus
McpDataContext as dyn McpToolContext — on each MCP HTTP request it runs the same A then G chain
controllers use (run_ability_chain, the same function the GraphQL bridge
calls), then installs the caller’s ambient Ability for the tool call. Either
leg’s denial is returned as raised, so a 401, a 403 and a throttler’s
429 + Retry-After all reach the client intact. Import AuthzMcpModule
beside the feature module, the same shape as the other transports.
#[injectable]pub struct McpAbilityBridge<A: Guard, G: Guard> { #[inject] auth: Arc<A>, #[inject] ability: Arc<G>,}The ability is installed by the guard’s around, which runs inside rmcp’s
spawned dispatch — the same seam GraphqlOperationGuard uses, so “who scopes
the operation” has one answer on both transports. A tool body is therefore
scoped whether or not the app also registered McpDataContext (the data context
adds the executor and the per-operation transaction; it is not what installs the
ability). Any custom McpOperationGuard can wrap the dispatch the same way —
the default impl is a pass-through:
// Snapshot on the request, while it still exists…fn capture(&self, req: &Request) -> Option<Captured> { req.extensions() .get::<Arc<Ability>>() .cloned() .map(|ability| ability as Captured)}
// …install it inside rmcp's dispatch, where the request is long gone.fn around<'a>( &'a self, captured: &'a Captured, inner: BoxFuture<'a, OperationOutcome>,) -> BoxFuture<'a, OperationOutcome> { Box::pin(async move { match captured.clone().downcast::<Ability>() { Ok(ability) => with_ability(ability, inner).await, Err(_) => inner.await, } })}Both default to “install nothing” (capture returns None, around is a
pass-through), so an existing guard is unaffected. The split is the same one
McpToolContext uses — capture on the request, install inside the dispatch — so
the two seams in the crate have one shape.
With no bridge registered the endpoint falls back to the global guard pool
(use_guards_global(...)) rather than going straight to deny-all — so a global
ThrottlerGuard rate-limits tool calls, exactly as it does on /graphql. With
no pool either, /mcp stays deny-all: the fallback only ever widens what the
app declared, and unlike /graphql the MCP endpoint carries no Public marker,
so a pooled AuthnGuard still refuses an anonymous tool call.
What that endpoint runs is check_http — it holds a request, not an operation.
A pooled guard’s check_mcp runs in the per-operation chain, which folds
the pool whether or not a bridge is registered, so a global guard written
against MCP gates every tool call even when the bridge already authenticated the
caller who sent it.
That fallback is compiled in by nest-rs-guards’ mcp feature, which
nestrs g mcp turns on. Without it there is nothing to fall back to: the boot
line stays no operation guard registered — mcp endpoint is deny-all mode="deny_all" and a pooled guard is never consulted. Fail-closed either way —
check the boot line for mode="global_guard_pool" when the pool is what you
meant to gate with.
Public handlers — opt out per transport
Section titled “Public handlers — opt out per transport”A handler is “public” by not binding the transport’s authz
module’s guard. The whole module import then becomes optional — the
app lists AuthzModule only if other handlers need it.
#[get("/health")]#[public]async fn health(&self) -> &'static str { "ok" }#[public] makes AuthnGuard non-rejecting (anonymous requests pass
through with no claims). The AbilityGuard then builds an empty
Ability for the visitor; the row-level filter returns nothing
unless AbilityFactory granted the visitor branch explicitly. See
Authorization for the visitor-rule
pattern.
Going further
Section titled “Going further”- Authorization — the
AbilityFactorythe bridges all read. - Row-level filtering
— what runs inside
with_ability(...)after the bridge installs. - Response masking — the HTTP-side shaper that wraps each request.