Skip to content

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:

  1. What a guard is — one trait, three transports, Ok(()) or Denial.
  2. Secure the post API#[use_guards(AuthnGuard, AuthzGuard)] on PostsController, with the modules that provide those guards.
  3. Three scopes — global, controller, per-handler; additive, not substitutive.
  4. 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"]

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.

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(())
}
}

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&GraphqlContext — read-only; seed context upstream
check_ws_messageWSper 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(()) }
}

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

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

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

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

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

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.

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

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 on POST /graphql and installs Ability for the operation. Per-resolver gating uses a marker like GraphqlAuthnGuard.
  • MCP — an McpOperationGuard (e.g. McpAbilityBridge), the exact twin: same chain, same around installing Ability — 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 + Ability on upgrade and re-installs them around each message. Per-message gating goes through check_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 upgrade
pub 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).

  • 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.
  • SecurityAuthnGuard, AbilityGuard, the full chain.

Built by YV17labs