WebSocket 握手 403 排查:nginx Upgrade 正常,为什么还要 Nuxt 服务端补 Authorization
结论先说:WebSocket 返回 403 时,不要继续只盯着 nginx 的 Upgrade 头。
这次请求已经穿过 ALB、nginx、Kubernetes Ingress 并命中后端路由,真正缺的是业务鉴权要求的 Authorization。浏览器原生 WebSocket API 又不能像 fetch 一样随意添加请求头,所以最终方案不是继续堆 nginx 配置,而是在中间增加一层 Nuxt 服务端 WebSocket 代理:从浏览器 Cookie 中取出 token,转成 Authorization: Bearer ... 后再连接 Kubernetes 后端。
以下域名、地址、Cookie 名和服务名均已替换为占位值,落地时换成自己的配置即可。两张 DevTools 截图是根据已核实的现场状态重建的脱敏复现图,不是未处理的原始生产截图。
环境
| 组件 | 版本 / 形态 |
|---|---|
| 浏览器 | Chrome 150,原生 WebSocket API |
| 前端服务 | Nuxt 4.4.8、Node.js 22.14.0 |
| WebSocket 客户端 | 浏览器原生 API;Nuxt 服务端使用 ws 8.21.0 |
| 接入层 | ALB -> ECS nginx -> Nuxt / Kubernetes Ingress |
| 后端 | Kubernetes Service + WebSocket 服务 |
| 故障路径 | wss://agent.example.com/api/saas/agent/ws |
整体链路如下。左边是故障路径,右边是修复路径:

一、问题表现:页面能开,普通 API 正常,只有 WebSocket 403
页面、静态资源和普通 REST 请求都能正常返回,但对话功能建立 WebSocket 时失败。
Chrome Console 看到的核心报错是:
WebSocket connection to
'wss://agent.example.com/api/saas/agent/ws' failed:
Error during WebSocket handshake: Unexpected response code: 403
脱敏复现截图如下:

Network 面板里能看到:
- Request Method 是
GET; Connection: Upgrade存在;Upgrade: websocket存在;- 服务端明确返回
403 Forbidden; - 请求里没有后端要求的
Authorization。

