# 4. STOMP: four lines of configuration, three surprising defaults Previous: [3. The payload](03-the-payload.md) · Next: [5. The limits](05-limits.md) --- [`WebSocketConfig`](../src/main/java/com/ankurm/ssews/WebSocketConfig.java) is short. Each line decides more than it looks like it does. ```java 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`](../src/test/java/com/ankurm/ssews/StompRoutingTest.java) 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: ```java 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`](../src/main/java/com/ankurm/ssews/PresenceListener.java) 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`](../rabbitmq/README.md). 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 ```java 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](03-the-payload.md) · Next: [5. The limits](05-limits.md)