1
0
Files
spring-messaging-demo/sse-websocket/docs/04-stomp.md
2026-09-04 00:52:18 +05:30

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-c573045a47d1

Whispering 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