12k
All articles

WebSocket Authentication Explained

WebSocket authentication methods explained: query tokens, cookies, subprotocols, and first-message auth, plus token renewal, revocation, and per-message authorization.

OpenReplay Team
OpenReplay Team
WebSocket Authentication Explained

The browser WebSocket API does not support setting custom HTTP headers — the WebSocket constructor accepts only a URL and an optional subprotocol array — so authentication must happen via one of three mechanisms: a token in the query string during the HTTP upgrade handshake, a session cookie that the browser sends automatically, or a credential in the first message after the connection opens. This is the constraint that makes WebSocket auth different from REST auth, where you would simply attach an Authorization: Bearer header to each request.

If you have shipped REST auth with JWTs or session cookies, you already know the pieces. What changes with WebSockets is the delivery mechanism and the lifetime. A REST request authenticates once and ends. A WebSocket connection stays open for minutes or hours, which means the token that was valid at handshake time can expire, be revoked, or lose its permissions while the socket is still open. This article covers each handshake-vs-first-message pattern with working browser and Node.js code, gives a decision rule for picking one, and treats the two things most guides skip: token renewal on long-lived connections and per-message authorization.

Key Takeaways

  • The browser WebSocket constructor accepts only a URL and a subprotocol array, so you cannot send an Authorization header — auth moves to the query string, a cookie, the Sec-WebSocket-Protocol header, or the first message.
  • WebSocket authentication is an ongoing gate, not a one-time event: because connections are long-lived, per-message authorization and token renewal are production requirements, not optional hardening.
  • Use in-band token refresh when the socket carries stateful subscriptions; use close-and-reconnect when the connection is stateless and context is cheap to rebuild.
  • The JWT exp claim is a NumericDate in seconds since the Unix epoch, so client-side refresh scheduling must convert via exp * 1000 before comparing to Date.now().
  • A common silent failure mode is a socket that closes on token expiry while the UI keeps showing a “connected” state and drops every subsequent message — visible in session replay, invisible in server logs.

Why WebSocket Authentication Is Different

WebSocket authentication is constrained at the browser level: the JavaScript WebSocket API gives you no way to attach a custom header to the opening handshake. Every WebSocket connection starts as an HTTP GET with an Upgrade: websocket header, but the browser controls that request entirely — your code only supplies the URL and an optional subprotocol list. The WebSocket protocol itself is explicit that it does not solve this for you: per RFC 6455 §10.5, the protocol “doesn’t prescribe any particular way that servers can authenticate clients during the WebSocket handshake.”

The browser’s missing-header constraint leaves four practical mechanisms, all of which work around it:

  1. Query-param token — put the token in the connection URL.
  2. Cookie/session — let the browser send an existing session cookie with the upgrade request.
  3. Sec-WebSocket-Protocol subprotocol — smuggle the token in the subprotocol array.
  4. First-message auth — open the socket unauthenticated, then send credentials as the first message.

The rest of this section walks each one with browser client code and Node.js server code using the ws library (current stable: 8.x).

Query-Parameter Token

Put the token in the connection URL and validate it during the HTTP upgrade, before allocating connection resources. This is the simplest method and the most widely used, and its advantage is fast rejection — the server can return a 401 before the socket is ever established.

// Browser client
const token = localStorage.getItem('authToken');
const socket = new WebSocket(`wss://api.example.com/ws?token=${encodeURIComponent(token)}`);
// Node.js server — ws@8.21.0
import { WebSocketServer } from 'ws';
import { verify } from 'jsonwebtoken';

const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', (req, socket, head) => {
  const { searchParams } = new URL(req.url, 'wss://api.example.com');
  const token = searchParams.get('token');
  try {
    const user = verify(token, process.env.JWT_SECRET);
    wss.handleUpgrade(req, socket, head, (ws) => {
      ws.user = user;
      wss.emit('connection', ws, req);
    });
  } catch {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
  }
});

Concrete risk: a token in the WebSocket URL is recorded in nginx and Apache access logs by default — both default log formats (combined / %r) include the full request line with its query string — may appear in browser history, and can leak via the Referer header if the page navigates after connecting. Short-lived tokens (5–15 minutes is an industry convention rather than an OWASP-documented figure) reduce but do not eliminate this exposure.

