WebSocket 鉴权入门(实战版):握手票据 / 首帧鉴权 + 连接级 cid
这是一篇把我们前面讨论过的要点合并整理后的入门文档。重点强调两件事:
鉴权该怎么做(握手票据 vs 首帧鉴权);
每条连接都有唯一
cid,用于区分与观测连接(而“谁在用这条连接”由鉴权绑定的userId决定)。
1 WebSocket 与 HTTP 的根本区别
HTTP API:短连接、无状态。每次请求来就处理,返回后连接即结束,服务端不用“记住这条连接”。
WebSocket:长连接、双向通信。握手成功后,服务端会一直持有该连接(直到关闭),并在“连接上下文”里保存会话信息(如
cid、userId、订阅集等)。
连接建立 ≠ 身份确认:
服务端总能区分“每一条连接”(有独立的 socket 对象),这是连接层;
但不知道“这条连接是谁(哪个用户)”,直到鉴权完成为止,这是身份层。
2 连接级 cid:区分连接的“身份证”
WebSocket 协议本身没有连接 ID。因此建议在服务端为每条新连接生成一个唯一 cid(如 UUID),并写入连接上下文,用途:
在未鉴权阶段也能可靠地区分和观测连接(日志、限流、告警)。
鉴权通过后,将
userId绑定到同一条连接,不改变cid。断开时用
cid做清理;重连时生成新的cid。生产中建议所有日志/指标都打上
cid;需要时再加上userId串联业务。
区分靠 cid,识别靠 userId。千万别把客户端自报的 cid 当作权威;应以**服务端生成的 cid**为准。
3 两种常见鉴权方案
方案一:握手阶段携带一次性短票据(推荐)
做法
前端通过 HTTPS 调
/auth/ws-ticket获取一次性、30–60 秒有效的ticket。建立连接:
wss://example.com/ws?ticket=<ticket>。服务端在 HTTP Upgrade 握手时校验
ticket(签名/时效/未使用),通过即作废,并把userId绑定到连接上下文。
优点
握手即鉴权:连接一建立就知道“这条线是谁”,可立即推送用户数据。
失败直接拒绝升级(不会占用 WS 资源)。
浏览器 WS 不支持自定义请求头,查询参数是通用手段。
注意
不要把长期 JWT 放 URL,改用一次性短票据;全程
wss://;校验Origin白名单。多实例部署时,把
userId -> socketIds路由写入 Redis/NATS 便于跨实例转发。
方案二:连接后首帧发送鉴权消息(需要状态机)
做法
连接建立后,服务端立刻生成
cid,将连接置为 pending(未鉴权):仅允许
auth消息;设 5–10 秒鉴权超时;
对 pending 做连接数/速率限制(防滥用)。
客户端首帧发送
{type:'auth', token:<JWT>};服务端校验:通过 →
ws.ctx = { cid, userId, ... },迁移到 authenticated;失败/超时 →
close(4401)。
优点
- token 不暴露在 URL;适合必须在应用层协议统一鉴权/续期的场景。
注意
必须实现未鉴权状态机与超时控制;鉴权前不得给该连接推送用户数据。
同样需要
wss://与Origin校验。
4 握手只发生一次:ticket 过期不影响已连接
HTTP Upgrade 仅在“新建连接”时发生一次。
带着
ticket的 URL 只在握手时使用;握手成功后进入 WS 帧流,不会再回到 Upgrade。因此
ticket过期不影响已建立的连接,只影响新建/重连。
5 全链路时序(含 cid)

