12k
All articles

Аутентификация WebSocket: подробное руководство

Методы аутентификации WebSocket: токен в URL, cookies, подпротоколы и первое сообщение, а также обновление токена и проверка прав на каждый запрос.

OpenReplay Team
OpenReplay Team
Аутентификация WebSocket: подробное руководство

Браузерный API WebSocket не поддерживает установку пользовательских HTTP-заголовков — конструктор WebSocket принимает только URL и необязательный массив субпротоколов, — поэтому аутентификация должна осуществляться одним из трёх способов: токен в строке запроса во время HTTP-рукопожатия обновления соединения, сессионная cookie, которую браузер отправляет автоматически, или учётные данные в первом сообщении после установки соединения. Именно это ограничение делает аутентификацию WebSocket отличной от аутентификации REST, где достаточно просто добавить заголовок Authorization: Bearer к каждому запросу.

Если вы уже реализовывали REST-аутентификацию с JWT или сессионными cookie, вам знакомы все составляющие. Что меняется в случае WebSocket — так это механизм доставки и время жизни. REST-запрос аутентифицируется один раз и завершается. WebSocket-соединение остаётся открытым минутами или часами, а значит, токен, действительный в момент рукопожатия, может истечь, быть отозван или потерять свои права доступа, пока сокет ещё открыт. В этой статье рассматривается каждый из паттернов — рукопожатие и первое сообщение — с рабочим кодом для браузера и Node.js, приводится правило выбора подходящего метода, а также разбираются два аспекта, которые большинство руководств обходит стороной: обновление токена в долгоживущих соединениях и авторизация на уровне отдельных сообщений.

Ключевые выводы

  • Конструктор браузерного WebSocket принимает только URL и массив субпротоколов, поэтому отправить заголовок Authorization невозможно — аутентификация переносится в строку запроса, cookie, заголовок Sec-WebSocket-Protocol или первое сообщение.
  • Аутентификация WebSocket — это непрерывный контроль, а не однократное событие: поскольку соединения долгоживущие, авторизация на уровне сообщений и обновление токенов являются обязательными требованиями для продакшена, а не опциональным усилением безопасности.
  • Используйте внутриполосное обновление токена, когда сокет несёт состояние подписок; используйте закрытие и переподключение, когда соединение не имеет состояния и контекст легко восстановить.
  • Поле exp в JWT — это NumericDate в секундах от эпохи Unix, поэтому при планировании обновления на стороне клиента необходимо выполнять преобразование exp * 1000 перед сравнением с Date.now().
  • Типичный незаметный сбой — сокет, закрывающийся по истечении срока действия токена, тогда как UI продолжает отображать статус «подключено» и молча теряет все последующие сообщения. Такой сбой виден в записях сессий, но не отражается в серверных логах.

Чем аутентификация WebSocket отличается от других подходов

Аутентификация WebSocket ограничена на уровне браузера: JavaScript API WebSocket не предоставляет возможности добавить пользовательский заголовок к открывающему рукопожатию. Каждое WebSocket-соединение начинается как HTTP-запрос GET с заголовком Upgrade: websocket, однако браузер полностью контролирует этот запрос — ваш код может указать только URL и необязательный список субпротоколов. Сам протокол WebSocket прямо указывает, что не решает эту задачу: согласно RFC 6455 §10.5, протокол «не предписывает какого-либо конкретного способа аутентификации клиентов сервером во время рукопожатия WebSocket».

Отсутствие возможности задать заголовок в браузере оставляет четыре практических механизма, каждый из которых обходит это ограничение:

  1. Токен в параметре запроса — разместить токен в URL соединения.
  2. Cookie / сессия — позволить браузеру автоматически отправить существующую сессионную cookie вместе с запросом обновления соединения.
  3. Субпротокол Sec-WebSocket-Protocol — передать токен в массиве субпротоколов.
  4. Аутентификация через первое сообщение — открыть сокет без аутентификации, а затем отправить учётные данные в первом сообщении.

Далее каждый из этих методов рассматривается с кодом для браузерного клиента и Node.js-сервера с использованием библиотеки ws (актуальная стабильная версия: 8.x).

Токен в параметре запроса

Разместите токен в URL соединения и проверьте его в процессе HTTP-рукопожатия, до выделения ресурсов для соединения. Это наиболее простой и широко используемый метод; его преимущество — быстрое отклонение: сервер может вернуть 401 ещё до установки сокета.

// Браузерный клиент
const token = localStorage.getItem('authToken');
const socket = new WebSocket(`wss://api.example.com/ws?token=${encodeURIComponent(token)}`);
// Node.js-сервер — 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();
  }
});