Cookie-based auth reuses a session you already established over HTTP: the browser automatically attaches same-domain cookies to the upgrade request, so no client-side auth code is needed at all. This is the lowest-friction method when your WebSocket endpoint shares a domain with the app that logged the user in.

// Browser client — no token handling needed; the cookie rides along
const socket = new WebSocket('wss://app.example.com/ws');
// Node.js server — ws@8.21.0
import { parse } from 'cookie';

server.on('upgrade', (req, socket, head) => {
  const origin = req.headers.origin;
  if (origin !== 'https://app.example.com') {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    return socket.destroy();
  }
  const cookies = parse(req.headers.cookie || '');
  const session = sessionStore.get(cookies.sid);
  if (!session) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    return socket.destroy();
  }
  wss.handleUpgrade(req, socket, head, (ws) => {
    ws.user = session.user;
    wss.emit('connection', ws, req);
  });
});

Concrete risk: cookie-based WebSocket auth requires both Origin header validation on the server and a SameSite=Strict or SameSite=Lax cookie attribute. As MDN’s SameSite documentation describes, SameSite controls whether a cookie is sent on cross-site requests; SameSite=None (required for genuinely cross-site cookies) re-exposes the CSRF surface that SameSite was introduced to close. Cookies also fail across different domains, which is the main reason teams reach for the next two methods.

Sec-WebSocket-Protocol Subprotocol

You can pass a token as a subprotocol value, because the subprotocol array is the one piece of the handshake the browser does expose. The Sec-WebSocket-Protocol header was designed to negotiate application-level subprotocols, not to carry authentication tokens; using it for auth is a workaround that works in all major browsers but should be treated as a last resort.

Subprotocol values must be valid token values with no separator characters, so the token must be encoded as unpadded base64url — standard base64’s / and = characters are not valid here. The base64url alphabet is defined in RFC 4648 §5.

// Browser client — token as an unpadded base64url subprotocol value
const token = localStorage.getItem('authToken'); // already base64url, unpadded
const socket = new WebSocket('wss://api.example.com/ws', ['auth.bearer', token]);
// Node.js server — ws@8.21.0
const wss = new WebSocketServer({
  noServer: true,
  handleProtocols: (protocols) => {
    // protocols is a Set; the second entry is our token
    const [, token] = [...protocols];
    return validate(token) ? 'auth.bearer' : false;
  },
});

Concrete risk: like the query-param method, the subprotocol value can land in logs that record handshake headers, and the encoding requirement (unpadded base64url) is easy to get wrong. Reserve this method for cases where cookies are blocked cross-domain and query-param exposure is unacceptable.

First-Message Authentication

The connection opens unauthenticated and the client sends credentials as its first message; the server validates before processing anything else. This keeps tokens out of URLs and logs entirely, at the cost of a queuing-and-timeout protocol you have to build yourself.

// Browser client — queue app messages until AUTH succeeds
const socket = new WebSocket('wss://api.example.com/ws');
const queue = [];
let authed = false;

socket.onopen = () => {
  socket.send(JSON.stringify({ type: 'AUTH', token: localStorage.getItem('authToken') }));
};

socket.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'AUTH_OK') {
    authed = true;
    queue.forEach((m) => socket.send(m));
    queue.length = 0;
  }
};

function send(data) {
  const m = JSON.stringify(data);
  authed ? socket.send(m) : queue.push(m);
}
// Node.js server — ws@8.21.0
wss.on('connection', (ws) => {
  const timer = setTimeout(() => ws.close(4001, 'auth timeout'), 7000);
  ws.once('message', (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type !== 'AUTH') return ws.close(4002, 'expected AUTH');
    try {
      ws.user = verify(msg.token, process.env.JWT_SECRET);
      clearTimeout(timer);
      ws.send(JSON.stringify({ type: 'AUTH_OK' }));
    } catch {
      ws.close(4003, 'invalid token');
    }
  });
});

Concrete risk: a server accepting unauthenticated WebSocket connections must enforce a hard auth timeout — typically 5–10 seconds, a convention with no spec basis — and close any connection that does not send valid credentials within that window. Without it, an attacker can exhaust connection limits with open-but-unauthenticated sockets. The right value depends on your p95 round-trip time: 5 seconds suits low-latency connections, 10 seconds is safer for mobile and high-latency clients.

