How does Spring resolve the Principal used for user-destination routing over a STOMP/WebSocket connection, and how would you set it manually?
answer
- Principal from handshake (Spring Security) → auto onto WS session
- or ChannelInterceptor on clientInboundChannel + CONNECT frame
- StompHeaderAccessor.setUser(authentication)
- Authentication implements Principal; getName() = routing key
- set user on CONNECT, not SUBSCRIBE; return message from preSend
basics
~20 sThe 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 sUser 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@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
Know the user usually comes from the authenticated HTTP handshake.
Explain that user destinations need a Principal and where it originates.
Implement CONNECT-frame auth via a ChannelInterceptor and setUser, and know handshake-vs-STOMP auth trade-offs.
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