Guards
A WebSocket connection has two gates a Guard can sit on, and they
answer different questions. The upgrade is an HTTP GET carrying
headers, cookies, and a query string — the moment to ask “may this
peer open a connection at all?” The dispatch loop then runs in a
task on the upgraded socket — the moment to ask “may this connection
fire this event?” One Guard trait covers both; the difference is
where you bind it.
Two scopes, one trait
Section titled “Two scopes, one trait”nest_rs_guards::Guard is the same trait used for HTTP routes and
GraphQL operations. It exposes three optional methods — check_http,
check_graphql, check_ws_message — and a guard implements the ones
it cares about. WebSockets reach both:
| Scope | Decorator location | Trait method run |
|---|---|---|
| Connection | #[use_guards(...)] on the gateway struct | check_http (on the upgrade Request) |
| Message | #[use_guards(...)] next to a #[subscribe_message] | check_ws_message (on each envelope) |
A guard listed at gateway scope rejects the upgrade — the response
goes back as HTTP, no socket is opened, no on_connect fires. A guard
listed at message scope rejects the dispatch — the connection
stays open, the envelope is replaced with an error frame on the same
event name, and a warn! lands in nest_rs::ws.
Connection-level: the upgrade gate
Section titled “Connection-level: the upgrade gate”A connection-level guard runs once per connection, against the upgrade request, like any HTTP guard:
use nest_rs_ws::{gateway, messages};use crate::auth::AuthGuard;
#[gateway(path = "/ws")]#[use_guards(AuthGuard)]pub struct ChatGateway { #[inject] svc: Arc<RoomService>,}AuthGuard reads the Authorization header (or a cookie, or a query
parameter — your strategy decides), verifies the token, and attaches
the principal to the request. A failed check returns the same
Denial shape an HTTP route would return (401, 403, etc.) — the
client receives the response, and no socket is ever opened.
Because the SocketContext seam runs after connection-level
guards and captures the post-guard request state, anything those
guards attach (the principal, an ambient ability, the database
executor) is re-installed around every message dispatch on the
connection. Without that capture, a per-message guard like
AuthzGuard — whose check_ws_message reads current_ability() and
fails closed — would see None once the upgrade request had unwound.
Per-message: the envelope gate
Section titled “Per-message: the envelope gate”A per-message guard runs every time the envelope’s event matches a
handler the guard is attached to:
use nest_rs_ws::{messages, WsClient};use nest_rs_ws::serde_json::Value;use nest_rs_core::{Layer, injectable};use nest_rs_guards::{Denial, Guard};use async_trait::async_trait;
#[injectable]#[derive(Default)]pub struct ModeratedGuard;
impl Layer for ModeratedGuard {}
#[async_trait]impl Guard for ModeratedGuard { async fn check_ws_message( &self, _client: &WsClient, _event: &str, data: &Value, ) -> Result<(), Denial> { match data.get("author").and_then(Value::as_str) { Some("banned") => Err(Denial::forbidden("author `banned` is not allowed")), _ => Ok(()), } }}Bind it beside the message it gates:
#[messages]impl ChatGateway { #[subscribe_message("message")] #[use_guards(ModeratedGuard)] async fn on_message(&self, msg: SendMessage) { self.svc.record(msg); }}A denial surfaces as a wire-shaped error frame on the rejected event:
> {"event":"message","data":{"author":"banned","text":"hi"}}< {"event":"message","data":{"error":"author `banned` is not allowed"}}The connection stays open; the client can fire other events.
Mount-time dedup
Section titled “Mount-time dedup”The #[messages] macro composes each event’s guard chain at mount
time: app-global guards first, then any listed on the gateway, then
any on the message itself. Duplicates are removed by TypeId, so a
guard listed both as a global and on a specific message runs once
per dispatch — there is no cost to redeclaring on a provider. Per the
layers-declare-on-provider rule, do redeclare: a feature crate
moved to another app may not inherit the same globals.
ConnUpgrade ── check_http ── on_connect │ ├── Envelope ── check_ws_message (global ∪ gateway ∪ message, dedup) ── dispatch └── Envelope ── check_ws_message (...) ── dispatch └── on_disconnect ── closeA guard chain that returns Err(Denial) short-circuits at the first
denial — later guards do not run, the handler is skipped, the error
frame is sent.
Authentication and authorization
Section titled “Authentication and authorization”WebSockets reuse the HTTP pair directly — there is no WS-specific
guard type. The gateway struct binds
#[use_guards(AuthGuard, AuthzGuard)]; both run once on the upgrade
(an HTTP GET) and are access-graph-validated like any HTTP binding:
omit AuthzWsModule and those guards are unreachable, so boot fails.
Because the upgrade’s task-locals unwind before message handlers run,
WsDataContext — the SocketContext bridge AuthzWsModule provides
— re-seeds the executor and ability around each message. Binding
AuthzGuard beside a #[subscribe_message] adds a per-message check
through check_ws_message: it denies, fail-closed, when no ambient
ability is present.
See Security for the strategy + ability composition; the
WS-specific bit — WsDataContext — lives in nest_rs_seaorm behind
the ws feature (packaged by AuthzWsModule), alongside its HTTP and
GraphQL sibling bridges.
Going further
Section titled “Going further”- Security — the authn + authz wiring story across HTTP, GraphQL, and WS.
- Messages — the wire shape of a denial reply and the return contract.
Reference
Section titled “Reference”Guard— thecheck_http/check_graphql/check_ws_messagetriple.EventLayerTable— the per-gateway, per-event guard table the dispatcher iterates.apps/live/src/chat/guard.rs— a per-message guard worked end to end.