Method Comparison and Decision Rule

Pick the method by what your deployment constrains, not by which is “most secure” in the abstract — all four are secure when implemented correctly. The table below summarizes the trade-offs; the decision rule that follows resolves the common cases.

MethodToken exposureCSRF riskCross-domainComplexityUse when
Query-param tokenLogs, history, RefererNoneYesLowYou control the server logs and want the simplest path
Cookie / sessionNone (cookie is HttpOnly)Yes (mitigate with SameSite + Origin)NoLowSame domain, existing session
Subprotocol headerHandshake logsNoneYesMediumCookies blocked, query params unacceptable
First-messageNoneNoneYesHighTokens must stay out of URLs entirely

The decision rule: use query-param tokens when you control the server logs and need the simplest implementation; use cookies when you are on the same domain and already have a session; use first-message auth when you need to keep tokens out of URLs entirely and can absorb the queuing complexity; use the subprotocol header only when cookies are blocked cross-domain and query-param exposure is unacceptable — it is non-standard and should be a last resort. Teams that would rather not own any of this often reach for a managed realtime platform, which handles token lifecycle as part of the service.

Token Renewal for Long-Lived Connections

A WebSocket connection outlives its token, so you need a renewal strategy decided up front. There are two: refresh the token in-band over the open socket, or close and reconnect with a fresh one. Use in-band token refresh — sending a fresh token over the existing connection — when the socket carries stateful subscriptions or pending operations that would be lost on reconnect; use close-and-reconnect when the connection is stateless and the client can cheaply re-establish context without data loss.

The client schedules the refresh from the JWT’s own expiry. The exp claim is a NumericDate in seconds since the Unix epoch per RFC 7519 §4.1.4, so you must multiply by 1000 before comparing to Date.now().

// Browser — schedule in-band refresh 60s before expiry, fall back to reconnect
class WebSocketAuthManager {
  constructor(url) {
    this.url = url;
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(`${this.url}?token=${getToken()}`);
    this.ws.onopen = () => this.scheduleRefresh();
    this.ws.onmessage = (e) => {
      const msg = JSON.parse(e.data);
      if (msg.type === 'TOKEN_REFRESH_OK') this.scheduleRefresh();
    };
  }

  scheduleRefresh() {
    const { exp } = parseJwt(getToken());        // exp is in seconds
    const fireAt = exp * 1000 - Date.now() - 60_000; // 60s before expiry
    clearTimeout(this.timer);
    this.timer = setTimeout(() => this.refresh(), Math.max(0, fireAt));
  }

  async refresh() {
    try {
      const fresh = await fetchNewToken();
      setToken(fresh);
      this.ws.send(JSON.stringify({ type: 'TOKEN_REFRESH', token: fresh }));
    } catch {
      this.ws.close(4004, 'refresh failed'); // fall back to a clean reconnect
      this.connect();
    }
  }
}

The server validates the refreshed token and updates the session attached to the socket — it does not tear the connection down, which is the entire point of the in-band path:

// Node.js server — ws@8.21.0
ws.on('message', (raw) => {
  const msg = JSON.parse(raw);
  if (msg.type === 'TOKEN_REFRESH') {
    try {
      ws.user = verify(msg.token, process.env.JWT_SECRET);
      ws.send(JSON.stringify({ type: 'TOKEN_REFRESH_OK' }));
    } catch {
      ws.close(4003, 'invalid refresh token');
    }
  }
});

Authorization Is an Ongoing Gate

WebSocket authentication is not a one-time event at connection open: because connections are long-lived, a token that was valid at handshake time may expire, be revoked, or lose its associated permissions while the socket is still open, making per-message authorization a production requirement, not an optional hardening step. Permissions change mid-connection through admin actions, subscription expiry, or moderation — and the handshake that ran an hour ago cannot account for any of it.

The fix is to check authorization on every inbound message, not just at connection time. Treat the handshake as establishing identity and each message as a fresh authorization decision:

// Node.js server — per-message permission check
const PERMISSIONS = { admin: ['read', 'write', 'delete'], user: ['read', 'write'], guest: ['read'] };

ws.on('message', (raw) => {
  const msg = JSON.parse(raw);
  const allowed = PERMISSIONS[ws.user.role] || [];
  if (!allowed.includes(msg.action)) {
    return ws.send(JSON.stringify({ type: 'FORBIDDEN', action: msg.action }));
  }
  handle(msg, ws);
});