这组证据很重要:Upgrade 头已经有了,服务器也给出了业务状态码,因此不能再把问题简单归因于“nginx 没开 WebSocket”。
二、先用状态码判断请求走到哪一层
WebSocket 排障最容易浪费时间的地方,是看到“握手失败”就反复修改代理头。更有效的做法是先解释状态码代表的链路位置。
| 状态 | 常见含义 | 下一步 |
|---|---|---|
400 / 426 |
HTTP/1.1、Upgrade 或 Connection 不满足升级要求 | 检查 ALB、nginx 和上游协议头 |
404 |
请求到达某个 HTTP 服务,但 Host、Path 或后端路由不匹配 | 对照 Ingress host/path 和应用 router |
401 |
路由存在,鉴权组件要求登录,但请求没有有效凭证 | 检查 Bearer token / session Cookie |
403 |
请求到达鉴权或业务层,但当前凭证不被接受 | 对比浏览器与服务端代理的请求头 |
502 |
nginx 找不到可用上游,或者连接上游失败 | 检查 upstream、Service endpoint 和 Pod |
101 |
WebSocket 握手成功 | 继续验证消息收发和连接持续时间 |
不同框架对 401/403 的使用不完全一致,不能只凭状态码下最终结论;但它足以帮助我们停止在错误的层级继续试配置。
用 curl 只测握手
下面的命令不会完成业务会话,只用来检查握手状态。请在能访问目标域名的终端执行:
curl -i -N --http1.1 \
-H 'Connection: Upgrade' \
-H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
-H 'Sec-WebSocket-Version: 13' \
'https://agent.example.com/api/saas/agent/ws'
如果返回 401/403,至少能确认 TLS、ALB、nginx 和后端路由不是完全断开的。若返回 404,先不要谈 token,应该先核对 Host 和 Path。
三、逐层核对:请求其实已经进入 Kubernetes
1. nginx access log 要记录 upstream
仅记录最终状态不够,建议把实际命中的上游也写入 access log:
log_format upstream_json escape=json '{'
'"time":"$time_iso8601",'
'"request":"$request",'
'"status":$status,'
'"request_time":$request_time,'
'"upstream_host":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_time":"$upstream_response_time"'
'}';
故障请求的脱敏日志类似:
{
"request": "GET /api/saas/agent/ws HTTP/1.1",
"status": 403,
"upstream_host": "192.0.2.20:80",
"upstream_status": "403",
"upstream_time": "0.005"
}
这里已经能看到请求由 nginx 转给了 Ingress 地址,并且 403 是上游在 5ms 内返回的,不是 nginx 自己拼出来的页面。
2. Ingress 必须同时核对 Host 和 Path
在有权限访问集群的机器上执行:
kubectl -n app get ingress agent-api -o yaml
kubectl -n app get service agent-api -o wide
kubectl -n app get endpoints agent-api -o wide
kubectl -n app get pods -l app=agent-api -o wide
需要核对四件事:
- Ingress 中存在
agent.example.com; /api/saas指向预期 Service;- Service 有 endpoint;
- Pod Ready 且没有重启。
如果这些都成立,而且后端日志同步出现同一条握手请求,就可以确定“没有转进 Kubernetes”这个假设不成立。
四、真正的根因:浏览器 WebSocket 无法自定义 Authorization
前端代码原本非常直接:
const ws = new WebSocket(
'wss://agent.example.com/api/saas/agent/ws',
)
浏览器的构造函数只提供 url 和可选的 protocols:
new WebSocket(url)
new WebSocket(url, protocols)
它没有类似下面这样的标准参数:
// 浏览器原生 WebSocket 不支持这种写法
new WebSocket(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
这和 Node.js 中的 ws 库不同。Node.js 运行在服务端,创建连接时可以指定请求头;浏览器受 Web 平台安全模型约束,不能随意给握手添加任意 HTTP header。
MDN 的 WebSocket 构造函数说明也只有 URL 和 subprotocols:
https://developer.mozilla.org/docs/Web/API/WebSocket/WebSocket
当后端鉴权中间件只接受 Authorization: Bearer ... 时,浏览器直连就会卡住:
- nginx 能转发 Upgrade;
- Ingress 能匹配 Host 和 Path;
- Service 和 Pod 都正常;
- 但浏览器无法按后端契约补 Authorization;
- 最终后端返回 403。
调试页为什么曾经可以直连 Kubernetes
最初联调时,前端还没有从 SaaS Agent 后端中拆出。后端直接提供了一个静态 WebSocket 握手调试页:
https://agent.example.com/api/saas/agent_ws_debug.html
这里的 agent_ws_debug.html 不是正式 Nuxt 前端,而是后端随服务提供的联调页面。它默认把当前页面的协议和 Host 组装成同域 WebSocket 地址 /api/saas/agent/ws;页面中还有 Cookie 和 x-user-agent 输入框,点击 Connect 时会把它们转成 debug_cookie、x_user_agent 查询参数,然后调用浏览器原生 WebSocket 发起握手:
function buildDebugUrl() {
const url = new URL('wss://agent.example.com/api/saas/agent/ws')
url.searchParams.set('x_user_agent', '<debug-user-agent>')
url.searchParams.set('debug_cookie', '<temporary-session-cookie>')
return url.toString()
}
const ws = new WebSocket(buildDebugUrl())
后端有一条专门的调试鉴权通道,会解析 debug_cookie。因此这个 HTML 页面可以绕过“浏览器无法自定义 Authorization 请求头”的限制,直接经 nginx 和 Ingress 连接 Kubernetes 后端。它本身既是握手发起器,也是观察 open、close、error 和服务端推送事件的联调界面。
但这只是联调方案,不是正式鉴权模型。Cookie 或 token 放在查询参数中,可能进入浏览器 Console、nginx/Ingress access log、链路追踪和历史记录。调试页内置的 Session 被注销、更换或者不再被当前鉴权服务接受时,握手同样会返回 403;这不代表 WebSocket 没有进入 Kubernetes。
后来前后端分离,独立前端不再依赖后端调试页,也不应继续传 debug_cookie。两个阶段的区别如下:
| 阶段 | 浏览器怎样建立 WS | 后端怎样取得凭证 | 定位 |
|---|---|---|---|
| 最初联调 | 调试页直连 Kubernetes WS | 从 debug_cookie 查询参数解析 Cookie |
仅限隔离调试 |
| 前后端分离后 | 浏览器连接同源 Nuxt WS | Nuxt 从 Cookie 取 token,再添加 Authorization 连接 Kubernetes |
正式方案 |
这也解释了一个看似矛盾的现象:调试页曾经能直连 Kubernetes,并不能证明正式浏览器客户端也能直连。前者使用了后端专门提供的调试契约,后者必须满足正式的 Authorization 鉴权契约。
五、修复:让 Nuxt 服务端完成鉴权桥接
修复思路是把连接拆成两段:
浏览器 -> 同源 Nuxt WebSocket 路由
Nuxt -> 带 Authorization 的 Kubernetes WebSocket 路由
浏览器只需要正常携带同源 Cookie;Nuxt 服务端读取 Cookie 中的登录态,取出 access token,再使用 Node.js ws 库建立后端连接。

1. 从 Cookie 中提取服务端凭证
下面是简化后的通用写法。实际项目建议优先使用 HttpOnly Session Cookie,避免把长期 token 暴露给前端 JavaScript。
interface AuthSnapshot {
session_token?: {
access_token?: string
token_type?: string
}
}
function getCookieValue(cookie: string, name: string) {
return cookie
.split(';')
.map((item) => item.trim())
.find((item) => item.startsWith(`${name}=`))
?.split('=')
.slice(1)
.join('=') ?? ''
}
function getAuthorizationFromCookie(cookie: string) {
const raw = getCookieValue(cookie, 'frontend_auth')
if (!raw) return ''
try {
const snapshot = JSON.parse(decodeURIComponent(raw)) as AuthSnapshot
const token = snapshot.session_token?.access_token
const type = snapshot.session_token?.token_type || 'Bearer'
return token ? `${type} ${token}` : ''
} catch {
return ''
}
}
不要打印完整 Cookie 和 token。需要排障时只记录“Cookie 是否存在、是否成功解析、token 长度或哈希前几位”,避免日志成为凭证泄漏源。
2. Nuxt 服务端连接后端 WebSocket
安装服务端 WebSocket 客户端:
pnpm add ws
创建后端连接时补 Authorization:
import WebSocket from 'ws'
export function createBackendSocket(options: {
browserCookie: string
backendUrl: string
}) {
const authorization = getAuthorizationFromCookie(options.browserCookie)
return new WebSocket(options.backendUrl, {
headers: {
...(authorization ? { authorization } : {}),
cookie: options.browserCookie,
},
})
}
这次最关键的代码变化其实只有三步:
const authorization = getAuthorizationFromCookie(browserCookie)
const backend = new WebSocket(backendUrl, {
headers: {
...(authorization ? { authorization } : {}),
},
})
3. Nuxt WebSocket 路由负责双向转发
Nuxt/Nitro 开启 WebSocket 后,在路由中取得浏览器 Cookie:
export default defineWebSocketHandler({
open(peer) {
const cookie = peer.request.headers.get('cookie') || ''
const backend = createBackendSocket({
browserCookie: cookie,
backendUrl: 'ws://api-test.example.com/api/saas/agent/ws',
})
backend.on('message', (data) => {
peer.send(data.toString())
})
backend.on('close', (code, reason) => {
peer.close(code, reason.toString())
})
peer.context.backend = backend
},
message(peer, message) {
const backend = peer.context.backend as WebSocket | undefined
if (backend?.readyState === WebSocket.OPEN) {
backend.send(message.text())
}
},
close(peer) {
const backend = peer.context.backend as WebSocket | undefined
backend?.close(1000, 'client closed')
},
})
生产实现还应处理后端连接建立前的消息队列、错误码映射、连接超时和心跳,这里只保留与鉴权桥接直接相关的部分。
六、nginx:WS 精确走 Nuxt,普通 SaaS API 可按契约分流
1. http 层只定义一次 Upgrade map
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}
upstream web_agent {
server 127.0.0.1:8080;
}
2. WebSocket 精确路径走 Nuxt
location = /api/saas/agent/ws {
proxy_pass http://web_agent;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
这里用精确匹配,是为了保证只有这个 WS 路径进入 Nuxt 鉴权桥接。查询参数不会影响 nginx 的精确 URI 匹配。
3. 普通 API 是否直连 Ingress,要先确认鉴权契约
如果 /api/saas/* 的普通 REST 接口已经能从浏览器请求中取得有效 Cookie 或 Authorization,可以单独直连 Ingress:
location = /api/saas {
proxy_pass http://192.0.2.20;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /api/saas/ {
proxy_pass http://192.0.2.20;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
两个普通 API 块的 proxy_pass 确实一样,都指向同一个 Ingress;它们的区别不在上游,而在 location 匹配范围:
| 请求 URI | 命中规则 | 上游 |
|---|---|---|
/api/saas |
location = /api/saas |
Ingress |
/api/saas?from=web |
location = /api/saas |
Ingress |
/api/saas/jobs |
location ^~ /api/saas/ |
Ingress |
/api/saas/agent/ws |
location = /api/saas/agent/ws |
Nuxt WS 代理 |
/api/saasx |
不命中上述 SaaS 规则 | 由其他 location 处理 |
location = /api/saas 只补住不带尾随斜线的根 URI;location ^~ /api/saas/ 覆盖带子路径的请求。nginx 匹配 location 时不看查询参数,所以 /api/saas?from=web 仍会命中精确规则。
因此,这里保留两个块的原因只有一个:同时覆盖 /api/saas 根 URI 和 /api/saas/ 下的子路径。 它们的上游和代理参数相同是正常的,不代表两条规则可以直接删掉一条。
如果接口契约明确不存在 /api/saas 这个裸路径,并且 access log 也确认没有这类请求,那么 location = /api/saas 可以删除,只保留 /api/saas/ 子路径规则。不要仅因为两个块看起来一样就删除;是否删除应由真实请求和路由契约决定。
不建议为了少写一个块而改成 location ^~ /api/saas:它还会匹配 /api/saasx 这类本不属于 SaaS API 的 URI。保留“根 URI 精确匹配 + 子路径前缀匹配”,边界更明确。如果不希望重复代理参数,可以把公共的 proxy_set_header 抽到 nginx include 片段,但不要改变这两个 URI 边界。
精确 WS location 会先于 /api/saas/ 前缀规则命中,因此 WS 请求会进入 Nuxt 鉴权桥接,不会被普通 API 规则抢走。
但要注意:如果 REST 请求同样依赖 Nuxt 从 Cookie 转成 Authorization,就不能直接切到 Ingress。 路由分流必须以鉴权契约为边界,不能只按“前端/后端”四个字判断。
七、上线顺序:备份、校验、reload、对账 upstream
1. 先做目录级备份
在 nginx 所在服务器执行:
cp -a /etc/nginx /etc/nginx.bak.$(date +%Y%m%d_%H%M%S)
如果实际配置不在 /etc/nginx,先从 nginx master 进程参数确认配置目录,不要靠猜。
2. 校验通过后才 reload
nginx -t
nginx -s reload
ps -o pid,lstart,etime,cmd -C nginx
nginx -t 只证明配置语法正确,不证明上游可用,更不证明鉴权成功。
3. 用三组证据闭环
修复后至少核对:
- Chrome Network 显示
101 Switching Protocols; - nginx access log 显示 WS 实际命中
127.0.0.1:8080; - 后端连接持续存在并有消息字节传输,不是握手后立即关闭。
脱敏后的成功日志类似:
{
"request": "GET /api/saas/agent/ws HTTP/1.1",
"status": 101,
"request_time": 444.809,
"upstream_host": "127.0.0.1:8080",
"upstream_status": "101"
}
这次修复后连续出现多条 101,连接持续几十秒到数分钟且有数据传输,才算真正闭环。
八、这类问题最容易走的四条弯路
1. 看到 WebSocket 失败就继续加 Upgrade 头
如果 Network 已经显示 Upgrade 存在,且上游返回 401/403,继续重复添加相同请求头没有意义。
2. 把 403 当成“没有进入 Kubernetes”
是否进入 K8s 要看 nginx upstream、Ingress access log、Service endpoint 和 Pod 日志,不能靠感觉判断。
3. 让浏览器 WebSocket 直接带 Authorization
浏览器原生 API 没有任意 headers 参数。可选方案只有调整后端鉴权协议,或者增加同源服务端代理。把 token 放 query string 或 subprotocol 也不是无代价替代:它们可能出现在日志、链路追踪或代理记录中,需要单独做安全评估。
4. 一次把所有 API 都切到 Kubernetes
WebSocket 修好了,不代表 REST 也能绕过 Nuxt。先画清楚每类请求的鉴权来源,再决定 location 分流。
快速参考
状态码速查
400/426 -> 先查 HTTP/1.1、Upgrade、Connection
404 -> 先查 Host、Path、Ingress 和应用 router
401/403 -> 先查鉴权头、Cookie 与 token 转换
502 -> 先查 upstream、Service endpoint、Pod
101 -> 握手成功,再查消息收发与连接持续时间
修复核心
浏览器 WebSocket
-> 同源 Nuxt WS 路由
-> 从 Cookie 提取 token
-> Node.js ws 添加 Authorization
-> Kubernetes Ingress
-> 101 Switching Protocols
方案演进速查
早期联调: 后端调试页 -> debug_cookie 查询参数 -> Kubernetes
正式拆分: 浏览器 -> Nuxt -> Authorization 请求头 -> Kubernetes
nginx 路由速查
= /api/saas -> 只匹配根 URI
^~ /api/saas/ -> 匹配子路径
= /api/saas/agent/ws -> 精确匹配优先,单独走 Nuxt WS 代理
三条铁律
- 每一层都用日志或状态亲眼核对,不能用“应该已经到了”代替证据。
nginx -t成功只代表语法正确,最终必须用101 + upstream + 消息传输验证。- nginx 路由边界应服从鉴权边界;需要服务端补凭证的请求不能直接绕过代理。

浙公网安备 33010602011771号