WebSocket鉴权从握手到消息级授权的完整落地指南

发布时间:2026/10/19 11:17:25
WebSocket鉴权从握手到消息级授权的完整落地指南
WebSocket鉴权这个事我前阵子在联调一个实时告警模块时才真正意识到它有多容易被低估。产品那边说“就加个鉴权嘛和HTTP一样带个token不就行了”结果真到了浏览器端才发现原生WebSocket API根本不允许你随便加请求头。项目拖了两天最后把方案改成“临时票据握手拦截连接级版本踢人”才落地。这篇就把整个思考过程、踩过的坑、以及最终敲定的完整方案写出来希望能帮你少走几步弯路。1. WebSocket鉴权为什么是个老生常谈但总是踩坑的问题先说最基本的事实WebSocket的连接建立过程本质上是一次HTTP Upgrade请求。客户端发一个带Upgrade: websocket头部的HTTP请求服务端返回101状态码之后双方再从HTTP协议切换到WebSocket双向通道。问题就出在这个“本质上是一次HTTP请求”上。HTTP鉴权最成熟的那套玩法——Authorization: Bearer token请求头、Cookie自动携带、自定义Header——在浏览器端的WebSocket场景里要么施展不开要么有各种隐蔽副作用。我见过太多人第一次写WebSocket鉴权时下意识写出的代码是这样的// 错误理解以为和axios一样传个headers配置就行 const ws new WebSocket(wss://api.example.com/ws, { headers: { Authorization: Bearer token, }, });但浏览器端的WebSocket API并不支持这种配置。原生构造函数只有两个参数url和protocols一个字符串或者字符串数组。在浏览器环境下你是没法通过原生API给握手请求添加自定义Header的。这是平台层的硬限制不是后端不支持。所以WebSocket鉴权的第一性问题就成了既然HTTP那套“带Header”的玩法在浏览器里被卡住了那token到底该放在哪里带过去顺着这个思路去查资料能看到的大多是零散的方案碎片有的人说放URL query里有的人说放Sec-WebSocket-Protocol里有的人说用Cookie。每个方案单独看都能跑通但如果你不把“为什么这么选”和“后续代价是什么”想清楚很容易在某个边缘场景上翻车。另外还有一个概念上的误区需要先掰扯清楚鉴权和授权不是一回事。鉴权解决的是“你是谁”的问题发生在握手阶段授权解决的是“你能干什么”的问题发生在连接建立之后的每条消息上。很多人把握手鉴权做完就以为万事大吉结果忘了消息级授权后来被人直接遍历接口拿数据这就不是踩坑的问题了是安全事故。在动手设计之前还需要认清一个事实WebSocket一旦建立就是一条长连接。HTTP鉴权那套“每次请求都带上凭证凭证失效立刻401”的模型在长连接上并不直接成立。连接建立之后token过期了你能怎么办把连接掐断让客户端重连还是继续允许通信这个决策直接决定了你的体系复杂度后面我会专门用一章来讲。理解了这些前提我们再来看具体的方案选型。2. 握手前的三种Token传递方式选型会决定后续命运把token从浏览器带到服务端细数下来主流就那么几条路各有各的适用场景选错了解起来很痛苦。2.1 URL Query传Token最直接但暴露面也最大这是实现成本最低的方案。客户端在拼接WebSocket地址时直接把token加到query参数里const ws new WebSocket(wss://api.example.com/ws?token${token});服务端再从URL参数里解析。代码简单到不需要多解释。但这个方案有一个非常容易被忽视的坑URL会出现在访问日志里。不管是Nginx的access_log还是网关的请求日志都会把完整URL记录下来。而query里的token一旦进了日志系统就等于把凭证明文留在了存储里。再往下走日志可能被采集到日志平台、被同步到数据仓库、被各种人检索。你的token有效期要是设得长泄露面就非常大。我并不是完全否定这个方案。它有一个非常合适的应用场景配合短期ticket使用。后面第三章我会讲“先换临时票据、再建立连接”的做法那个票据有效期通常只有几十秒到几分钟就算进了日志攻击者拿到的也只是个很快就失效的一次性凭证风险被压缩到了一个很小的时间窗口内。如果你的token本身就是短期ticket或者你的场景对日志脱敏有严格管控那URL query的方式其实可以接受。2.2 子协议传递不落日志但很多人不会用第二个方案是利用WebSocket握手协议里的Sec-WebSocket-Protocol子协议字段。这个字段的本意是让客户端告诉服务端“我支持哪些应用层协议”服务端在握手响应里选择其中一个回传。关键在于握手请求里只要带了非空的Sec-WebSocket-Protocol头服务端就必须在响应头里也带一个对应的值否则浏览器会直接判定握手失败。有人就想到既然这个头可以自由定制内容那我不妨把token放进去const protocols [myapp-protocol, auth.${token}]; const ws new WebSocket(wss://api.example.com/ws, protocols);这里传的数组会被浏览器转成一个逗号分隔的Sec-WebSocket-Protocol头。服务端需要从请求头里拿到这个值解析出token然后在校验通过后在响应头里回传同样的协议名。这样做的好处是token不会出现在URL里不落访问日志浏览器地址栏和历史记录里也看不到。缺点也明显你等于“挪用”了一个本是用来做应用层协议协商的字段如果团队内部没有约定好格式维护起来很别扭。还有就是某些网关或代理对Sec-WebSocket-Protocol可能有特殊处理比如强制要求只能使用特定值接入时得先确认你的链路环节都放行。这个方案在不少大厂内部系统里其实用得很多因为日志泄漏问题是实打实的痛点。它适合那种“前后端同源、走自有网关、并且有团队规范约束”的场景。2.3 Cookie传递同域部署最简单跨域很头疼如果前端页面和后端WebSocket服务部署在同域名下Cookie方案是最省事的。浏览器发握手请求时会自动带上该域名下的Cookie服务端直接从Cookie头里解析会话ID或token就行了前端根本不用操心。麻烦的是跨域场景。前端页面在app.example.comWebSocket服务在ws.example.com这就涉及跨域Cookie携带。SameSite属性和第三方Cookie策略会让你的Cookie悄无声息地不出现。要解决跨域Cookie传递你得给Cookie配置SameSiteNone; Secure这要求连接必须是HTTPS。一旦你在本地用http://localhost调试会直接发现Cookie发不出去排查半天还以为是代码的问题。另外Cookie方案还有一个安全上的特殊敌人——跨站WebSocket劫持CSWSH。恶意网站可以构造一个指向你服务的WebSocket连接浏览器会默默带上Cookie如果你的服务端只校验Cookie而不校验Origin头黑客的页面就能以用户身份建立连接。用Cookie方案时服务端必须校验Origin头这是强制措施不是可选项。2.4 三种常见方式的直观对比传递方式实现成本URL日志暴露风险浏览器兼容性跨域友好度推荐场景URL Query极低高除非用短期票据全支持支持不涉及Cookie策略一次性ticket日志可脱敏子协议中低全支持支持但与网关兼容性需验证同源部署、自有网关Cookie低低全支持低SameSite策略限制前后端严格同域且能校验Origin2.5 非浏览器客户端其实没这么麻烦这一段是容易被教程忽略的补充。原生App、桌面客户端、后端服务之间建立WebSocket连接时并不受浏览器API限制。你可以任意添加自定义Header。比如Python的websockets库、Node.js的ws库、Java的OkHttp都支持在握手时设置Header。所以如果你主要服务的客户端是App而不是浏览器那事情就简单多了直接走Authorization: Bearer token和你写HTTP接口时一模一样。选型这件事我的原则很简单先搞清楚你的客户端是什么、部署拓扑是什么、日志链路是否可控再决定用哪个方式。没有凭空最优的方案。3. 服务端握手拦截从拿到Token到建立连接的完整校验链路确定了token怎么传过来接下来就是服务端在握手阶段怎么拦、怎么校验、以及失败之后前端怎么感知。这一章我把一个完整的校验链路拆开来讲。3.1 拦截点应用层做最终校验网关层做粗筛在服务端架构里你至少有两个地方可以做拦截。第一层是Nginx或API网关。这里可以对URL参数或Cookie做一些快速校验比如判断token是否存在、是否满足基本格式不满足就直接返回403。这一层的价值是挡掉明显无效的请求减轻应用层压力。第二层是应用层。这里才是校验的主战场验签、查状态、判断用户是否被禁用、是否已在别处登录等等。只有应用层能拿到完整的用户上下文。3.2 拿到Token后的完整校验步骤从请求的不同位置尝试获取凭证这是一个细节但非常关键的设计。因为客户端可能因为版本差异从不同位置传。我通常这样写// 伪代码演示服务端从多个位置取token public String resolveToken(ServerHttpRequest request) { // 1. 先从Header尝试获取 String headerToken request.getHeaders().getFirst(Authorization); if (headerToken ! null headerToken.startsWith(Bearer )) { return headerToken.substring(7); } // 2. 再从URL query获取 String queryToken request.getURI().getQuery(); if (queryToken ! null queryToken.contains(token)) { // 实际应使用解析工具这里示意 return extractQueryParam(queryToken, token); } // 3. 然后从Cookie尝试获取 // ... Cookie逻辑 // 4. 最后从Sec-WebSocket-Protocol获取 // ... }拿到token之后按顺序做四件事空值校验token为空直接拒绝。签名校验解析JWT或用统一凭证服务验签确认token不是伪造的。有效期校验token过期拒绝握手。会话状态校验这一步很多人会漏。即使token本身有效也要去Redis或用户中心查一下当前会话是否已经被踢下线、用户是否被封禁。这样你才能支持“管理员踢人”和“顶号”这类运营操作。整个过程不要只校验签名然后就直接放行。要知道JWT过期前一直是有效的但它只代表“签发时用户确实登录过”不代表“用户当前的登录状态仍然有效”。如果用户已经改了密码或账号被冻结一个没过期的JWT还是能通过验签。所以必须在握手时主动查一次会话状态。3.3 用拦截器落地的Java示例以Java Spring框架为例实现HandshakeInterceptorComponent public class AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { // 1. 解析token从Header、Query、Cookie、子协议任一位置 String token resolveToken(request); if (token null || !validateToken(token)) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } // 2. 校验通过后把用户信息放入attributes // 这个attributes会传递到WebSocketSession中方便后续业务使用 attributes.put(userId, parseUserId(token)); attributes.put(tokenVersion, queryTokenVersion(token)); return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手结束后可以记录日志或者做统计 } }拦截器返回false时握手就失败了。但这里有一个非常容易让前端同事困惑的点浏览器端的WebSocket握手失败时前端无法读取服务端返回的状态码和响应体。你在服务端返回了什么JSON错误消息浏览器一概看不到前端只能触发onerror回调看到一个干巴巴的“连接失败”。这就是为什么我建议不要在服务端花太多精力去构造精细的错误响应体——前端根本拿不到。更好的做法是让客户端在建立WebSocket之前先通过一个普通的HTTP接口获取“临时票据”如果这个接口返回明确错误前端能拿到具体的状态码和提示信息再决定是否继续。3.4 推荐流程先换临时票据再建立连接这个“先取票再连接”的结构是我在项目里实际用下来最顺畅的一套。客户端先调用HTTP接口POST /api/ws/ticket这个接口走常规的HTTP鉴权Header带token或者登录态Cookie服务端校验通过后返回一个一次性、短期有效的连接票据。然后客户端再用这个票据去拼WebSocket地址// 第一步向HTTP接口换取临时票据 const resp await fetch(/api/ws/ticket, { headers: { Authorization: Bearer token }, }); const { ticket } await resp.json(); // 第二步用票据建立WebSocket连接 const ws new WebSocket(wss://api.example.com/ws?ticket${ticket});这样有几个明显的好处前端能够获得明确的错误反馈HTTP接口返回的信息它是能读到的不会在握手失败时一头雾水。票据有效期短我一般设置30到60秒即使出现在URL日志里风险窗口也很小。票据可以一次性使用用完即焚进一步降低重放攻击的可能。票据里可以绑定用户ID和客户端ID服务端建立连接时能直接拿到用户身份。这个模式本质上是用“短期凭证一次性使用”去对冲“token必须通过URL传递”这个没有完美解的问题。实际项目中把票据有效期设短、库存Redis里设置一次性消费安全性很稳。3.5 连接建立后的注册与身份绑定握手通过只是第一步。连接真正建立后你需要维护一份“在线连接表”然后用它支撑后续的主动推送、状态查询和踢人操作。具体做法是在WebSocketHandler的afterConnectionEstablished里把WebSocketSession注册到一个连接管理器里。管理器可以是ConcurrentHashMapString, SetWebSocketSessionkey是userIdvalue是该用户当前所有活跃连接因为同一用户可能开了多个标签页。注册的同时把从握手阶段解析出来的userId、token过期时间、tokenVersion都存到session的attributes里。之后任何时候需要用这个连接都可以从连接管理器找到它并从session attributes里取出身份信息而不需要再解析一次token。现在很多文章讲到握手鉴权就结束了好像连接建立起来就完事了。但实际上真正的复杂度全在连接建立之后。4. 连接建立之后过期踢人、消息级鉴权与多端处理连接建立仅仅是开始。长连接状态下token过期怎么处理、每条消息怎么鉴权、怎么支持顶号和踢人这些才是完整方案的真正难点。4.1 token过期之后已建立的连接怎么办这是每个刚接触WebSocket鉴权的人都会问的问题。HTTP接口每次请求都带token过期就401你重新登录再试就好了。但WebSocket是一条已经建立好的长连接你不可能让它每个包都重新校验一次token有效期成本太高也没必要。围绕这个问题有几种常见策略策略一允许连接存活到token过期为止。连接建立那一刻的token是有效的就默认这条连接在过期前都是可信的。过期后由客户端收到特定的关闭码后自动重连用新token重新建立连接。这种方式最简单也足够满足大多数业务场景。策略二服务端主动踢掉“即将过期”的连接。在session attributes里记录token过期时间通过定时任务扫描比如连接还剩一分钟过期时服务端主动给客户端推送一个“准备过期”的提示然后断开连接。客户端收到后无缝重连用户几乎无感知。策略三token过期不踢连接但业务消息继续由业务层做校验。这其实是把“握手鉴权”和“消息鉴权”分离了。连接本身保留但涉及到需要高权限的业务操作时再单独校验用户的最新登录态。我个人推荐的组合是策略一加策略三连接层面允许存活到token自然过期服务端通过自定义关闭码告诉客户端重新连接业务敏感操作在消息层单独校验权限。4.2 自定义关闭码与服务端主动断连当服务端检测到需要切断连接时要优雅地关闭。WebSocket允许自定义关闭码范围是4000到4999。我通常定义几个关闭码含义客户端处理方式4001token过期需要重新获取票据并重连静默重连4002账号在别处登录顶号提示用户不再自动重连4003用户被管理员踢下线提示用户不再自动重连4004服务端主动踢出封禁等跳转登录页服务端断连代码大致是这样session.close(CloseStatus.valueOf(4001, token expired));客户端在onclose事件里读到code然后根据code决定是否自动重连。这个设计非常关键它把“要不要重连”的决策权从客户端无脑判断升级成了服务端语义化指令。你甚至不用额外定义消息协议关闭码本身就是一种信令。4.3 消息级鉴权连接可信和消息可信是两码事这是很多系统翻车的地方。握手鉴权通过只证明了建立连接的那一端是合法用户本人。但连接建立之后客户端发来的每一条消息内容里的用户身份并不天然可信。举个例子客户端发来消息请求{action: getOrder, orderId: 12345, userId: 67890}。如果你在业务处理里直接信任消息体里的userId那攻击者只需要把userId改成别人的ID就能查询别人的订单数据。这是典型的越权漏洞。正确的做法是消息处理时从当前WebSocketSession的attributes里取userId而不是从消息体里取。也就是把“连接身份”作为唯一可信来源消息体里的身份字段一律忽略。public void handleMessage(WebSocketSession session, String payload) { // 从session获取可信用户身份 Long userId (Long) session.getAttributes().get(userId); // 业务逻辑里只使用这个userId绝不使用payload里传的userId handleBusinessLogic(userId, payload); }同时针对不同类型的业务消息还要再做一层权限判断。比如普通用户只能操作自己的数据只有管理员角色才能执行某些操作。这层校验在每个消息处理入口做或者用拦截切面统一做都比在握手阶段一次性校验更稳妥。4.4 顶号、踢人与多端登录再来看一个演进中的问题同一用户在多处登录怎么办以及如何实现“顶号”做法是在用户登录时给会话分配一个tokenVersion可以是自增数字或者UUID客户端每次取ticket时会带上这个版本号。服务端持有“用户当前有效版本号”并把它绑定到连接上。当用户在B端重新登录系统给该用户的sessionVersion加1所有用旧版本号建立的连接就不再被信任。你可以选择主动断开这些旧连接关闭码4002也可以选择保留连接但拒绝后续消息处理。我一般选择主动断开这样客户端逻辑最清晰。这个方案同时还能覆盖“管理员踢人”和“用户被禁用”两种场景只要在Redis里把用户的在线状态字段改掉然后向该用户的所有连接发送指定关闭码就行。4.5 心跳机制与僵尸连接清理另一个绕不开的话题是半开连接。客户端断网或异常掉线时TCP层可能不会立即感知服务端那条WebSocket连接就一直挂着占用资源不释放。尤其是移动端网络切换场景这种僵尸连接特别多。解决办法就是心跳检测。服务端定期比如每30秒发送Ping帧客户端收到后必须回Pong帧。服务端在维护一个“最近活跃时间”如果超过N秒没收到Pong就主动关闭连接并清理连接管理器里的记录。客户端侧也可以在onclose时主动通知服务端清理但服务端一定要有心跳自检兜底不能只依赖客户端。Routine里还有一个小细节如果服务端做的是多实例部署连接管理器得用Redis等共享存储单机的ConcurrentHashMap只能管住当前实例的连接。否则推送消息时消息发给在另一台机器上的连接就会找不到。5. 实测中踩过的坑日志泄漏、跨域Cookie和无效子协议这一章我把真实踩过的坑汇总一下。每一条都是能在联调阶段卡你半天甚至一天的问题提前知道了可以省很多事。5.1 坑一URL里的token进了Nginx access_log第一次用query传token上线后我无意间打开Nginx日志一看完整URL带着token明晃晃躺在里面。虽然系统内部日志有访问控制但日志是要同步给运维团队的、会进入ELK的、可能还会做长期归档。token一旦进了这种地方你根本没法控制它的流向。我的解决办法是双管齐下一是改造Nginx配置对WebSocket网关的location关闭访问日志或者做URL参数脱敏二是把token短期化让它就算泄露也很快失效。如果你不能控制日志策略绝对不要用长期有效的token走URL query。5.2 坑二跨域Cookie的SameSite失效问题用Cookie方案做跨域WebSocket时我在本地调试时发现Cookie就是带不上去。排查半天问题出在Cookie的SameSite属性上。现在主流浏览器默认把跨域请求的Cookie视为第三方Cookie除非你显式设置SameSiteNone; Secure。但设置了Secure又要求页面必须是HTTPS环境在http://localhost上调试时Cookie直接被浏览器拒绝发送。这导致本地环境能跑上了线上HTTPS又能跑但在预发环境切HTTPS之前就疯狂踩坑。这个问题的教训是如果你要用Cookie方案从第一天就把SameSiteNone; Secure和HTTPS一起设计进去别在本地环境用HTTP调试。5.3 坑三子协议带token服务端忘了回显用Sec-WebSocket-Protocol传token时前端把token放进了protocols数组结果服务端校验完token后没有在握手响应里回传同样的协议名。浏览器直接握手失败控制台报错Error during WebSocket handshake: Sent non-empty Sec-WebSocket-Protocol header but no response was received这个报错很容易误导人。第一次遇到时我以为是token校验失败实际上服务端根本没处理协议回选。记住请求里带了非空的Sec-WebSocket-Protocol服务端必须从其中选一个值并在响应里回传否则浏览器一律判定握手失败。5.4 坑四握手失败时前端拿不到详细错误服务端校验失败时可以返回401或403但浏览器WebSocket握手失败时前端触发的是onerror拿不到状态码、拿不到响应体什么都拿不到。你让后端同事在响应里写了“token过期请重新登录”的JSON纯属白费力气。这也是我后来坚持“先换ticket再建连接”的原因。所有详细的错误信息都在预检的HTTP接口里返回前端能读到、能提示、能跳转。WebSocket握手阶段反而只需要接受“建立成功或者沉默失败”这两种结果。5.5 坑五连接管理器泄漏往死连接写数据抛异常连接注册了忘了在afterConnectionClosed里清理或者清理逻辑没兜底就会导致连接管理器里堆积大量僵尸Session。服务端向这些死连接写数据时会抛Broken pipe之类的异常然后被某些框架当成消息发送失败重试重试又失败连锁反应非常难排查。我的经验是统一封装一个ConnectionRegistry注册和移除的逻辑都收敛到一个类里并且在心跳检测任务中也做一次兜底清理。不要在业务代码里到处手写session.sendMessage一旦没人管清理出问题是迟早的事。5.6 坑六只做握手鉴权忘了消息级授权这可能是最危险的一个坑。连接建立时鉴权通过但消息里带了别人的ID也能操作成功。我在一次安全评审时帮同事梳理过这个漏洞他们的前端页面确实没有越权按钮但攻击者只需要在控制台里手动调用ws.send()把payload里某个ID一改就能访问到别人数据。解决方案已经在第四章说过消息处理时只信任session里绑定的userId消息体里的身份字段直接无视。这个规则要写进团队编码规范里。5.7 坑七Cookie方案忘了校验Origin头如果你选了Cookie鉴权那么这个坑不踩都难。攻击者的页面可以构造new WebSocket(wss://your-service/ws)浏览器会自动带上受害者的Cookie。你服务端要是只校验Cookie不校验来源攻击者就成功冒充受害者建立了连接。服务端一定要校验请求的Origin头只允许你控制的域名接入。这一步是Cookie方案的标配不是可选项。6. 一套可落地的组合方案与选型建议最后把各章拆开的方案串成一个完整的、可以直接照抄的落地方案。6.1 推荐组合拳流程完整链路分五步每一步都有明确定位。第一步客户端用普通HTTP接口/api/ws/ticket换取一次性票据。这个接口走常规鉴权返回值里包含ticket和有效期。第二步客户端用票据拼出WebSocket地址wss://api.example.com/ws?ticketxxx。票据有效期设30到60秒一次性使用。第三步服务端握手拦截器校验ticket验签、查有效期、查Redis是否已消费、查用户当前登录态。校验通过后消费掉票据并把userId和tokenVersion写入session attributes。第四步连接管理器注册当前连接记录userId到session的映射。启动心跳检测定期清理僵尸连接。第五步业务消息处理时统一从session attributes取userId做消息级权限校验。需要踢人或顶号时通过连接管理器找到对应连接发送自定义关闭码。这套流程的好处是token不通过URL长期暴露、前端能拿到明确的错误反馈、支持顶号和踢人、消息级授权闭环。代价是实现量略大但每一步都不复杂属于一次性成本。如果项目时间非常紧可以砍掉“临时票据”环节直接在握手时校验长期token但必须接受日志暴露风险并确保日志脱敏处理到位。或者干脆用子协议传token不落日志但代价是和网关的兼容性需要验证。6.2 不同场景的选型速查客户端类型部署拓扑推荐方案浏览器 同域前后端同域名Cookie Origin校验或子协议传token浏览器 跨域前端域名/API域名不同临时票据走URL query或子协议原生App/桌面端任意自定义Header直接带Bearer token后端服务间任意自定义Header带服务凭证最简单6.3 最后说两句基于实践的话WebSocket鉴权方案没有银弹。你最终的选择永远取决于三个约束客户端是什么浏览器还是App、部署拓扑如何同域还是跨域、日志链路是否可控。把这三点想清楚了方案自然就浮出来了。另一个建议是真正的安全不能只靠握手环节。我见过把握手鉴权做得非常复杂但消息层裸奔的案例。务必要记住长连接的可信建立在全生命周期管理上——握手跑通只是入场而已连接管理、消息授权、心跳清理、踢人销号每一样都在安全边界内。如果你们的WebSocket服务已经上线你也不确定现在的鉴权是否有漏洞可以先自查三个地方握手token是否可能出现在URL日志里、业务消息是否信任了payload里的userId、Cookie方案是否校验了Origin。这三个点只要有一个没覆盖出安全事件都算不上意外。