Pair this with a revocation check so that a logged-out or banned user cannot keep operating on an already-open socket. A small in-memory Set of revoked token IDs works for a single process; a shared store such as Redis is needed once you run multiple WebSocket nodes, since revocation has to be visible to whichever node holds the connection.

Security Checklist and Debugging Silent Failures

Securing a WebSocket connection comes down to a short, concrete checklist. Run through it before shipping:

  • Use wss:// only. RFC 6455 §10.6 recommends running WebSockets over TLS for confidentiality and integrity. Plain ws:// exposes tokens and messages in transit.
  • Keep tokens short-lived. OWASP’s Session Management Cheat Sheet recommends idle timeouts of 2–5 minutes for high-value applications and 15–30 minutes for lower-risk ones; pick toward the short end for tokens sitting in the browser.
  • Enforce an auth timeout on first-message auth (5–10 seconds, by convention) and close unauthenticated sockets.
  • Rate-limit connections per IP or per user to cap unauthenticated socket churn. Any specific number (such as a maximum concurrent count) is illustrative, not a standard — set it from your own traffic baseline.
  • Support revocation. Check a revocation list on connect and on each message so a logged-out session cannot persist on an open socket.
  • Validate Origin for cookie-based auth, and set SameSite=Strict or Lax.

These items matter because WebSocket auth fails quietly. When a short-lived token expires mid-session and the server closes the socket, the browser fires a close event; if the application does not handle it, the UI can continue showing a “connected” state while every subsequent message is silently dropped — a failure mode invisible in server logs but visible in session replay, where we see the last successful message followed by client-side silence. The same is true of the first-message pattern: on a slow connection, messages queued before auth completes can be dropped if the auth timeout fires first, and session replay of those sessions shows the client sending messages that never produce a response while the socket still looks open. Replaying the client side is often the only way we can explain a class of bug that server logs only show as a close frame with no context.

Conclusion

Treat the missing Authorization header as the start, not the whole problem: choose a delivery method from the decision rule above, then decide your renewal strategy and wire per-message authorization before the connection is ever long-lived in production. The handshake proves who connected; everything after it is where real WebSocket security lives. Start by auditing one existing socket — confirm it runs over wss://, rejects expired tokens mid-session, and surfaces a closed connection to the user instead of silently dropping messages.

FAQs

Can I send an Authorization Bearer header with a browser WebSocket connection?

No. The browser WebSocket constructor accepts only a URL and an optional subprotocol array, so there is no API to attach a custom Authorization header to the opening handshake. The four practical alternatives are a token in the query string, a session cookie the browser sends automatically, a credential encoded in the Sec-WebSocket-Protocol subprotocol value, or a credential sent as the first message after the connection opens. Native WebSocket libraries outside the browser, such as Node.js clients, can set arbitrary headers, but browser code cannot.

What happens to messages sent before first-message authentication completes?

Messages sent before the AUTH handshake completes must be queued on the client and flushed only after the server confirms authentication, because anything sent earlier can be silently dropped. If the server's auth timeout fires before it receives valid credentials, typically within a 5 to 10 second window, the connection closes and any in-flight or queued messages are lost. On slow connections this produces a failure where the socket still appears open while messages never receive a response. Always gate application sends behind an authenticated flag.

Should I refresh a WebSocket token in-band or close and reconnect?

Use in-band refresh, sending a fresh token over the existing socket, when the connection carries stateful subscriptions or pending operations that would be lost on reconnect. Use close-and-reconnect when the connection is stateless and the client can cheaply re-establish context without data loss. The choice is architectural, not stylistic: reconnecting tears down server-side subscription state, so for a live feed with many active channels, in-band refresh avoids re-subscription overhead and dropped events during the gap.

Why does my WebSocket UI show 'connected' after the token expires?

The server closes the socket when the token expires and the browser fires a close event, but if the application's state management does not handle that event, the UI keeps showing a connected state while every subsequent message is silently dropped. This failure is invisible in server logs, which only record a close frame with no context, but visible in session replay as the last successful message followed by client-side silence. Handle the close event explicitly to surface the disconnect and trigger reconnection.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.