5.8 KiB
4. STOMP: four lines of configuration, three surprising defaults
Previous: 3. The payload · Next: 5. The limits
WebSocketConfig is short. Each line
decides more than it looks like it does.
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
Surprise 1: a client can publish straight to a topic
setApplicationDestinationPrefixes("/app") says which destinations reach a @MessageMapping.
It does not say which destinations a client may send to. A SEND to /topic/room goes
directly to the broker, and every subscriber receives it.
StompRoutingTest sends a message with
a timestamp the controller would have overwritten, and it arrives untouched:
=== SEND straight to /topic/room ===
received: ChatMessage[from=not-checked-by-anyone, text=unvalidated, at=2000-01-01T00:00:00Z]
=== the controller's Instant.now() rewrite did NOT happen ===
No handler ran. No validation ran. Nothing logged that anything was skipped. If your controller is where authorisation, sanitisation or rate limiting happens, a client that knows the destination name walks past all of it.
The fix is not a routing setting — it is Spring Security's message-level authorisation
(simpDestMatchers("/topic/**").denyAll() for client SEND frames), or a broker relay whose
own ACLs forbid publishing. Treat a bare simple broker as an open pipe.
Surprise 2: a handler with no @SendTo is not private
The default destination for a @MessageMapping return value is the broker prefix plus the
mapping. @MessageMapping("/chat.echo") with no annotation publishes to /topic/chat.echo:
=== @MessageMapping("/chat.echo") with NO @SendTo ===
subscribed to /topic/chat.echo, received: ChatMessage[from=echo, text=who can see this?, ...]
Anyone who can guess the mapping can subscribe to the replies. If a handler's answer is for the
caller only, it needs @SendToUser, not silence.
Surprise 3: convertAndSendToUser needs more than a name
/user/** is not a real destination. On SUBSCRIBE, DefaultUserDestinationResolver rewrites
/user/queue/whisper into a session-scoped destination, which is why two clients subscribed to
the same string get different messages:
Translated /user/queue/whisper -> [/queue/whisper-userea271ead-...]
Translated /user/queue/whisper -> [/queue/whisper-user84865e50-...]
With no authenticated Principal, the "user" is the STOMP session id — and the three-argument
convertAndSendToUser(sessionId, dest, payload) silently delivers nothing, because the
resolver looks that name up in a user registry that has never heard of it. No exception, no log
line. The message has to carry the session id too:
SimpMessageHeaderAccessor out = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
out.setSessionId(targetSessionId);
out.setLeaveMutable(true); // without this the accessor is frozen before the resolver looks
template.convertAndSendToUser(targetSessionId, "/queue/whisper", payload, out.getMessageHeaders());
And there is a second trap stacked on the first, which cost a debugging round here:
StompSession.getSessionId()on the client is not the server's session id.alice.getSessionId() [client side] : 08b58570-0fcb-ffaf-d82c-119232e66561 server-side session id : f882cf6a-f0d9-48e3-baf3-c573045a47d1Whispering to the client-side value delivers nothing, silently, exactly like the missing header does. The server-side id is what appears in
SimpMessageHeaderAccessor.getSessionId(), and it has to come from the server.
With Spring Security in the picture the Principal is a real user name and none of this applies
— which is a good argument for putting security in front of a STOMP endpoint even when you
do not think you need authentication yet.
What STOMP gives you that SSE cannot
Presence. PresenceListener is fifteen
lines and produces:
| CONNECTED d775c936-8492-4b32-871e-c659346a2fb9
| SUBSCRIBE d775c936-8492-4b32-871e-c659346a2fb9 -> /topic/room
| DISCONNECT def64a1f-dc10-45bc-92d3-aa2e842ae02f status=CloseStatus[code=1000, reason=null]
The server knows who is connected, what they subscribed to, and when they left, with a close status attached. SSE has no equivalent of any of those three.
The simple broker is not a broker
enableSimpleBroker is in-memory, single JVM, no persistence, no acknowledgement, and it drops
everything on restart. It is also fine for a great many applications. When it is not,
enableStompBrokerRelay("/topic", "/queue") swaps in RabbitMQ or ActiveMQ without touching a
single controller — the annotations and destinations stay exactly as they are. The broker
side of that is ../rabbitmq.
Note the asymmetry once you relay: /topic and /queue become real broker destinations with
real semantics, and the "client can publish straight to a topic" problem becomes the broker's ACL
problem rather than yours.
SockJS is a second endpoint, not an option on the first
registry.addEndpoint("/ws");
registry.addEndpoint("/ws-sockjs").withSockJS();
.withSockJS() on the same path does not give you both. SockJS rewrites the URL into
/{server}/{session}/{transport}, and a plain WebSocket client pointed at that path fails the
handshake. In 2026, SockJS is mostly legacy — WebSocket support is universal — but it
is still the answer for a corporate proxy that strips Upgrade.
Previous: 3. The payload · Next: 5. The limits