skip to content

How does Spring resolve the Principal used for user-destination routing over a STOMP/WebSocket connection, and how would you set it manually?

level: seniorimportance: should knowfreq 45%

answer

  1. Principal from handshake (Spring Security) → auto onto WS session
  2. or ChannelInterceptor on clientInboundChannel + CONNECT frame
  3. StompHeaderAccessor.setUser(authentication)
  4. Authentication implements Principal; getName() = routing key
  5. set user on CONNECT, not SUBSCRIBE; return message from preSend

basics

~20 s

The Principal comes from the WebSocket session — usually the authenticated user from the HTTP handshake (Spring Security). If auth happens inside STOMP instead, a ChannelInterceptor on the inbound channel reads credentials from the CONNECT frame and sets the user via StompHeaderAccessor.setUser(principal).

solid answer

~40 s

User destinations need to know 'who is this session'. Spring gets that Principal from the WebSocket session, associated at HTTP handshake time — if you secured the handshake with Spring Security, the authenticated user propagates automatically. When authentication instead happens over STOMP (e.g. a token in the CONNECT frame's headers), you register a ChannelInterceptor on the clientInboundChannel (configureClientInboundChannel), intercept the CONNECT command, validate the token, build an Authentication/Principal, and call StompHeaderAccessor.setUser(principal). Spring stores it on the session so all later frames — and convertAndSendToUser / @SendToUser — resolve to that user via Principal.getName(). A custom name (not the login) requires implementing Principal yourself or a custom handshake handler that assigns the session's user identity. Without any Principal, user destinations silently deliver nothing.

code

java · 24 lines
java
@Configuration
@EnableWebSocketMessageBroker
public class WsConfig implements WebSocketMessageBrokerConfigurer {

    private final TokenService tokenService;
    WsConfig(TokenService tokenService) { this.tokenService = tokenService; }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor acc =
                    MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                if (acc != null && StompCommand.CONNECT.equals(acc.getCommand())) {
                    String token = acc.getFirstNativeHeader("Authorization");
                    Authentication user = tokenService.validate(token); // -> implements Principal
                    acc.setUser(user);  // binds Principal to the WebSocket session
                }
                return message;
            }
        });
    }
}

go deeper

for a junior

Know the user usually comes from the authenticated HTTP handshake.

for a middle

Explain that user destinations need a Principal and where it originates.

for a senior

Implement CONNECT-frame auth via a ChannelInterceptor and setUser, and know handshake-vs-STOMP auth trade-offs.

for a principal

Reason about interceptor ordering with Spring Security, custom principal identities, and multi-session registry keying.

**Why a Principal matters here:** user destinations (`/user/**`, `convertAndSendToUser`, `@SendToUser`) route by **username**. Spring gets the username from `Message` header `SIMP_USER` / the session's `Principal`, matched against `Principal.getName()`. No principal ⇒ no routing target ⇒ messages to that user vanish. **Path 1 — authenticate at the HTTP handshake (most common):** A WebSocket/SockJS connection starts as an HTTP request (the Upgrade, or SockJS' `/info` + transport requests). If Spring Security protects that URL, the request is already authenticated (session cookie, JWT filter, etc.), and Spring's `DefaultHandshakeHandler` copies the request's `Principal` (`HttpServletRequest.getUserPrincipal()`) onto the WebSocket session. From then on, every STOMP frame on that session carries that user. This is zero-extra-code: secure `/ws/**` in your security config and the principal flows through. **Path 2 — authenticate inside the STOMP layer:** Sometimes the handshake is anonymous (e.g. browsers can't set an `Authorization` header on a native WebSocket handshake, so a token is passed in the STOMP **CONNECT frame** headers instead). You handle this with a **`ChannelInterceptor` on the client inbound channel**: ```java @Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors(new ChannelInterceptor() { @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor acc = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); if (StompCommand.CONNECT.equals(acc.getCommand())) { String token = acc.getFirstNativeHeader("Authorization"); Authentication user = tokenService.validate(token); // your logic acc.setUser(user); // <-- binds Principal to the session } return message; } }); } ``` Because you set the user on the **CONNECT** frame, Spring associates it with the WebSocket session and reuses it for all subsequent frames. `Authentication implements Principal`, so `getName()` returns the username used by user destinations. **Custom principal name / anonymous identity:** If you want to route by something other than the login (e.g. a device id, or a stable id for anonymous users), you can (a) provide a custom `HandshakeHandler` overriding `determineUser(...)` to return your own `Principal` implementation, or (b) set your own `Principal` in the CONNECT interceptor. Spring uses whatever `Principal.getName()` returns as the user key. **Interaction with Spring Security context:** Setting the user on the accessor makes user-destination routing work, but if you also want `SecurityContextHolder`/`@PreAuthorize` inside message handlers, Spring Security's WebSocket support propagates the `Authentication` for you when it's the message user (`AuthenticationPrincipalArgumentResolver`, `SecurityContextChannelInterceptor`). Order matters: the security interceptor must see the user you set. **Multi-session reality:** one user may have several sessions (tabs/devices), each with the *same* `Principal.getName()` but distinct session ids. `convertAndSendToUser` fans out to all of them via the `SimpUserRegistry`. The registry keys users by principal name. **Gotchas:** - Setting the user on a *SUBSCRIBE/SEND* frame is too late/wrong — do it on **CONNECT**. - You must return the (possibly mutated) message from `preSend`; and use `StompHeaderAccessor` obtained via `MessageHeaderAccessor.getAccessor(...)` (mutable) rather than reading immutable headers. - Interceptor ordering: your auth interceptor must run before Spring Security's `SecurityContextChannelInterceptor` so the context is populated from the user you set. - Native WebSocket handshakes can't carry custom headers from the browser JS `WebSocket`/`SockJS` API, which is precisely why CONNECT-frame auth exists. - If you rely on the HTTP session/cookie, ensure the handshake actually carries it (CORS credentials, same-site cookie settings).

  • Why is CONNECT-frame authentication needed even though you could secure the HTTP handshake?
    Browsers' native WebSocket/SockJS JS APIs can't attach custom headers (like Authorization: Bearer) to the handshake request. So token-based auth is commonly deferred to the STOMP CONNECT frame, whose native headers the client can set, and validated in an inbound ChannelInterceptor.
  • You set the user in an interceptor but @PreAuthorize on a message handler sees an anonymous context. What's likely wrong?
    Interceptor ordering: Spring Security's SecurityContextChannelInterceptor must run after your auth interceptor so it can copy the user you set into the SecurityContext. Ensure your interceptor is registered/ordered before it, and that you set the user on the CONNECT frame.

saying these in an interview costs you the question

  • Thinking the Principal must always be a Spring Security login (any Principal with a getName() works)
  • Setting the user on SUBSCRIBE/SEND frames instead of CONNECT
  • Forgetting to return the message from preSend, dropping the frame
  • Assuming the browser can send an Authorization header on the WebSocket handshake

context