Конкретный риск: токен в URL WebSocket-соединения по умолчанию записывается в журналы доступа nginx и Apache — оба стандартных формата логов (combined / %r) включают полную строку запроса вместе со строкой параметров, — может попасть в историю браузера и утечь через заголовок Referer, если страница выполняет навигацию после установки соединения. Использование токенов с коротким сроком действия (5–15 минут — общепринятая практика, а не задокументированная рекомендация OWASP) снижает, но не устраняет этот риск.

Аутентификация на основе cookie повторно использует сессию, уже установленную через HTTP: браузер автоматически прикрепляет cookie того же домена к запросу обновления соединения, поэтому никакого кода аутентификации на стороне клиента не требуется. Это наименее трудоёмкий метод, когда WebSocket-эндпоинт находится на том же домене, что и приложение, выполнившее вход пользователя.

// Браузерный клиент — обработка токена не нужна; cookie передаётся автоматически
const socket = new WebSocket('wss://app.example.com/ws');
// Node.js-сервер — 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);
  });
});

Конкретный риск: аутентификация WebSocket на основе cookie требует как проверки заголовка Origin на сервере, так и атрибута cookie SameSite=Strict или SameSite=Lax. Как описано в документации MDN по SameSite, атрибут SameSite определяет, будет ли cookie отправляться в кросс-сайтовых запросах; значение SameSite=None (обязательное для полностью кросс-сайтовых cookie) возвращает CSRF-уязвимость, которую SameSite был призван устранить. Кроме того, cookie не работают между разными доменами — именно поэтому команды обращаются к двум следующим методам.

Субпротокол Sec-WebSocket-Protocol

Токен можно передать в качестве значения субпротокола, поскольку массив субпротоколов — единственная часть рукопожатия, к которой браузер предоставляет доступ. Заголовок Sec-WebSocket-Protocol предназначен для согласования субпротоколов прикладного уровня, а не для передачи токенов аутентификации; использование его для этой цели является обходным решением, которое работает во всех основных браузерах, но должно рассматриваться как крайняя мера.

Значения субпротокола должны быть корректными значениями token без символов-разделителей, поэтому токен необходимо закодировать в base64url без дополнения (unpadded base64url) — символы / и = стандартного base64 здесь недопустимы. Алфавит base64url определён в RFC 4648 §5.

// Браузерный клиент — токен в виде значения субпротокола unpadded base64url
const token = localStorage.getItem('authToken'); // уже в формате base64url без дополнения
const socket = new WebSocket('wss://api.example.com/ws', ['auth.bearer', token]);
// Node.js-сервер — ws@8.21.0
const wss = new WebSocketServer({
  noServer: true,
  handleProtocols: (protocols) => {
    // protocols — это Set; второй элемент — наш токен
    const [, token] = [...protocols];
    return validate(token) ? 'auth.bearer' : false;
  },
});

Конкретный риск: как и в случае с параметром запроса, значение субпротокола может попасть в логи, записывающие заголовки рукопожатия, а требование к кодированию (unpadded base64url) легко нарушить. Используйте этот метод только тогда, когда cookie заблокированы из-за кросс-доменных ограничений, а раскрытие токена через параметр запроса недопустимо.

Аутентификация через первое сообщение

Соединение открывается без аутентификации, и клиент отправляет учётные данные в своём первом сообщении; сервер выполняет проверку перед обработкой чего-либо ещё. Этот подход полностью исключает попадание токенов в URL и логи, однако требует реализации собственного протокола с очередью сообщений и таймаутом.

// Браузерный клиент — ставить сообщения приложения в очередь до успешного AUTH
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-сервер — 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');
    }
  });
});

Конкретный риск: сервер, принимающий неаутентифицированные WebSocket-соединения, должен применять жёсткий таймаут аутентификации — как правило, 5–10 секунд, что является сложившейся практикой, не имеющей нормативного обоснования, — и закрывать любое соединение, не предоставившее корректные учётные данные в течение этого времени. Без этого злоумышленник может исчерпать лимиты соединений, открыв множество неаутентифицированных сокетов. Оптимальное значение зависит от 95-го перцентиля времени приёма-передачи: 5 секунд подходит для низколатентных соединений, 10 секунд — более безопасный выбор для мобильных клиентов и соединений с высокой задержкой.

Сравнение методов и правило выбора

Выбирайте метод исходя из ограничений вашего развёртывания, а не из абстрактного понятия «наибольшей безопасности» — все четыре метода безопасны при правильной реализации. В таблице ниже приведены компромиссы; следующее за ней правило выбора охватывает наиболее распространённые случаи.

