<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Libre+Caslon+Text:ital,wght@0,400;0,700;1,400&family=Libre+Franklin:wght@400..800&family=JetBrains+Mono:wght@400;500;700&display=swap" />
Late edition Stop the presses

Suspect at large

A backend developer is fleeing the scene. The record is being set.

Setting the type Inking the plates Running the presses

Skip to the front page
Kathmandu, NepalThe Backend EditionEst. 2021

The personal record of a backend developer

Wednesday 5 August 2026Vol. ISelected works & notesPrice: one coffee
← Back to the dispatchesSystem Design · 8 min read

System Design

Your API Gateway Cannot Proxy Your WebSocket

Spring Cloud Gateway Server WebMVC cannot proxy a WebSocket upgrade, and it fails without saying so. What to do instead, how to authenticate a socket a browser cannot put a header on, and the SockJS origin trap that logs nothing at all.

By · Backend Developer — Spring Boot & DevOps27 July 2026 · 8 min read

Every REST route worked. Health checks were green. Then I opened the page that needed live updates and the browser sat there, retrying, forever.

This is the writeup I wanted when that happened.

The symptom

The architecture was ordinary. Spring Cloud Gateway on :8090 in front of six Spring Boot services, and one of those services holding the WebSocket connections for realtime fan-out. The frontend was told to open wss://host/ws and let the gateway route it like everything else.

What you get instead, depending on your exact configuration:

  • A 404 on the upgrade request, even though the route is plainly declared.
  • A 400 Bad Request from the gateway, no logs at all in the target service.
  • SockJS silently giving up on the WebSocket transport and falling back to XHR streaming — which appears to work, badly, until it does not work at all.
  • On a good day, nothing whatsoever in any log, anywhere.

I lost most of an afternoon to the last one, checking route order and CORS, because a route that fails silently looks exactly like a route you got wrong.

The cause

Spring Cloud Gateway ships in two flavours, and this is not obvious from the starter names.

Spring Cloud Gateway Server WebFlux is the original: reactive, running on Netty. It proxies WebSockets fine, because a non-blocking runtime can hold thousands of long-lived upgraded connections without holding thousands of threads.

Spring Cloud Gateway Server WebMVC is the newer, blocking, Servlet-stack variant. It exists so you can run a gateway without dragging Reactor and Netty into a codebase that is otherwise plain Spring MVC — which is a genuinely good reason to pick it.