6 代码骨架(TypeScript + Node.js ws)
服务端:cid + pending/authed 管理(适配方案二;方案一可省略 pending)
import http from "http";
import { WebSocketServer } from "ws";
import { randomUUID } from "crypto";
const server = http.createServer();
const wss = new WebSocketServer({ server });
type Ctx = { cid: string; userId?: string; subs: Set<string> };
const cidMap = new Map<string, WebSocket>(); // cid -> ws
const userMap = new Map<string, Set<WebSocket>>(); // userId -> Set<ws>
wss.on("connection", (ws, req) => {
// 1) 连接建立就分配 cid(连接层标识)
(ws as any).ctx = { cid: randomUUID(), subs: new Set<string>() } as Ctx;
const { cid } = (ws as any).ctx;
cidMap.set(cid, ws);
// 可选:回给前端,用于日志串联
ws.send(JSON.stringify({ type: "hello", cid }));
// 2) ——若你用方案二:设置鉴权超时,仅允许 'auth' —— //
const authTimeout = setTimeout(() => {
if (!(ws as any).ctx.userId) ws.close(4401, "auth timeout");
}, 10_000);
ws.on("message", (raw) => {
let msg: any;
try {
msg = JSON.parse(String(raw));
} catch {
return;
}
// 未鉴权:只接受首帧 auth
if (!(ws as any).ctx.userId) {
if (msg.type !== "auth") return ws.close(4401, "auth required");
const payload = verifyToken(msg.token); // TODO: 自己实现
if (!payload) return ws.close(4401, "invalid token");
// 3) 绑定身份(身份层)
(ws as any).ctx.userId = payload.sub;
clearTimeout(authTimeout);
if (!userMap.has(payload.sub)) userMap.set(payload.sub, new Set());
userMap.get(payload.sub)!.add(ws);
ws.send(JSON.stringify({ type: "auth_ok" }));
return;
}
// 4) 已鉴权:业务处理(身份来自 ws.ctx,不信任消息体携带的 userId/cid)
handleBusiness((ws as any).ctx, msg, ws);
});
ws.on("close", () => {
clearTimeout(authTimeout);
cidMap.delete(cid);
const userId = (ws as any).ctx.userId;
if (userId) {
const set = userMap.get(userId);
set?.delete(ws);
if (set && set.size === 0) userMap.delete(userId);
}
});
});
server.listen(8080);
// 广播/定向推送
function sendToUser(userId: string, message: any) {
const set = userMap.get(userId);
if (!set) return;
const data = JSON.stringify(message);
for (const ws of set) if (ws.readyState === ws.OPEN) ws.send(data);
}若使用方案一(握手即鉴权),在
server.on('upgrade')时先校验并作废ticket,成功才handleUpgrade(),随后直接设置ws.ctx.userId = payload.userId即可,基本不需要 pending 状态机。
客户端:断线重连 + 快照/增量
let ws: WebSocket | null = null;
let retry = 0;
let lastEventId: string | null = null;
const subs = new Set<string>(["orders"]);
async function getTicket() {
const r = await fetch("/auth/ws-ticket", { method: "POST" });
return r.json() as Promise<{ ticket: string; expireAt: number }>;
}
async function connect() {
const { ticket } = await getTicket();
ws = new WebSocket(
`wss://example.com/ws?ticket=${encodeURIComponent(ticket)}`
);
ws.onopen = () => {
retry = 0;
for (const topic of subs) {
ws!.send(
JSON.stringify({
type: "subscribe",
topic,
snapshot: true,
since: lastEventId,
})
);
}
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.eventId) lastEventId = msg.eventId; // 记录递增ID便于断线补偿
// TODO: 业务处理
};
ws.onclose = () => {
const delay = Math.min(30000, 1000 * 2 ** retry++) + Math.random() * 300;
setTimeout(connect, delay);
};
}
connect();7 常见问答(FAQ)
Q1:cid 是协议自带的吗?
不是。原生 WS 没有连接 ID。cid 应由服务端在 connection 事件里自行生成并绑定到连接上下文。
Q2:为何不要在每条消息里带 userId?
身份来自“这条连接是谁”(ws.ctx.userId),每条消息带 userId 容易被伪造,还会重复冗余。除非你做每消息签名(复杂度高),否则不要把 userId 当作消息体凭据。
Q3:ticket 过期会影响已建立的连接吗?
不会。Upgrade 只发生在建立连接时。ticket 过期只影响新建/重连。
Q4:access token 很短,需要在连接内续期吗?
看需求。最简单是断线重连即可;若希望无缝续期,可以设计 reauth { token:newJWT } 消息更新 ws.ctx,或服务端发 reauth_required 提示前端重连。
8 最佳实践清单
[ ] 全程
wss://,并校验Origin白名单[ ] 服务端生成唯一
cid,贯穿整个连接生命周期(日志/指标都打上cid)[ ] 握手即鉴权(推荐):URL 查询参数携带一次性短票据,校验通过即作废
[ ] 首帧鉴权(可选):未鉴权只允许
auth,5–10s 超时,对 pending 做限流/限并发[ ] 鉴权通过后将
{ userId, roles, subs }绑定到连接上下文(后续消息不再带凭据)[ ] 快照 + 增量:
subscribe(snapshot:true, since:lastEventId?)[ ] 心跳(ping/pong)与背压阈值(send 队列长度限制)
[ ] 集群路由:Redis/NATS 维护
userId -> {instance, socketIds/cids},跨实例转发[ ] 关闭码:
4401(auth failed/timeout)、4403(reauth required)、1012(service restart)[ ] 日志串联:必要时把
cid回给前端用于问题定位,但不要把客户端自报cid当权威
9 TL;DR
每条连接都有唯一
cid(服务端生成):用于区分连接、日志追踪与限流排障。鉴权把“谁在用这条连接”绑定到连接上下文(
userId);后续消息无需再带身份。默认为握手票据方案;特殊场景用首帧鉴权但要写好 pending 状态机与超时。
断线重连 = 新
cid;重放快照或按lastEventId补增量恢复状态。