МетодРаскрытие токенаРиск CSRFКросс-доменСложностьКогда использовать
Токен в параметре запросаЛоги, история, RefererНетДаНизкаяВы контролируете серверные логи и хотите простейшую реализацию
Cookie / сессияНет (cookie с флагом HttpOnly)Да (снижается с помощью SameSite + Origin)НетНизкаяОдин домен, существующая сессия
Заголовок субпротоколаЛоги рукопожатияНетДаСредняяCookie заблокированы, параметры запроса неприемлемы
Первое сообщениеНетНетДаВысокаяТокены не должны попадать в URL

Правило выбора: используйте токен в параметре запроса, если вы контролируете серверные логи и вам нужна простейшая реализация; используйте cookie, если вы работаете на одном домене и уже имеете сессию; используйте аутентификацию через первое сообщение, если токены не должны попадать в URL и вы готовы реализовать логику очередей; используйте заголовок субпротокола только тогда, когда cookie заблокированы из-за кросс-доменных ограничений, а раскрытие через параметр запроса недопустимо — это нестандартный подход, к которому следует прибегать в последнюю очередь. Команды, не желающие реализовывать всё это самостоятельно, нередко выбирают управляемые платформы реального времени, которые берут на себя управление жизненным циклом токенов.

Обновление токена в долгоживущих соединениях

WebSocket-соединение переживает свой токен, поэтому стратегию обновления необходимо определить заранее. Существует два подхода: обновить токен внутри полосы через открытый сокет или закрыть соединение и переподключиться с новым токеном. Используйте внутриполосное обновление токена — отправку нового токена через существующее соединение — когда сокет несёт состояние подписок или незавершённые операции, которые будут потеряны при переподключении; используйте закрытие и переподключение, когда соединение не имеет состояния и клиент может дёшево восстановить контекст без потери данных.

Клиент планирует обновление на основе времени истечения самого JWT. Поле exp представляет собой NumericDate в секундах от эпохи Unix согласно RFC 7519 §4.1.4, поэтому перед сравнением с Date.now() необходимо умножить его на 1000.

// Браузер — планирование внутриполосного обновления за 60 с до истечения, с откатом на переподключение
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 в секундах
    const fireAt = exp * 1000 - Date.now() - 60_000; // за 60 с до истечения
    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'); // откат на чистое переподключение
      this.connect();
    }
  }
}

Сервер проверяет обновлённый токен и обновляет сессию, привязанную к сокету, не разрывая соединение — в этом и состоит смысл внутриполосного подхода:

// Node.js-сервер — 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');
    }
  }
});

Авторизация как непрерывный контроль

Аутентификация WebSocket — это не однократное событие при открытии соединения: поскольку соединения долгоживущие, токен, действительный в момент рукопожатия, может истечь, быть отозван или лишиться связанных прав доступа, пока сокет ещё открыт. Это делает авторизацию на уровне каждого сообщения обязательным требованием для продакшена, а не опциональным усилением безопасности. Права доступа могут измениться в середине сессии в результате административных действий, истечения подписки или модерации — и рукопожатие, выполненное час назад, не может учесть ничего из этого.

Решение — проверять авторизацию при каждом входящем сообщении, а не только в момент подключения. Рассматривайте рукопожатие как установление личности, а каждое сообщение — как самостоятельное решение об авторизации:

// Node.js-сервер — проверка прав доступа для каждого сообщения
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);
});

Дополните это проверкой отзыва, чтобы вышедший из системы или заблокированный пользователь не мог продолжать работу через уже открытый сокет. Небольшой Set в памяти с идентификаторами отозванных токенов подходит для одного процесса; при запуске нескольких WebSocket-узлов необходимо общее хранилище, например Redis, поскольку информация об отзыве должна быть доступна любому узлу, удерживающему соединение.

Контрольный список безопасности и отладка незаметных сбоев

Защита WebSocket-соединения сводится к короткому конкретному контрольному списку. Проверьте каждый пункт перед выпуском в продакшен:

  • Используйте только wss://. RFC 6455 §10.6 рекомендует запускать WebSocket поверх TLS для обеспечения конфиденциальности и целостности. Незащищённый ws:// раскрывает токены и сообщения при передаче.
  • Используйте токены с коротким сроком действия. Шпаргалка OWASP по управлению сессиями рекомендует таймаут бездействия 2–5 минут для критически важных приложений и 15–30 минут для менее рискованных; для токенов, хранящихся в браузере, выбирайте меньшие значения.
  • Применяйте таймаут аутентификации при использовании первого сообщения (5–10 секунд по сложившейся практике) и закрывайте неаутентифицированные сокеты.
  • Ограничивайте количество соединений по IP или пользователю, чтобы сдержать поток неаутентифицированных сокетов. Любое конкретное число (например, максимальное количество одновременных соединений) носит иллюстративный характер, а не является стандартом — устанавливайте его исходя из вашего трафика.
  • Поддерживайте отзыв токенов. Проверяйте список отзыва при подключении и при каждом сообщении, чтобы вышедшая из системы сессия не сохранялась на открытом сокете.
  • Проверяйте Origin при аутентификации на основе cookie и устанавливайте SameSite=Strict или Lax.

