How do you secure STOMP messages with per-destination authorization in Spring Security (modern AuthorizationManager approach)?
answer
- @EnableWebSocketSecurity + AuthorizationManager<Message<?>> bean
- MessageMatcherDelegatingAuthorizationManager.Builder
- nullDestMatcher / simpDestMatchers / simpSubscribeDestMatchers
- first-match-wins; end anyMessage().denyAll()
- secure SUBSCRIBE separately from SEND; CONNECT needs CSRF token
basics
~10 sAdd Spring Security's WebSocket message security. In Spring Security 6 you annotate a config with @EnableWebSocketSecurity and define an AuthorizationManager<Message<?>> using MessageMatcherDelegatingAuthorizationManager.builder(), matching by destination (simpDestMatchers / simpSubscribeDestMatchers) and requiring roles/authentication, ending with anyMessage().denyAll().
solid answer
~40 sSpring Security can authorize each inbound STOMP message by its destination and type. In Spring Security 6 the modern way is to annotate a config class with @EnableWebSocketSecurity and expose a bean AuthorizationManager<Message<?>> built from the injected MessageMatcherDelegatingAuthorizationManager.Builder. On it you chain rules: nullDestMatcher() for CONNECT/DISCONNECT frames (usually .authenticated()), simpDestMatchers("/app/**") for SEND messages, simpSubscribeDestMatchers("/user/**","/topic/admin/**") for SUBSCRIBE, each with .authenticated()/.hasRole(...)/.permitAll(), then a catch-all anyMessage().denyAll(). Order matters — first match wins. This is distinct from HTTP security: it authorizes messages on the clientInboundChannel, not URLs. The older AbstractSecurityWebSocketMessageBrokerConfigurer with configureInbound(...) is deprecated. Also note CSRF: STOMP CONNECT requires a CSRF token by default; you can relax same-origin only deliberately.
code
java · 18 lines@Configuration
@EnableWebSocketSecurity
public class WebSocketSecurityConfig {
@Bean
AuthorizationManager<Message<?>> messageAuthorizationManager(
MessageMatcherDelegatingAuthorizationManager.Builder messages) {
messages
.nullDestMatcher().authenticated() // CONNECT / DISCONNECT
.simpSubscribeDestMatchers("/user/queue/errors").permitAll()
.simpDestMatchers("/app/admin/**").hasRole("ADMIN") // SEND to admin app dests
.simpSubscribeDestMatchers("/topic/admin/**").hasRole("ADMIN") // SUBSCRIBE (read path!)
.simpDestMatchers("/app/**").hasRole("USER")
.simpSubscribeDestMatchers("/user/**", "/topic/**").hasRole("USER")
.anyMessage().denyAll(); // fail closed
return messages.build();
}
}go deeper
Know that Spring can authorize STOMP messages by destination, separate from URL security.
Write basic destination rules and know to end with denyAll.
Distinguish SEND vs SUBSCRIBE matchers, use the modern AuthorizationManager, and explain CSRF/same-origin on CONNECT.
Design layered message security (destination rules + method security), reason about interceptor ordering and fail-closed defaults across tenants/roles.
**Two different security layers — don't confuse them:** 1. **HTTP/handshake security** (`SecurityFilterChain`): protects the endpoint URL (`/ws/**`) — who may open the connection. 2. **Message security** (this topic): authorizes each **STOMP frame** flowing through the `clientInboundChannel` — who may SEND to `/app/**`, SUBSCRIBE to `/topic/admin/**`, etc. URL rules can't express this because after the handshake everything flows over one connection. **Modern setup (Spring Security 6.x):** ```java @Configuration @EnableWebSocketSecurity // imports the builder bean & wiring public class WebSocketSecurityConfig { @Bean AuthorizationManager<Message<?>> messageAuthz( MessageMatcherDelegatingAuthorizationManager.Builder messages) { messages .nullDestMatcher().authenticated() // CONNECT, DISCONNECT, etc. .simpSubscribeDestMatchers("/user/queue/errors").permitAll() .simpDestMatchers("/app/**").hasRole("USER") // SEND to app dests .simpSubscribeDestMatchers("/user/**", "/topic/**").hasRole("USER") // SUBSCRIBE .anyMessage().denyAll(); // default deny return messages.build(); } } ``` `@EnableWebSocketSecurity` registers a `SecurityContextChannelInterceptor` (propagates the `Authentication`) and an `AuthorizationChannelInterceptor` that consults your `AuthorizationManager` for every inbound message. **Matcher vocabulary:** - `nullDestMatcher()` — messages with **no destination**: CONNECT, DISCONNECT, other lifecycle frames. Typically `.authenticated()` so only logged-in sessions may connect (if you enforce auth at message layer). - `simpDestMatchers(patterns)` — matches by destination regardless of type, but is chiefly used for **SEND** (message type MESSAGE). E.g. `/app/**`. - `simpSubscribeDestMatchers(patterns)` — matches only **SUBSCRIBE** frames. Critical because reading a stream is subscribing; you often want stricter rules on who can *subscribe* to `/topic/admin/**` than who can send. - `simpMessageDestMatchers(...)` — SEND-type only (message). - `simpTypeMatchers(SimpMessageType...)` — match by frame type directly. - Terminal rules: `.permitAll()`, `.denyAll()`, `.authenticated()`, `.hasRole("X")`, `.hasAuthority(...)`, `.hasAnyRole(...)`, or `.access(customAuthorizationManager)`. **Order is first-match-wins.** Put specific `permitAll` rules (like the error queue) *before* broad `hasRole` rules, and always end with `anyMessage().denyAll()` so anything unmatched is rejected — a fail-closed default. **SEND vs SUBSCRIBE distinction is the classic trap:** authorizing `simpDestMatchers("/topic/admin/**").hasRole("ADMIN")` covers SEND, but a non-admin could still **subscribe** and receive admin broadcasts unless you also add a `simpSubscribeDestMatchers("/topic/admin/**").hasRole("ADMIN")`. Subscription is the read path; secure it explicitly. **Legacy approach (deprecated, still seen):** extend `AbstractSecurityWebSocketMessageBrokerConfigurer` and override `configureInbound(MessageSecurityMetadataSourceRegistry messages)` with the same matcher fluent API. Spring Security 6 deprecated this in favor of the `AuthorizationManager` bean. Recognize it in old code; write new code the modern way. **CSRF and same-origin:** Spring Security enforces **same-origin** for WebSocket/SockJS and requires the STOMP **CONNECT** frame to carry a **CSRF token** (the server rejects cross-site connections otherwise). The legacy configurer exposed `sameOriginDisabled()`; disabling it removes an important protection and should be deliberate (and paired with strict `setAllowedOrigins`). The reason: SockJS' HTTP fallback transports (and iframe) are ordinary requests a malicious page could try to forge, so CSRF applies. **@PreAuthorize alternative/complement:** you can also secure message-handling **methods** with `@PreAuthorize` (enabled via `@EnableGlobalMethodSecurity`/method security) using SpEL over the message/principal. Destination-based `AuthorizationManager` rules are coarse and centralized; method security is fine-grained and colocated with handlers. Many apps use both. **Interceptor ordering caveat:** if you authenticate in a custom CONNECT interceptor, it must run *before* Spring Security's channel interceptors so the `Authentication` is present when authorization runs. **When to use:** any app where different users have different rights over destinations (admin channels, per-tenant topics, user queues). Always fail closed with `anyMessage().denyAll()`.
- Why must you secure SUBSCRIBE separately from SEND, and which matcher does each?SEND is the write path (simpDestMatchers / simpMessageDestMatchers), SUBSCRIBE is the read path (simpSubscribeDestMatchers). Restricting only SEND to /topic/admin/** still lets an unauthorized user subscribe and receive those broadcasts. You must add a simpSubscribeDestMatchers rule to guard reads.
- Why does Spring Security require a CSRF token on the STOMP CONNECT frame, and when might you relax it?Because SockJS falls back to HTTP transports (and an iframe) that a malicious cross-origin page could try to forge, Spring enforces same-origin + a CSRF token on CONNECT. You'd relax same-origin (legacy sameOriginDisabled / adjusting origins) only deliberately, paired with a strict allowed-origins allowlist.
- What is the difference between the legacy AbstractSecurityWebSocketMessageBrokerConfigurer and the modern approach?The legacy configurer overrides configureInbound(MessageSecurityMetadataSourceRegistry) and is deprecated in Spring Security 6. The modern approach annotates a config with @EnableWebSocketSecurity and defines an AuthorizationManager<Message<?>> bean via MessageMatcherDelegatingAuthorizationManager.Builder.
saying these in an interview costs you the question
- Securing only SEND destinations and leaving SUBSCRIBE open (unauthorized reads)
- Forgetting anyMessage().denyAll() so unmatched frames are implicitly permitted
- Thinking HTTP SecurityFilterChain URL rules also authorize individual STOMP frames
- Blindly calling sameOriginDisabled() / removing CSRF because 'CONNECT failed'
- Putting broad rules before specific permitAll rules (first-match-wins ignores later ones)