Guards
Pre-handler gates — decide whether a request runs, attach context, and declare once across HTTP, GraphQL, WS and MCP.
This page continues blog from
Modules and
Providers — the composed stage before you
open the tutorial posts/ feature. Once PostsHttpModule mounts
PostsController, guards decide who reaches each handler — and often
attach the principal the handler reads back via Ctx<T>.
Install
Section titled “Install”cargo add nest-rs --features guardsThe http feature turns this on, and http is a default — name it explicitly only in a headless app built with default-features = false, where no transport implies it.
What a guard does
Section titled “What a guard does”A guard runs before the handler. It sees the request first and either
lets it through (Ok(())) or short-circuits with a typed Denial the
framework maps to 401 / 403 / 429. One trait, four transports — the same
#[injectable] impl is reachable from HTTP, GraphQL, WS and MCP through the
the request lifecycle.
Guard ships in
nest-rs-guards. It extends
Layer
so guards plug into dedup-by-TypeId and declaration-order chaining.
A minimal custom guard
Section titled “A minimal custom guard”use nest_rs::guards::prelude::*;use nest_rs::http::poem::Request as HttpRequest;
#[injectable]#[derive(Default)]pub struct RateLimitGuard;
impl Layer for RateLimitGuard {}
#[async_trait]impl Guard for RateLimitGuard { async fn check_http(&self, req: &mut HttpRequest) -> Result<(), Denial> { if over_quota(req) { return Err(Denial::rate_limited(60, "rate limited")); } Ok(()) }}
impl HttpGuard for RateLimitGuard {}That last line attests the edge you implemented — see A guard must declare the transports it checks below. Without it, binding this guard on a route is a compile error.
A guard is an #[injectable] provider listed in some module’s
providers = [...]. The access graph (see
Providers)
ensures every controller / resolver / gateway that references it imports
a module that builds it.
The trait carries one method per transport. Unimplemented transports
inherit Ok(()) — that means “doesn’t apply here”, not “skip security”.
Other guards on the same route still run.
| Method | Transport | Request shape |
|---|---|---|
check_http | HTTP | &mut HttpRequest — gate and mutate (attach context in extensions) |
check_graphql | GraphQL | &GraphqlOperationContext — read-only; seed context upstream |
check_ws_message | WS | per inbound message, after the upgrade |
check_mcp | MCP | per operation, inside rmcp’s dispatch — the ability is ambient |
#[async_trait]pub trait Guard: Layer { async fn check_http(&self, _req: &mut HttpRequest) -> Result<(), Denial> { Ok(()) } async fn check_graphql(&self, _op: &GraphqlOperationContext<'_>) -> Result<(), Denial> { Ok(()) } async fn check_ws_message(&self, _client: &WsClient, _event: &str, _data: &Value) -> Result<(), Denial> { Ok(()) } async fn check_mcp(&self, _ctx: &McpOperationContext<'_>) -> Result<(), Denial> { Ok(()) }}Secure the post API
Section titled “Secure the post API”After Modules, PostsController
lives under posts/http/. Binding guards on the struct is how you gate
every route on that controller — and declare the auth modules the access
graph must reach.
use std::sync::Arc;use nest_rs::authz::Read;use nest_rs::http::{controller, routes};use nest_rs::seaorm::Bind;use nest_rs::http::poem::web::{Json, Path};use nest_rs::http::poem::Result;use uuid::Uuid;
use crate::authn::AuthnGuard;use crate::authz::AuthzGuard;use crate::posts::{AdminGuard, Post, PostsService};
#[controller(path = "/posts")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct PostsController { #[inject] svc: Arc<PostsService>,}
#[routes]impl PostsController { #[get("/:id")] async fn get(&self, post: Bind<Read, PostsService>) -> Json<Post> { Json(Post::from(&*post)) }
#[delete("/:id")] #[use_guards(AdminGuard)] async fn delete(&self, id: Path<Uuid>) -> Result<()> { self.svc.delete(*id).await }}AdminGuard runs on top of the controller’s two guards. Per-handler
binding is additive — it does not replace the controller list.
A route with no guards stays open, but silence is not a decision: the boot
reports every route that binds no guard and is not marked #[public] in a
single warn naming them (below). Declaring
the posture — a guard, or #[public] — is what makes it intentional.
The HTTP adapter imports the auth bridges transitively — same pattern as
UsersHttpModule in the repo:
#[module( imports = [PostsModule, AuthzModule], providers = [PostsController],)]pub struct PostsHttpModule;#[module( imports = [nest_rs::authn::AuthnModule::for_root(None)], providers = [AuthnStrategy, AuthnGuard],)]pub struct AuthnModule;#[module( imports = [AuthnModule], providers = [AuthzAbility, AuthzGuard],)]pub struct AuthzModule;AuthzGuard is provided here rather than under authz/http/ because
AbilityGuard answers every transport — it implements check_http,
check_graphql, check_ws_message and check_mcp. A resolver, a gateway and
an MCP host bind the same type, so none of them imports an HTTP adapter to
reach it.
flowchart TB
REQ[HTTP request] --> AG[AuthnGuard]
AG --> AZ[AuthzGuard]
AZ --> AD{AdminGuard?}
AD -->|delete only| ADG[AdminGuard]
AD -->|other routes| H[PostsController handler]
ADG --> H
AG -. "insert Claims" .-> H
AZ -. "install Ability" .-> H
Solid arrows are the guard chain on GET /posts/:id. DELETE adds
AdminGuard on top. AuthnGuard attaches Claims; AuthzGuard builds
the ambient Ability handlers and Bind read through.
The three scopes
Section titled “The three scopes”A guard binds at one of three scopes. The container resolves all three from the same provider — declare once, choose where it runs.
| Scope | Binding | Resolved by |
|---|---|---|
| Global | App::builder().use_guards_global([guard::<AuthnGuard>()]) in main | Per-route shaper, post-routing (self-mount edge for gateways) |
| Controller / Resolver / Gateway | #[use_guards(AuthnGuard, AuthzGuard)] on the struct | Container, at mount |
| Per-handler | #[use_guards(AdminGuard)] beside a verb / #[query] / #[subscribe_message] | Container, at mount |
Multiple guards in one attribute run in declaration order, outermost first — the first listed sees the request before the second.
use nest_rs::guards::{AppBuilderGuardsExt, guard};use features::authn::AuthnGuard;use features::authz::AuthzGuard;
App::builder() .use_guards_global([guard::<AuthnGuard>(), guard::<AuthzGuard>()]) .module::<BlogModule>()Across scopes the chain composes global → controller → handler,
outermost first. Layer::priority is an optional tiebreaker when
declaration order cannot express intent — most guards leave it at 0.
flowchart TB G[global guards] --> C[controller guards] C --> H[handler guards] H --> R[handler]
Where guards run in the chain
Section titled “Where guards run in the chain”Guards are pooled by TypeId and execute once per request, post-routing at the RouteShaper — so a global guard still reads #[public] route data, and a guard denial short-circuits before the route’s interceptors run. The full family-by-family nesting (guards vs pipes vs interceptors vs filters, and why DbContext wraps the guards) lives on the request lifecycle.
Attach context and route metadata
Section titled “Attach context and route metadata”Guards often do more than gate — they produce values the handler needs.
Attach on the way in; read with Ctx<T>:
#[async_trait]impl Guard for AuthnGuard<MyStrategy> { async fn check_http(&self, req: &mut HttpRequest) -> Result<(), Denial> { let claims = self.strategy.authenticate(req).await?; req.extensions_mut().insert(claims); Ok(()) }}
impl HttpGuard for AuthnGuard<MyStrategy> {}
#[get("/me")]async fn me(&self, auth: Ctx<Claims>) -> Json<Post> { Json(self.svc.find_by_author(auth.sub).await?)}Ctx<T> rejects with 500 if the value is absent — a missing context
means the guard that should have set it never ran. Store an Arc<_> if
the value is large.
AuthzGuard reads Claims left by AuthnGuard to build the ambient
Ability. That is why order matters:
#[use_guards(AuthnGuard, AuthzGuard)], not the reverse.
For per-route policy — required roles, rate-limit quotas — #[meta(...)]
attaches a typed payload; Reflector reads it inside the guard:
#[get("/admin/audit")]#[use_guards(AuthnGuard, RolesGuard)]#[meta(Roles(&["admin", "auditor"]))]async fn audit(&self) -> &'static str { "ok" }Because the global pool runs post-routing at the RouteShaper (above), a
global guard reads #[meta(...)] too — the route data is attached before
the chain runs, exactly as it is for #[public] below.
#[public] uses the same channel. The framework does not act on it —
each guard decides what “public” means (AuthnGuard may authenticate when
a token is present but not reject anonymous callers; AuthzGuard may
still apply visitor rules). Marking a route public does not strip its
guards; it tells them to relax.
Declare on the provider
Section titled “Declare on the provider”The Layer System dedups every layer by
TypeId across
the three sites. When the same guard is declared at several levels, the
broadest site wins; narrower declarations log a debug line at boot
(deduped once on nest_rs::layers) but never re-execute.
App::builder() .use_guards_global([guard::<AuthnGuard>(), guard::<AuthzGuard>()]) .module::<BlogModule>()#[controller(path = "/posts")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct PostsController { /* ... */ }AuthnGuard and AuthzGuard run exactly once per request, not twice.
The controller’s two extra lines cost zero runtime overhead and one debug
line at boot.
Re-running one on purpose: #[force_guards]
Section titled “Re-running one on purpose: #[force_guards]”Dedup is what makes redeclaring free, so the one thing it takes away is
running a guard twice on purpose. #[force_guards(...)] is the opt-out,
and it takes the same list #[use_guards] does:
#[patch("/:id/publish")]#[force_guards(FreshTokenGuard)] // replay, even though the pool ran it#[authorize(Update, PostEntity)]async fn publish(&self, post: Bind<Update, PostsService>) -> Result<Json<Post>> { /* ... */ }Reach for it when a guard’s answer can go stale between the pool’s site and the handler — a token freshness re-check on a sensitive mutation, a quota counted per operation rather than per request. Everywhere else it is a second execution nobody asked for, so the default stays dedup.
Per operation only. #[use_guards] binds on the struct or the method;
#[force_guards] is read only from the method, because re-running a guard is a
decision about one operation rather than a posture a whole controller takes.
Writing it on the struct is cannot find attribute 'force_guards' in this scope.
Declare layers on the provider that needs them, not only at the app
boundary. A crates/features controller is designed to be portable — if
it relies only on use_guards_global([...]) to be secure, forgetting
that line in a second app turns every route public. The compiler will not
complain — but boot now does: with no global guard pool active, every
route that binds no controller/method guard and is not marked #[public]
is reported in a single warn on nest_rs::layers (an implicit access
decision). The fix is the same either way: redeclare on PostsController
to bind the policy to the code that needs it, or mark genuinely-open
routes #[public] on purpose. The same rule applies to interceptors and
filters.
flowchart TB MAIN["main: use_guards_global([AuthnGuard, AuthzGuard])"] PC["PostsController: #[use_guards(AuthnGuard, AuthzGuard)]"] MAIN -. "dedup — runs once" .-> RUN[per-request chain] PC -. "debug at boot, zero extra cost" .-> RUN
One trait, four transports
Section titled “One trait, four transports”A guard implements the check_* methods that apply to it, and the framework
runs each at its own seam:
| Method | Runs | Bound by |
|---|---|---|
check_http | post-routing on &mut HttpRequest, before the handler | #[controller] / #[routes], and the #[gateway] struct |
check_graphql | in the resolver’s per-operation chain, and in front of the federation root fields | #[resolver] / #[operations] |
check_ws_message | once per inbound envelope, after the upgrade | beside a #[subscribe_message] |
check_mcp | once per #[tool] / #[prompt] operation, inside rmcp’s dispatch | #[mcp] host / #[tools] operation |
use_guards_global([...]) reaches every transport at every method it
implements. /graphql and /mcp are Exempt at the HTTP edge and gate
in-band instead, so a global ThrottlerGuard still rate-limits a tool call
through check_http, and a global guard written against check_mcp still
refuses one.
The three multiplexed transports need one thing on top: a bridge above the
resolver, the gateway and the tool, because their per-operation context does
not carry the poem request. The bridge re-runs the HTTP chain once and
re-installs the ambient Ability for the operation; below it you bind ordinary
guards as usual. That wiring — AuthzGraphqlModule, AuthzWsModule,
AuthzMcpModule, forward_principal!, and what each one gates — lives on
Authorization on GraphQL, WS & MCP,
with the connection-vs-message split on
WebSocket guards.
A guard must declare the transports it checks
Section titled “A guard must declare the transports it checks”Every check_* above defaults to Ok(()) — right as “this guard does not
apply to that transport”, and the reason a guard bound where it has no
entry used to compile and pass everything. Four marker traits close that:
| Bound at | Requires | Because it runs |
|---|---|---|
#[controller] / #[routes], #[gateway] struct | HttpGuard | check_http |
#[resolver] / #[operations] | GraphqlGuard | check_graphql |
beside a #[subscribe_message] | WsGuard | check_ws_message |
#[mcp] host / #[tools] operation | McpGuard | check_mcp |
Declare the marker next to the check_* it attests — impl GraphqlGuard for RequireAdmin {} — and the binding compiles. Bind a guard that lacks
one and it does not, with a diagnostic naming the missing method and the
two ways out:
error[E0277]: `ThrottlerGuard` does not check GraphQL operations | | #[use_guards(ThrottlerGuard)] | ^^^^^^^^^^^^^^ this guard has no `check_graphql` = note: override `check_graphql`, then declare `impl GraphqlGuard for ThrottlerGuard {}`; or bind this guard on an HTTP `#[controller]` / `#[routes]` insteadThe marker never proves the method exists — the Ok(()) default gives
every guard all four. It proves you declared that this guard checks
this edge. An empty impl Guard for X {} satisfies the compiler and
passes every request; the marker is what turns that into an error at the
#[use_guards] line rather than a chain entry that guards nothing.
A guard bound globally takes no marker, and that is a deliberate hole
rather than an oversight. A global guard serves whichever edges it
implements — its check_http on the request, its check_graphql /
check_mcp on the operation. Requiring HttpGuard there would refuse a
GraphQL-only guard, and the alternative is four global lists where the
developer wrote one. So an empty guard registered globally still passes everything: the
markers bind #[use_guards] sites, not the pool.
A guard on the #[gateway] struct attests HttpGuard, not WsGuard —
the upgrade is an HTTP GET, and WsGuard belongs to the per-message scope.
See Security / per-transport bridges for the full HTTP / GraphQL / WS wiring, and WebSockets / Guards for the two WS scopes (connection vs message).
Going further
Section titled “Going further”- Interceptors — the sibling layer: it wraps the handler instead of gating it, and dedups the same way.
- The request lifecycle — where a guard sits among the other three layer families.
- Security —
AuthnGuard,AbilityGuard, and the chain they compose into.