Эти меры важны потому, что сбои аутентификации WebSocket происходят незаметно. Когда токен с коротким сроком действия истекает в середине сессии и сервер закрывает сокет, браузер генерирует событие close; если приложение не обрабатывает его, UI продолжает отображать статус «подключено», тогда как все последующие сообщения молча теряются — такой сбой невидим в серверных логах, которые фиксируют лишь фрейм закрытия без контекста, но виден в записях сессий: последнее успешное сообщение сменяется тишиной на стороне клиента. То же характерно для паттерна с первым сообщением: на медленном соединении сообщения, поставленные в очередь до завершения аутентификации, могут быть потеряны, если таймаут аутентификации срабатывает первым, — и воспроизведение таких сессий показывает, что клиент отправляет сообщения, не получая ответа, пока сокет всё ещё выглядит открытым. Воспроизведение клиентской стороны нередко является единственным способом объяснить класс ошибок, которые в серверных логах отображаются лишь как фрейм закрытия без какого-либо контекста.

Заключение

Воспринимайте отсутствие заголовка Authorization как отправную точку, а не как всю проблему целиком: выберите метод доставки по приведённому выше правилу, затем определите стратегию обновления и реализуйте авторизацию на уровне сообщений ещё до того, как соединение станет долгоживущим в продакшене. Рукопожатие устанавливает личность; всё, что происходит после него, — вот где живёт настоящая безопасность WebSocket. Начните с аудита одного существующего сокета: убедитесь, что он работает через wss://, отклоняет истёкшие токены в середине сессии и сообщает пользователю о разрыве соединения вместо того, чтобы молча терять сообщения.

Часто задаваемые вопросы

Можно ли отправить заголовок Authorization Bearer в браузерном WebSocket-соединении?

Нет. Конструктор браузерного WebSocket принимает только URL и необязательный массив субпротоколов, поэтому API не предоставляет возможности добавить пользовательский заголовок Authorization к открывающему рукопожатию. Четыре практических альтернативы: токен в строке запроса, сессионная cookie, отправляемая браузером автоматически, учётные данные, закодированные в значении субпротокола Sec-WebSocket-Protocol, или учётные данные, отправляемые в первом сообщении после открытия соединения. Нативные WebSocket-библиотеки вне браузера, например клиенты Node.js, могут устанавливать произвольные заголовки, однако браузерный код — нет.

Что происходит с сообщениями, отправленными до завершения аутентификации через первое сообщение?

Сообщения, отправленные до завершения AUTH-рукопожатия, должны ставиться в очередь на клиенте и отправляться только после подтверждения аутентификации сервером, поскольку всё отправленное ранее может быть молча потеряно. Если таймаут аутентификации на сервере срабатывает до получения корректных учётных данных — как правило, в течение 5–10 секунд — соединение закрывается, и все сообщения в очереди или в процессе передачи теряются. На медленных соединениях это приводит к ситуации, когда сокет всё ещё выглядит открытым, а сообщения не получают ответа. Всегда блокируйте отправку сообщений приложения флагом аутентификации.

Следует ли обновлять токен WebSocket внутри полосы или закрывать соединение и переподключаться?

Используйте внутриполосное обновление — отправку нового токена через существующий сокет — когда соединение несёт состояние подписок или незавершённые операции, которые будут потеряны при переподключении. Используйте закрытие и переподключение, когда соединение не имеет состояния и клиент может дёшево восстановить контекст без потери данных. Выбор носит архитектурный, а не стилистический характер: переподключение разрушает серверное состояние подписок, поэтому для живой ленты с множеством активных каналов внутриполосное обновление позволяет избежать накладных расходов на повторную подписку и потери событий в период разрыва.

Почему UI WebSocket показывает 'подключено' после истечения срока действия токена?

Сервер закрывает сокет по истечении срока действия токена, и браузер генерирует событие close; однако если управление состоянием приложения не обрабатывает это событие, UI продолжает отображать статус подключения, тогда как все последующие сообщения молча теряются. Этот сбой невидим в серверных логах, которые фиксируют лишь фрейм закрытия без контекста, но виден в записях сессий как последнее успешное сообщение, после которого наступает тишина на стороне клиента. Явно обрабатывайте событие close, чтобы сообщать пользователю о разрыве соединения и инициировать переподключение.

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.