Guards
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>.
Four steps on this page:
- What a guard is — one trait, three transports,
Ok(())orDenial. - Secure the post API —
#[use_guards(AuthnGuard, AuthzGuard)]onPostsController, with the modules that provide those guards. - Three scopes — global, controller, per-handler; additive, not substitutive.
- Declare on the provider — dedup by
TypeId; redeclare at the controller so security travels with the feature.
flowchart LR A["① Guard trait<br/>gate or pass"] --> B["② PostsController<br/>AuthnGuard + AuthzGuard"] B --> C["③ three scopes<br/>global → controller → handler"] C --> D["④ declare on provider<br/>dedup, access graph"]
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, three transports — the same
#[injectable] impl is reachable from HTTP, GraphQL, and WS 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(()) }}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 | &GraphqlContext — read-only; seed context upstream |
check_ws_message | WS | per inbound message, after the upgrade |
#[async_trait]pub trait Guard: Layer { async fn check_http(&self, _req: &mut HttpRequest) -> Result<(), Denial> { Ok(()) } async fn check_graphql(&self, _ctx: &GraphqlContext<'_>) -> Result<(), Denial> { Ok(()) } async fn check_ws_message(&self, _client: &WsClient, _event: &str, _data: &Value) -> 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 poem::web::{Json, Path};use 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<PostsService, Read>) -> 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 by intent; omitting #[use_guards(...)] is
the explicit decision.
The HTTP adapter imports the auth bridges transitively — same pattern as
UsersHttpModule in the repo:
#[module( imports = [PostsModule, AuthzHttpModule], providers = [PostsController],)]pub struct PostsHttpModule;#[module( imports = [nest_rs_authn::AuthnModule::for_root(None)], providers = [AppJwtStrategy, AuthnGuard],)]pub struct AuthnModule;#[module( imports = [AuthzModule], providers = [AuthzGuard],)]pub struct AuthzHttpModule;Directorycrates/features/src/
Directoryauthn/
- module.rs (AuthnModule — provides AuthnGuard)
- guard.rs
Directoryauthz/
- module.rs (AuthzModule — AppAbility)
Directoryhttp/
- module.rs (AuthzHttpModule — provides AuthzGuard)
- guard.rs
Directoryposts/
Directoryhttp/
- module.rs (PostsHttpModule — imports AuthzHttpModule)
- controller.rs (#[use_guards(AuthnGuard, AuthzGuard)])
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 crate::authn::AuthnGuard;use crate::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(()) }}
#[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 scopes. When the same guard is declared at several levels, the
broadest scope 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.
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
HTTP, GraphQL, WebSockets, and MCP
Section titled “HTTP, GraphQL, WebSockets, and MCP”For most apps the global HTTP declaration is enough: GraphQL
POST /graphql, POST /mcp and the WS upgrade are all HTTP requests that pass
through the global chain. /graphql and /mcp are Exempt at the HTTP edge
and gate in-band instead, but with no bridge registered each folds the same
global pool per operation — so use_guards_global([...]) reaches every
transport, and a global ThrottlerGuard rate-limits a tool call.
Each transport method runs at a different seam:
check_http— on&mut HttpRequest, post-routing at the route site (before the handler, so it can read#[public]route data).check_graphql— inside the resolver’s per-operation chain (context is read-only).check_ws_message— once per inbound message, after the connection upgrade.
GraphQL, WS and MCP need a bridge one level above the resolver / gateway / tool because the per-operation context does not carry the poem request:
- GraphQL — a
GraphqlOperationGuard(e.g.GraphqlAbilityBridge) re-runs the HTTP guard chain onPOST /graphqland installsAbilityfor the operation. Per-resolver gating uses a marker likeGraphqlAuthnGuard. - MCP — an
McpOperationGuard(e.g.McpAbilityBridge), the exact twin: same chain, samearoundinstallingAbility— here inside rmcp’s spawned dispatch. With none registered the endpoint falls back to the global pool, and with no pool either it is deny-all. - WS — a
SocketContext(e.g.WsDataContext) captures pool +Abilityon upgrade and re-installs them around each message. Per-message gating goes throughcheck_ws_message.
On a resolver, #[use_guards(...)] carries a marker guard — a
struct whose job is to declare a dependency on the seeded context so
the access graph can validate it. Omitting the matching authz module
fails boot with a clear error naming the missing guard.
#[resolver]#[use_guards(GraphqlAuthnGuard)]pub struct PostResolver { /* ... */ }WebSockets need no marker type — the upgrade is a real HTTP GET, so
the gateway struct binds the real HTTP guards; they run once at
the upgrade and are access-graph-validated the same way (omit
AuthzWsModule and the guards are unreachable ⇒ boot fails). A
per-message check binds a real Guard beside the
#[subscribe_message]; it runs through check_ws_message — e.g.
AuthzGuard (the app’s AbilityGuard<AppAbility> alias) denies,
fail-closed, when no ambient ability is present.
#[gateway(path = "/posts/live")]#[use_guards(AuthnGuard, AuthzGuard)] // real guards — run once, at the upgradepub struct PostGateway { /* ... */ }
#[messages]impl PostGateway { #[subscribe_message("comment")] #[use_guards(AuthzGuard)] // re-checked per message via check_ws_message async fn comment(&self, msg: CommentInput) -> Reply { /* ... */ }}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”- Providers — the access graph also governs
#[use_guards(...)]bindings. - Interceptors — the sibling layer; wraps the handler instead of gating it. Same dedup rules apply.
- Pipes — input transform / validation.
- Error handling — typed catch for handler errors.
- Security —
AuthnGuard,AbilityGuard, the full chain.
Built by YV17labs