It also cannot proxy a WebSocket upgrade. This is a known and open limitation, tracked as [spring-cloud-gateway#3442](https://github.com/spring-cloud/spring-cloud-gateway/issues/3442). Not a bug in your route definition. Not a missing filter. The Servlet-based gateway does not implement the upgrade path.

That is worth saying plainly because the error messages point everywhere except at the answer:

If your gateway is the MVC variant, no amount of configuration will make `/ws` work through it.

Three ways out

Switch to the reactive gateway. The upgrade proxying works. The cost is that Reactor and Netty are now in your dependency tree, your filters are written against a reactive API, and any blocking code in a custom filter becomes a production incident rather than a code review comment. If your team is not already fluent in reactive Spring, this is a large bill for one feature.

Put the realtime service beside the gateway, not behind it. Clients open /ws directly against the realtime service. The gateway keeps /api. Two origins — unless something in front collapses them back into one, which is the next section.

Give realtime its own subdomain. wss://realtime.example.com. Clean, and now you own a second certificate, a second CORS allowlist, and cookies that do not span both hosts.

I took the second option. Here is what it actually costs, because "just bypass the gateway" is where most writeups stop and where the real work starts.

Collapsing the origins

Two ports is fine on localhost and miserable everywhere else: CORS preflights on every call, a second certificate, and browsers that will not expose the Web Crypto API — which OIDC PKCE needs — outside a secure context.

A reverse proxy in front of both fixes it. This is the entire Caddy config:

host.local {
    encode gzip

    handle /api/* {
        reverse_proxy localhost:8090
    }

    handle /ws/* {
        reverse_proxy localhost:8085
    }

    handle {
        reverse_proxy localhost:4200
    }
}

Caddy terminates TLS, and the browser now sees one origin serving the app, the REST API and the WebSocket. CORS stops being a problem because there is nothing cross-origin left. In production the same shape works with nginx or an ingress; the routing rule is the same three prefixes.

The architectural point is worth keeping: the gateway is not the only thing that can present a single origin. It was doing two jobs — routing and origin unification — and only one of them actually had to be its.

Now authenticate it

Bypassing the gateway raises a question the gateway was quietly answering: who is allowed to open this socket?

And here you meet the second wall. A browser cannot attach an `Authorization` header to a WebSocket upgrade. The WebSocket constructor takes a URL and a subprotocol list. That is all. There is nowhere to put a bearer token.

The usual workarounds are all bad. A token in the query string ends up in access logs, in Referer headers and in browser history. A cookie reintroduces CSRF surface and cross-origin cookie rules. A subprotocol field abuses a header that means something else.

The clean answer with STOMP: do not authenticate the transport. Authenticate the first frame.

STOMP has its own CONNECT frame with arbitrary headers, sent immediately after the socket opens. Put the token there, and reject the connection in a ChannelInterceptor before any message is dispatched:

java
@Component
public class StompAuthChannelInterceptor implements ChannelInterceptor {

    private final AuthenticationManagerResolver<String> byIssuer;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor =
                MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        if (accessor == null) return message;

        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
            String bearer = accessor.getFirstNativeHeader("Authorization");
            if (bearer == null || !bearer.startsWith("Bearer ")) {
                throw new MessagingException("No credentials on CONNECT");
            }
            Authentication auth = authenticate(bearer.substring(7));
            accessor.setUser(auth);          // sticks for the whole session
            return message;
        }

        if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
            authorizeDestination(accessor.getUser(), accessor.getDestination());
        }

        if (StompCommand.SEND.equals(accessor.getCommand())) {
            // Broadcast-only: every action goes through REST, so a client
            // that tries to publish is either confused or probing.
            throw new MessagingException("This channel does not accept SEND");
        }

        return message;
    }
}

Three things are happening there, and each one earned its place.

Authenticate on CONNECT. The principal set with accessor.setUser() stays attached for the life of the session, so later frames do not re-present credentials.

Authorize on SUBSCRIBE. This is the check people forget. Destinations look like /topic/sessions/{id}/questions, and without a check, any authenticated client can subscribe to any session's topics — including /moderation, which carries rejected content and is staff-only. The token has to be scoped to the session named in the destination.

Refuse SEND outright. The socket is a broadcast channel. Every action goes through REST, where the normal authorization already lives. Accepting SEND would create a second, weaker way into the domain.

The origin trap

One more, because it cost me an hour and produces zero useful logs.

SockJS sends an `Origin` header on every request, including same-origin ones. If your allowed-origins list does not match the browser's origin exactly — scheme, host and port — the handshake is rejected before your interceptor ever runs. You see no CONNECT, no error, no clue.

java
registry.addEndpoint("/ws")
        .setAllowedOrigins(allowedOrigins)   // must match exactly
        .withSockJS();

Two commands worth keeping. First, has any frame ever arrived?

bash
docker logs realtime-service | grep -c "STOMP CONNECT authenticated"

Zero means the transport never carried a frame, and the cause is almost always the origin. Then ask the endpoint directly:

bash
curl -sk https://host.local/ws/info -H "Origin: https://host.local"
# good:  {"entropy":123456,"websocket":true,...}
# bad:   Invalid CORS request

That second command turns an invisible failure into a one-line answer.

What to put in the frames

A design note that has nothing to do with transports and everything to do with not leaking data.

It is tempting to broadcast the whole object — the question with its text, the poll with its tally. It is also wrong, because a topic has many subscribers and they do not all have the same rights. A poll with results hidden while voting is open must not have its tally on the wire just because the UI would not have drawn it.

So the frames are deliberately thin:

java
public record PollFrame(String type, UUID pollId, UUID sessionId) { }

LAUNCHED, an id, nothing else. The client re-fetches over REST, and the server decides what that particular viewer is allowed to see, per request, with the authorization it already has. The socket says something changed. The API says what you may know about it.

This also makes the frames tiny, which matters when one popular session fans out to a few hundred subscribers.

Scaling past one node

The in-memory broker — enableSimpleBroker() — keeps subscriptions in the heap of one process. Two instances behind a load balancer means a message published on node A never reaches a client connected to node B, and the bug looks like "realtime works for some users".

The fix is a broker relay, RabbitMQ or ActiveMQ, so the broadcast goes to a broker both nodes are attached to. Do not do this until you actually run two nodes, but know that it is the step you are deferring.

The short version

  • Spring Cloud Gateway Server WebMVC cannot proxy WebSockets. Check which starter you have before you debug anything else.
  • Either go reactive, or let clients reach the realtime service directly and collapse the origins with a reverse proxy in front of both.
  • Browsers cannot put a header on a WebSocket upgrade. Authenticate on the STOMP CONNECT frame, authorize on SUBSCRIBE, and refuse SEND.
  • SockJS sends Origin on same-origin requests too. Match it exactly, or nothing works and nothing is logged.
  • Broadcast that something changed, not what changed. Let the API decide what each viewer may see.

None of this is exotic. It is just a set of things that are obvious afterwards, and completely invisible while you are staring at a route definition that looks perfectly correct.

Backend developer specializing in Java and Spring Boot. Building scalable, reliable systems that power modern applications. This broadsheet is hand-set in Caslon and Franklin.

The desk

Tech stack

  • Java & Spring Boot
  • MySQL & PostgreSQL
  • Docker & Microservices
  • JWT & OAuth2
Case closed

System up since 2021 · Building backend systems · Learning new technologies · Contributing to open source

© 2026 Utsab Dahal · All rights reserved · Printed in Kathmandu