skip to content

How do you secure STOMP messages with per-destination authorization in Spring Security (modern AuthorizationManager approach)?

level: seniorimportance: must knowfreq 50%

answer

  1. @EnableWebSocketSecurity + AuthorizationManager<Message<?>> bean
  2. MessageMatcherDelegatingAuthorizationManager.Builder
  3. nullDestMatcher / simpDestMatchers / simpSubscribeDestMatchers
  4. first-match-wins; end anyMessage().denyAll()
  5. secure SUBSCRIBE separately from SEND; CONNECT needs CSRF token

basics

~10 s

Add 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 s

Spring 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
java
@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

for a junior

Know that Spring can authorize STOMP messages by destination, separate from URL security.

for a middle

Write basic destination rules and know to end with denyAll.

for a senior

Distinguish SEND vs SUBSCRIBE matchers, use the modern AuthorizationManager, and explain CSRF/same-origin on CONNECT.

for a principal

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)

context