Skip to content

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>.

Terminal window
cargo add nest-rs --features guards

The 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.

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.

crates/features/src/posts/guard.rs
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.

MethodTransportRequest shape
check_httpHTTP&mut HttpRequest — gate and mutate (attach context in extensions)
check_graphqlGraphQL&GraphqlOperationContext — read-only; seed context upstream
check_ws_messageWSper inbound message, after the upgrade
check_mcpMCPper operation, inside rmcp’s dispatch — the ability is ambient
nest_rs::guards::Guard
#[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(()) }
}

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.

crates/features/src/posts/http/controller.rs (from the demo)
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:

crates/features/src/posts/http/module.rs (from the demo)
#[module(
imports = [PostsModule, AuthzModule],
providers = [PostsController],
)]
pub struct PostsHttpModule;
crates/features/src/authn/module.rs (from the demo)
#[module(
imports = [nest_rs::authn::AuthnModule::for_root(None)],
providers = [AuthnStrategy, AuthnGuard],
)]
pub struct AuthnModule;
crates/features/src/authz/module.rs (from the demo)
#[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.

A guard binds at one of three scopes. The container resolves all three from the same provider — declare once, choose where it runs.

ScopeBindingResolved by
GlobalApp::builder().use_guards_global([guard::<AuthnGuard>()]) in mainPer-route shaper, post-routing (self-mount edge for gateways)
Controller / Resolver / Gateway#[use_guards(AuthnGuard, AuthzGuard)] on the structContainer, 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.

apps/blog/src/main.rs
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]

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.

Guards often do more than gate — they produce values the handler needs. Attach on the way in; read with Ctx<T>:

the guard writes, the handler reads
#[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:

crates/features/src/posts/http/controller.rs
#[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.

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.

apps/blog/src/main.rs
App::builder()
.use_guards_global([guard::<AuthnGuard>(), guard::<AuthzGuard>()])
.module::<BlogModule>()
crates/features/src/posts/http/controller.rs (from the demo)
#[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:

crates/features/src/posts/http/controller.rs
#[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

A guard implements the check_* methods that apply to it, and the framework runs each at its own seam:

MethodRunsBound by
check_httppost-routing on &mut HttpRequest, before the handler#[controller] / #[routes], and the #[gateway] struct
check_graphqlin the resolver’s per-operation chain, and in front of the federation root fields#[resolver] / #[operations]
check_ws_messageonce per inbound envelope, after the upgradebeside a #[subscribe_message]
check_mcponce 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 atRequiresBecause it runs
#[controller] / #[routes], #[gateway] structHttpGuardcheck_http
#[resolver] / #[operations]GraphqlGuardcheck_graphql
beside a #[subscribe_message]WsGuardcheck_ws_message
#[mcp] host / #[tools] operationMcpGuardcheck_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:

Terminal window
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]` instead

The 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).

  • 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.