# 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.

- **Author:** Utsab Dahal, Backend Developer — Spring Boot & DevOps
- **Filed:** 27 July 2026 (2026-07-27)
- **Section:** System Design
- **Reading time:** 8 min read
- **Tags:** Spring Cloud Gateway, WebSocket, STOMP, Microservices, Architecture
- **Canonical page:** https://dahalutsab.com.np/dispatches/spring-cloud-gateway-cannot-proxy-websockets

---

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.

---

Source: https://dahalutsab.com.np/dispatches/spring-cloud-gateway-cannot-proxy-websockets
From The Utsab Dahal Times — the record of Utsab Dahal, Backend Developer — Spring Boot & DevOps, Kathmandu, Nepal.
Full record: https://dahalutsab.com.np/llms-full.txt
