Skip to content
Ashish.
All posts
Diagram illustrating the two-layer security model for WebSocket connections involving HTTP Upgrade and STOMP frame validation.
8 min readBackendAdvancedFeatured#websocket security#oauth2#jwt#spring websocket#stomp#real-time security

Securing WebSocket Connections with OAuth2 and JWT

A technical examination of securing real-time WebSocket connections using OAuth2 and JWT tokens within Spring WebSocket applications.

By Ashish Srivastava

The fundamental challenge in securing real-time applications lies in the architectural shift from request-response to persistent bi-directional streams. While standard REST APIs carry credentials with every request, a WebSocket connection persists for minutes or hours, creating a window where a stolen token enables prolonged session hijacking. Conversely, relying on automatic browser cookie transmission during the HTTP upgrade exposes the connection to Cross-Site Request Forgery (CSRF). The solution requires explicit, stateless token transmission at two distinct layers: the transport layer via the HTTP Upgrade handshake and the application layer via STOMP frame headers.

The Handshake: Extracting the Token from the Upgrade

When a client initiates a WebSocket connection, it sends an HTTP GET request with the Upgrade: websocket header. In a Spring application, this request traverses the HttpSession and SecurityFilterChain before reaching the WebSocket handler. To secure this initial phase, the client must include a Bearer token in the Authorization header of the upgrade request, rather than relying on query parameters.

Consider a scenario where a client attempts to connect to a trading application. The JavaScript implementation constructs the connection with the token embedded in the headers:

const socket = new WebSocket(
  'wss://api.example.com/trades',
  ['v1.stomp', 'v1.sockjs'],
  {
    headers: {
      'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
    }
  }
);

Spring Security intercepts this upgrade request. The ServerHttpHandler or a custom WebSocketHandler must be configured to validate the token. If the token is invalid, the server rejects the upgrade with a 401 or 403 status, preventing the TCP connection from ever establishing the WebSocket protocol. This serves as the first line of defense. However, this only secures the tunnel; once open, the application layer (STOMP) requires knowledge of the user identity to authorize subsequent messages.

Technical architecture diagram showing a client sending an HTTP Upgrade request with an Authorization header to a Spring WebSocket server. The diagram should highlight the SecurityFilterChain intercepting the request and validating the Bearer token before the WebSocket tunnel …

The Application Layer: Binding STOMP Headers to Security Context

Spring WebSocket often utilizes the STOMP protocol over the WebSocket transport. STOMP frames possess their own headers, distinct from the HTTP headers used during the handshake. A STOMP CONNECT frame includes headers such as accept-version, heart-beat, and Authorization.

CONNECT
accept-version:1.1,1.0
heart-beat:10000,10000
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
destination:/app/message
content-type:application/json

The critical mechanism here is the ChannelInterceptor. Spring Security does not automatically map STOMP headers to the SecurityContext because STOMP is agnostic to the underlying transport. You must implement a custom ChannelInterceptor to intercept the CONNECT frame.

When the server receives the CONNECT frame, the interceptor extracts the Authorization header. It then validates the JWT payload. If valid, it creates a UsernamePasswordAuthenticationToken or a JwtAuthenticationToken and sets it as the principal in the SecurityContextHolder. This ensures that when the client sends a SEND to /app/chat, the Spring @MessageMapping method has access to @AuthenticationPrincipal to authorize the action.

Without this interceptor, the SecurityContext remains empty or holds the anonymous user for all STOMP messages, even if the handshake was successful. The handshake validates the connection, but the STOMP interceptor validates the message sender.

@Component
public class StompSecurityInterceptor implements ChannelInterceptor {
    @Override
    public Message<?> preSend(Message<?> message, Channel channel) {
        if (channel instanceof SubProtocolChannel && ((SubProtocolChannel) channel).getSubProtocol().equals("stomp")) {
            // Logic to extract headers and validate JWT
            // Set SecurityContext based on token
        }
        return message;
    }
}

Handling Token Expiry and Refresh

JWTs are stateless and short-lived, typically expiring in 15 minutes. A WebSocket connection might last hours. If the token expires while the socket is open, the client can no longer authenticate new messages. Closing the socket and reconnecting is a poor user experience and breaks real-time state.

