WebSocket guards
Connection-level guards on the upgrade and per-message guards on each envelope — one Guard trait, two scopes.
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,
and 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::AuthnGuard;
#[gateway(path = "/ws")]#[use_guards(AuthnGuard)]pub struct ChatGateway { #[inject] svc: Arc<RoomService>,}AuthnGuard 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, WsGuard, async_trait};
#[injectable]#[derive(Default)]pub struct ModerationGuard;
impl Layer for ModerationGuard {}
#[async_trait]impl Guard for ModerationGuard { 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 to post")), _ => Ok(()), } }}
// The capability marker — see below. One line, right beside the method it attests.impl WsGuard for ModerationGuard {}Bind it beside the message it gates:
#[messages]impl ChatGateway { #[subscribe_message("message")] #[public] #[use_guards(ModerationGuard)] async fn on_message(&self, msg: SendMessageDto) { 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 to post"}}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 per-message guard chain at mount
time from two sources: app-global guards and the guards listed on the
message itself. Guards bound on the gateway struct are not in this
chain — they run once at the upgrade (check_http), never per message.
Duplicates are removed by TypeId, so a guard registered globally and also
listed on a specific message runs once per dispatch. Redeclaring on a
provider therefore costs nothing, and a feature crate moved to an app that
does not share the same globals still carries its own.
ConnUpgrade ── check_http ── on_connect │ ├── Envelope ── check_ws_message (global ∪ 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(AuthnGuard, 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.
Reference
Section titled “Reference”Guard— thecheck_http/check_graphql/check_ws_messagetriple.EventLayerTable— the per-gateway, per-event guard table the dispatcher iterates.crates/features/src/chat/ws/guard.rs— a per-message guard worked end to end.