Skip to content

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.

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:

ScopeDecorator locationTrait method run
Connection#[use_guards(...)] on the gateway structcheck_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.

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.

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:

Terminal window
> {"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.

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 ── close

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

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.

  • Security — the authn + authz wiring story across HTTP, GraphQL, and WS.
  • Messages — the wire shape of a denial reply and the return contract.