The mechanism to handle this involves a "refresh" strategy. Instead of relying on the handshake token for the entire session, the client should maintain a short-lived access token and a long-lived refresh token. When the access token expires, it typically triggers a 403 AccessDeniedException at the Spring method level (or a custom STOMP ERROR frame) during a subsequent SEND or SUBSCRIBE. This distinguishes message-level authorization denial from the initial HTTP Upgrade handshake, where a 401 is primarily returned if the token is invalid.

Upon detecting a 403, the client sends a SEND to a dedicated refresh endpoint (e.g., /app/auth/refresh) using the refresh token. Upon successful validation, the server issues a new access token. The client then updates its local storage and continues sending messages. This prevents the need for a full TCP reconnection. However, this introduces a race condition: if the server's internal token store (if using a blacklisting strategy) or the JWT signature changes, the client might be logged out globally. For pure stateless JWTs, the server simply ignores the old token, and the client must handle the 403 error gracefully by triggering the refresh flow.

This approach assumes the client can detect the error. In Spring, you can configure a global exception handler or a ClientInboundChannel interceptor to catch AccessDeniedException and trigger a re-authentication flow automatically.

Sequence diagram illustrating the WebSocket lifecycle with JWT expiration. Show the client sending a message, receiving a 403 error, sending a refresh token request, receiving a new token, and resuming the stream. Style : technical sequence diagram, minimalist, dark mode, gree…

Common Pitfalls

Implementing WebSocket security introduces specific risks that differ from traditional REST APIs.

  1. Token Storage Risks: Storing JWTs in localStorage exposes them to XSS attacks, allowing malicious scripts to steal the token and establish unauthorized WebSocket connections. Prefer httpOnly cookies for refresh tokens, but note that WebSocket upgrades require careful CSRF handling if cookies are used.
  2. CSRF on Upgrade: If you rely on cookies for the WebSocket handshake instead of Bearer tokens in the Authorization header, you are vulnerable to CSRF. The browser automatically sends cookies with the Upgrade request. Always use Bearer tokens for the handshake or implement strict SameSite cookie policies and CSRF tokens.
  3. STOMP Header Injection: Malicious clients can attempt to inject headers into STOMP frames to manipulate message routing or bypass security checks. Always sanitize and validate headers in your ChannelInterceptor before processing the message. Do not blindly trust client-supplied header values.

Practical Takeaways

To secure real-time applications effectively, adopt these mental models:

  1. Dual-Layer Validation: Treat the HTTP Upgrade and the STOMP frame as two independent security boundaries. A successful handshake does not grant permission to send messages; every message requires its own authentication context.
  2. Stateless by Default, Stateful by Exception: Design your system to assume stateless JWTs for performance. Introduce state (like deny-lists) only when specific security requirements (like immediate logout) force you to trade performance for control.
  3. Fail Securely: When a token expires or is invalid during a message exchange, do not silently drop the message or close the connection abruptly. Return a clear error code (403/401) so the client can initiate the refresh flow or prompt the user to re-login.

FAQ

Q: Can I use cookies for the WebSocket handshake instead of Bearer tokens? A: It is possible but risky. Cookies are automatically sent with the Upgrade request, making the connection vulnerable to CSRF unless strict SameSite policies and CSRF tokens are implemented. Bearer tokens in the Authorization header are generally preferred for WebSockets.

Q: How do I handle token refresh without closing the connection? A: Detect the AccessDeniedException (403) on the client side during a SEND or SUBSCRIBE. Trigger a request to a refresh endpoint using your refresh token. Once the new access token is received, update your local storage and retry the failed message.

Q: Is ChannelInterceptor sufficient for all security checks? A: For message authorization, yes, provided you set the SecurityContext correctly. However, you must also configure the SecurityFilterChain to protect the initial HTTP Upgrade request and any other endpoints (like the refresh endpoint) separately.

Conclusion

Securing WebSocket connections requires a dual-layer approach that respects the distinct nature of the HTTP upgrade and the persistent STOMP channel. By extracting tokens during the handshake and binding them to the security context via ChannelInterceptor, developers can maintain the stateless benefits of JWTs while protecting against session hijacking and CSRF. Managing token expiration through a refresh strategy further ensures a smooth user experience without compromising security posture.

Related posts