12k
All articles

WebSocket 身份认证详解

WebSocket 认证方法解析:URL token、Cookie、子协议和首条消息认证,并涵盖令牌续期、吊销与逐消息授权。

OpenReplay Team
OpenReplay Team
WebSocket 身份认证详解

浏览器 WebSocket API 不支持设置自定义 HTTP 请求头——WebSocket 构造函数仅接受 URL 和可选的子协议数组——因此身份认证必须通过以下三种机制之一实现:在 HTTP 升级握手期间将令牌放入查询字符串、浏览器自动发送的会话 Cookie,或在连接建立后的第一条消息中携带凭据。正是这一限制使 WebSocket 认证有别于 REST 认证——在 REST 中,你只需在每个请求中附加 Authorization: Bearer 请求头即可。

如果你已经使用 JWT 或会话 Cookie 实现了 REST 认证,那么你对这些概念已经相当熟悉。WebSocket 的不同之处在于传递机制和连接生命周期。REST 请求完成一次认证后即结束。而 WebSocket 连接会持续数分钟乃至数小时,这意味着握手时有效的令牌可能在连接仍然保持的情况下过期、被吊销或失去相应权限。本文将结合可运行的浏览器端和 Node.js 代码,逐一介绍握手认证与首消息认证两种模式,提供选型决策规则,并重点讲解大多数教程所忽略的两个关键问题:长连接的令牌续期与逐消息授权。

核心要点

  • 浏览器的 WebSocket 构造函数仅接受 URL 和子协议数组,因此无法发送 Authorization 请求头——认证需通过查询字符串、Cookie、Sec-WebSocket-Protocol 请求头或首条消息来实现。
  • WebSocket 身份认证是一个持续性的安全门控,而非一次性事件:由于连接是长期存活的,逐消息授权和令牌续期是生产环境的必要需求,而非可选的安全加固措施。
  • 当 Socket 承载有状态的订阅时,使用带内令牌刷新;当连接是无状态且上下文重建成本低廉时,使用关闭并重连的方式。
  • JWT 的 exp 声明是自 Unix 纪元以来的秒数(NumericDate),因此客户端在调度刷新时必须通过 exp * 1000 转换后再与 Date.now() 进行比较。
  • 一种常见的静默故障模式是:令牌过期后 Socket 已关闭,但 UI 仍显示”已连接”状态,导致后续所有消息被丢弃——这种情况在会话回放中清晰可见,却在服务器日志中毫无踪迹。

为什么 WebSocket 身份认证与众不同

WebSocket 身份认证在浏览器层面存在固有限制:JavaScript 的 WebSocket API 无法在开启握手时附加自定义请求头。每个 WebSocket 连接都以带有 Upgrade: websocket 请求头的 HTTP GET 请求开始,但浏览器完全控制该请求——你的代码只能提供 URL 和可选的子协议列表。WebSocket 协议本身也明确表示不解决这一问题:根据 RFC 6455 §10.5,该协议”未规定服务器在 WebSocket 握手期间对客户端进行身份认证的任何特定方式”。

浏览器缺少请求头支持这一限制催生了四种可行的变通机制:

  1. 查询参数令牌 — 将令牌放入连接 URL。
  2. Cookie/会话 — 让浏览器随升级请求自动发送现有的会话 Cookie。
  3. Sec-WebSocket-Protocol 子协议 — 将令牌夹带在子协议数组中。
  4. 首消息认证 — 以未认证状态打开 Socket,然后在第一条消息中发送凭据。

本节将使用 ws(当前稳定版:8.x)提供浏览器客户端代码和 Node.js 服务器代码,逐一介绍上述每种方式。

查询参数令牌

将令牌放入连接 URL,并在 HTTP 升级阶段、分配连接资源之前进行验证。这是最简单也是最广泛使用的方法,其优势在于可以快速拒绝——服务器可以在 Socket 建立之前返回 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();
  }
});

具体风险: WebSocket URL 中的令牌默认会被 nginxApache 的访问日志记录——两者的默认日志格式(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);
  });
});

具体风险: 基于 Cookie 的 WebSocket 认证要求服务器验证 Origin 请求头,并为 Cookie 设置 SameSite=StrictSameSite=Lax 属性。正如 MDN 的 SameSite 文档所述,SameSite 控制 Cookie 是否随跨站请求发送;SameSite=None(真正跨站 Cookie 的必要设置)会重新暴露 SameSite 本来要解决的 CSRF 攻击面。Cookie 在不同域名之间也无法使用,这正是团队转而采用后两种方法的主要原因。

