Authentication & secure tokens over WebSocketАутентификация и безопасные токены поверх WebSocket
The header problemПроблема заголовков
On the server, authenticating a WebSocket is easy: you read a header during the HTTP upgrade. In the browser it is not, because the WebSocket constructor takes only a URL and an optional subprotocol — you cannot set an Authorization header. That single limitation shapes every browser-side auth strategy.
There are three workable approaches, in rough order of preference.
На сервере аутентифицировать WebSocket легко: вы читаете заголовок во время HTTP-upgrade. В браузере — нет, потому что конструктор WebSocket принимает только URL и необязательный субпротокол — заголовок Authorization задать нельзя. Это единственное ограничение определяет все клиентские стратегии аутентификации.
Есть три рабочих подхода, примерно в порядке предпочтительности.
1. Cookies (same-origin)1. Cookie (same-origin)
If the WebSocket lives on the same origin as your app, the browser sends your existing HttpOnly session cookie with the upgrade automatically. The server validates it exactly like any other request. This is the simplest and safest option — the token never touches JavaScript.
Pair cookies with a CSRF defence (an Origin header check on upgrade), since cookies are sent cross-site by default.
Если WebSocket находится на том же origin, что и приложение, браузер автоматически отправляет существующую сессионную cookie HttpOnly вместе с upgrade. Сервер проверяет её как любой другой запрос. Это самый простой и безопасный вариант — токен вообще не попадает в JavaScript.
Сочетайте cookie с защитой от CSRF (проверка заголовка Origin при upgrade), поскольку cookie по умолчанию шлются и при кросс-сайт запросах.
2. The ticket pattern2. Паттерн «тикет»
When the socket is on a different origin (a dedicated realtime host), the cleanest pattern is a short-lived, single-use ticket. Your normal authenticated REST API mints it; the client passes it in the query string when connecting:
// 1) Ask the authenticated API for a ticket (Bearer auth here is fine — it's plain HTTP) const { ticket } = await fetch("/api/ws-ticket", { headers: { Authorization: `Bearer ${accessToken}` } }).then(r => r.json()); // 2) Open the socket with the ticket — it's short-lived and one-time const ws = new WebSocket(`wss://rt.example.com/v1?ticket=${ticket}`);
The ticket is valid for a few seconds and is consumed on first use, so even if it leaks into a log it is worthless moments later.
server.on("upgrade", async (req, socket, head) => { const url = new URL(req.url, "http://x"); const ticket = url.searchParams.get("ticket"); const user = await redeemTicket(ticket); // null if invalid/expired/used if (!user) { socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { ws.user = user; // attach identity wss.emit("connection", ws, req); }); });
Когда сокет на другом origin (выделенный realtime-хост), самый чистый паттерн — короткоживущий одноразовый тикет. Ваш обычный аутентифицированный REST API выдаёт его; клиент передаёт его в query-строке при подключении:
// 1) Запрашиваем тикет у аутентифицированного API (Bearer тут ок — это обычный HTTP) const { ticket } = await fetch("/api/ws-ticket", { headers: { Authorization: `Bearer ${accessToken}` } }).then(r => r.json()); // 2) Открываем сокет с тикетом — он короткоживущий и одноразовый const ws = new WebSocket(`wss://rt.example.com/v1?ticket=${ticket}`);
Тикет действует несколько секунд и «гасится» при первом использовании, поэтому даже попав в лог он через мгновение бесполезен.
server.on("upgrade", async (req, socket, head) => { const url = new URL(req.url, "http://x"); const ticket = url.searchParams.get("ticket"); const user = await redeemTicket(ticket); // null если невалиден/истёк/использован if (!user) { socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { ws.user = user; // привязываем личность wss.emit("connection", ws, req); }); });
3. Token via subprotocol3. Токен через субпротокол
The one place the constructor lets you smuggle a value is the subprotocol argument, which becomes the Sec-WebSocket-Protocol header. Some teams use it to carry a token, though it is visible in the same places a query string is:
const ws = new WebSocket(url, ["json", `bearer.${token}`]); // Server must echo back exactly one offered subprotocol to accept.
Whatever you choose, never use a long-lived access token in the URL of an unencrypted connection — and always use wss://.
Единственное место, куда конструктор позволяет «протащить» значение, — аргумент субпротокола, который становится заголовком Sec-WebSocket-Protocol. Некоторые команды используют его для токена, хотя он виден там же, где и query-строка:
const ws = new WebSocket(url, ["json", `bearer.${token}`]); // Сервер должен вернуть ровно один из предложенных субпротоколов, чтобы принять.
Что бы вы ни выбрали, никогда не передавайте долгоживущий access-токен в URL незашифрованного соединения — и всегда используйте wss://.
Re-auth and revocationПовторная аутентификация и отзыв
A WebSocket can live for hours, but tokens expire. Authenticate once on upgrade, then either: (a) trust the connection for its lifetime and rely on heartbeats + a max session age to force a reconnect, or (b) require the client to send a fresh token periodically as an in-band message, closing with code 1008 (policy violation) or a custom 4401 if it lapses. Your reconnect logic should treat those codes as “stop and re-login,” not “retry.”
WebSocket может жить часами, но токены истекают. Аутентифицируйтесь один раз при upgrade, затем либо: (а) доверяйте соединению на всё его время и полагайтесь на heartbeat + максимальный возраст сессии для принудительного переподключения, либо (б) требуйте от клиента периодически слать свежий токен внутриканальным сообщением, закрывая соединение кодом 1008 (нарушение политики) или своим 4401 при просрочке. Ваша логика переподключения должна трактовать эти коды как «остановиться и перелогиниться», а не «повторить».