Sec-WebSocket-Protocol 子协议

你可以将令牌作为子协议值传递,因为子协议数组是握手过程中浏览器唯一暴露给开发者的部分。Sec-WebSocket-Protocol 请求头的设计初衷是协商应用层子协议,而非承载身份认证令牌;将其用于认证是一种变通手段,虽然在所有主流浏览器中均可正常工作,但应作为最后手段使用。

子协议值必须是有效的 token 值且不含分隔符,因此令牌必须编码为无填充的 base64url 格式——标准 base64 中的 /= 字符在此处无效。base64url 字符集定义于 RFC 4648 §5

// 浏览器客户端 — 将令牌作为无填充 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;
  },
});

具体风险: 与查询参数方法类似,子协议值可能出现在记录握手请求头的日志中,且编码要求(无填充 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 秒(这是一种惯例,并无规范依据)——并关闭在此时间窗口内未发送有效凭据的连接。若不这样做,攻击者可以用大量已建立但未认证的 Socket 耗尽连接资源。合适的超时值取决于你的 p95 往返时延:5 秒适用于低延迟连接,10 秒对移动端和高延迟客户端更为安全。

方法对比与选型决策规则

应根据部署环境的约束条件来选择方法,而非抽象地追求”最安全”——只要正确实现,四种方法都是安全的。下表总结了各方法的权衡取舍;随后的决策规则可解决常见场景的选型问题。

方法令牌暴露风险CSRF 风险跨域支持实现复杂度适用场景
查询参数令牌日志、历史记录、Referer你能控制服务器日志且希望实现最简单
Cookie / 会话无(Cookie 为 HttpOnly有(通过 SameSite + Origin 缓解)同域且已有会话
子协议请求头握手日志Cookie 被跨域阻止且查询参数暴露不可接受
首消息认证令牌必须完全不出现在 URL 中

选型决策规则:当你能控制服务器日志且需要最简实现时,使用查询参数令牌当你在同域且已有会话时,使用 Cookie当令牌必须完全不出现在 URL 中且你能承受排队机制的复杂性时,使用首消息认证仅当 Cookie 被跨域阻止且查询参数暴露不可接受时,才使用子协议请求头——这是非标准用法,应作为最后手段。不希望自行处理上述任何问题的团队通常会选择托管实时平台,此类平台将令牌生命周期管理作为服务的一部分提供。

长连接的令牌续期

WebSocket 连接的生命周期会超过令牌的有效期,因此需要提前确定续期策略。策略有两种:通过已建立的 Socket 带内刷新令牌,或关闭连接并使用新令牌重连。当 Socket 承载有状态的订阅或重连会导致待处理操作丢失时,使用带内令牌刷新当连接是无状态的且客户端可以低成本重建上下文而不造成数据丢失时,使用关闭并重连的方式

客户端根据 JWT 自身的过期时间来调度刷新。根据 RFC 7519 §4.1.4exp 声明是自 Unix 纪元以来的秒数(NumericDate),因此在与 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();
    }
  }
}

服务器验证刷新后的令牌并更新与 Socket 关联的会话——不会断开连接,这正是带内刷新方式的核心价值所在:

// 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 身份认证并非连接建立时的一次性事件:由于连接是长期存活的,握手时有效的令牌可能在 Socket 仍然保持的情况下过期、被吊销或失去相应权限,因此逐消息授权是生产环境的必要需求,而非可选的安全加固措施。权限可能因管理员操作、订阅到期或内容审核等原因在连接期间发生变更——而一小时前完成的握手无法感知这些变化。

解决方案是在每条入站消息上检查授权,而不仅仅在连接时检查一次。将握手视为建立身份,将每条消息视为独立的授权决策:

// 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);
});

将此机制与吊销检查结合使用,以防止已登出或被封禁的用户继续在已建立的 Socket 上操作。对于单进程部署,一个小型内存 Set 存储已吊销的令牌 ID 即可;一旦运行多个 WebSocket 节点,则需要使用 Redis 等共享存储,因为吊销信息必须对持有连接的任意节点可见。

安全检查清单与静默故障排查

保障 WebSocket 连接安全可归结为一份简短而具体的检查清单。在上线前逐项确认:

  • 仅使用 wss:// RFC 6455 §10.6 建议通过 TLS 运行 WebSocket 以保障机密性和完整性。明文 ws:// 会在传输过程中暴露令牌和消息内容。
  • 保持令牌短期有效。 OWASP 的会话管理备忘单建议高价值应用的空闲超时为 2–5 分钟,低风险应用为 15–30 分钟;对于存储在浏览器中的令牌,应偏向较短的时限。
  • 强制执行认证超时:首消息认证的超时时间(按惯例为 5–10 秒),并关闭未认证的 Socket。
  • 限制连接速率,按 IP 或用户限制,以控制未认证 Socket 的消耗。任何具体数值(如最大并发连接数)仅供参考,并非标准规定——应根据你自己的流量基线来设定。
  • 支持令牌吊销。 在连接建立时和每条消息处理时检查吊销列表,防止已登出的会话在已建立的 Socket 上持续存活。
  • 验证 Origin 用于基于 Cookie 的认证,并设置 SameSite=StrictLax

这些事项至关重要,因为 WebSocket 认证失败往往是静默的。当短期令牌在会话中途过期,服务器关闭 Socket 时,浏览器会触发 close 事件;如果应用未处理该事件,UI 可能继续显示”已连接”状态,而后续所有消息被静默丢弃——这种故障在服务器日志中毫无踪迹,却在会话回放中清晰可见:最后一条成功消息之后,客户端陷入沉默。首消息认证模式同样如此:在网络较慢的情况下,认证完成前排队的消息可能因认证超时触发而被丢弃,在这些会话的回放中,可以看到客户端发送了消息却始终没有收到响应,而 Socket 看起来仍然是连接状态。回放客户端行为往往是唯一能够解释这类 Bug 的方式——服务器日志中只有一个没有上下文的关闭帧。

结论

将缺少 Authorization 请求头视为问题的起点,而非全部:根据上述决策规则选择合适的传递方式,然后在连接真正长期存活于生产环境之前,确定令牌续期策略并接入逐消息授权机制。握手证明了连接者的身份;握手之后的一切,才是 WebSocket 安全的真正所在。从审计一个现有的 Socket 开始——确认它运行在 wss:// 上、在会话中途拒绝过期令牌,并在连接断开时向用户明确提示,而不是静默丢弃消息。

常见问题

我可以在浏览器 WebSocket 连接中发送 Authorization Bearer 请求头吗?

不可以。浏览器的 WebSocket 构造函数仅接受 URL 和可选的子协议数组,因此没有 API 可以在开启握手时附加自定义 Authorization 请求头。四种可行的替代方案是:将令牌放入查询字符串、让浏览器自动发送会话 Cookie、将凭据编码在 Sec-WebSocket-Protocol 子协议值中,或在连接建立后作为第一条消息发送凭据。浏览器之外的原生 WebSocket 库(如 Node.js 客户端)可以设置任意请求头,但浏览器端代码无法做到。

在首消息认证完成之前发送的消息会怎样?

在 AUTH 握手完成之前发送的消息必须在客户端排队,仅在服务器确认认证后才能发送,因为提前发送的消息可能被静默丢弃。如果服务器的认证超时(通常为 5–10 秒)在收到有效凭据之前触发,连接将被关闭,所有在途或排队的消息都会丢失。在网络较慢的情况下,这会导致 Socket 看似仍然连接,但消息始终得不到响应。务必通过已认证标志来控制应用消息的发送。

应该通过带内方式刷新 WebSocket 令牌,还是关闭并重连?

当连接承载有状态的订阅或待处理操作且重连会导致这些数据丢失时,使用带内刷新——即通过现有 Socket 发送新令牌。当连接是无状态的且客户端可以低成本重建上下文而不造成数据丢失时,使用关闭并重连。这是架构层面的选择,而非风格偏好:重连会销毁服务器端的订阅状态,因此对于拥有大量活跃频道的实时数据流,带内刷新可以避免重新订阅的开销以及重连间隙中的事件丢失。

为什么令牌过期后我的 WebSocket UI 仍然显示"已连接"?

令牌过期时服务器会关闭 Socket,浏览器随即触发 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.