
写在前面如果你是一个游戏客户端或者服务端开发者大概率听过这样的对话:“登录接口报错了,控制台一堆红字,说什么 CORS 不允许。”“我们这个区服要跟另一个区服打排位,跨域了,账号数据怎么同步?”“H5 版本嵌在渠道的 iframe 里,拿不到登录态,也是跨域问题。”有意思的是——上面这三句话里的跨域,指的是三件完全不同的事。游戏行业里跨域这个词被严重超载了。它至少有三层含义:浏览器安全层面的跨域(Cross-Origin):Web 同源策略导致的 CORS/Cookie/iframe 问题。游戏服务器架构层面的跨域(Cross-Server / Cross-Zone):把多个独立区服的玩家撮合到一起玩。账号体系层面的跨域(Cross-Domain SSO):多个业务域名、多个游戏产品之间共享登录态。这三者在登录环节会交织在一起。本文从最浅的浏览器同源策略讲起,逐步深入到大厂的账号中台设计,最后用一个射击游戏的实际案例把它们串起来。第一层:浏览器的同源策略——最狭义的跨域1.1 什么是源浏览器眼中的源(Origin)由三元组决定:协议(scheme) 主机(host) 端口(port)举几个例子,假设当前页面是https://game.example.com:443/login:目标 URL是否同源原因https://game.example.com/api/auth✅三者全同http://game.example.com/api/auth❌协议不同https://api.example.com/auth❌主机不同(子域也算)https://game.example.com:8443/auth❌端口不同注意第三行——这是新人最容易踩的坑。game.example.com和api.example.com虽然是同一个公司同一个主域,但在浏览器看来是两个完全不同的源。1.2 为什么要有同源策略假设没有同源策略,会发生什么:你登录了网页游戏game.example.com,浏览器保存了登录 Cookie。然后你打开了一个恶意网站evil.com,这个网站的 JS 偷偷发起:fetch(https://game.example.com/api/user/inventory).then(rr.json()).then(datasendToHacker(data));// 你的背包、点券余额全没了因为浏览器会自动带上game.example.com的 Cookie,服务端会认为这是合法请求。同源策略的核心作用就是:禁止一个源的脚本读取另一个源的响应内容。这里有个关键细节很多人搞错:同源策略拦截的是读取响应,不是发送请求。上面那个fetch请求其实已经发出去了,服务端也已经执行了,只是浏览器不让 JS 读到返回结果。这就是为什么 CSRF 攻击依然能成立——写操作不需要读响应。1.3 CORS:跨域的官方解法CORS(Cross-Origin Resource Sharing)的本质是:由服务端决定是否授权某个外部源读取响应。简单请求满足以下条件的请求是简单请求,浏览器直接发出:方法为GET/HEAD/POSTContent-Type仅限text/plain、multipart/form-data、application/x-www-form-urlencoded没有自定义请求头服务端返回:Access-Control-Allow-Origin: https://game.example.com浏览器看到匹配,才把响应交给 JS。预检请求(Preflight)一旦你用了Content-Type: application/json(游戏登录接口几乎必用),或者带了自定义头如X-Device-Id,就触发预检。浏览器先发一个OPTIONS:OPTIONS /api/v1/login HTTP/1.1 Origin: https://game.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type,x-device-id,x-client-version服务端必须回应:HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://game.example.com Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Client-Version Access-Control-Allow-Credentials: true Access-Control-Max-Age: 86400实战踩坑点:Access-Control-Allow-Origin不能是*同时又Allow-Credentials: true。这是规范强制的。想带 Cookie,就必须回显具体的 Origin。Access-Control-Max-Age一定要配。不配的话每个 POST 前面都跟一个 OPTIONS,登录链路 RTT 直接翻倍。移动弱网下用户能明显感觉到卡。国内主流做法是配 86400(Chrome 上限 7200 秒)。回显 Origin 必须校验白名单。见过线上代码直接Access-Control-Allow-Origin: ${req.headers.origin}无脑回显的,等于同源策略形同虚设。JS 默认只能读 6 个响应头。想让客户端读到X-Trace-Id之类的自定义头做排障,必须显式声明Access-Control-Expose-Headers: X-Trace-Id。1.4 Cookie 的跨域:SameSite 带来的大地震2020 年 Chrome 80 把 Cookie 的SameSite默认值从None改成了Lax,这件事让无数游戏渠道接入方一夜之间登录挂掉。三个取值:值行为Strict只有同站请求才带 Cookie,从外站点链接跳进来都不带Lax同站带;跨站只有顶级导航 安全方法(GET)才带None全都带,但必须同时加Secure(HTTPS)为什么这对游戏是致命的?典型的渠道接入场景:游戏 H5 被嵌在渠道 App 的 WebView 里,或者嵌在渠道页面的iframe中:!-- 页面在 https://channel.com --iframesrchttps://game.example.com/h5/index.html/iframeiframe 内部发起的请求,虽然目标是game.example.com自己,但因为顶级页面是channel.com,所以属于跨站(cross-site)请求。SameSiteLax下,Cookie 不会被发送。结果就是:玩家在 iframe 里怎么点都是未登录。解法有几种,各有代价:方案 A:SameSiteNone; Secure最直接,但要求全链路 HTTPS,且部分老旧 WebView(尤其是 iOS 12 以下的 Safari)有臭名昭著的 bug——它们把SameSiteNone当成Strict解析。需要做 UA 嗅探,对旧版本干脆不下发 SameSite 属性。方案 B:彻底不用 Cookie,改用 Token Header这是现在大厂的主流选择。登录成功后服务端返回 Token,客户端存在localStorage或内存里,每次请求手动放到Authorization头。// 登录const{accessToken,refreshToken}awaitapi.post(/v1/login,credentials);// 存储(注意 iframe 内 localStorage 也是按 origin 隔离的,这点反而是好事)tokenStore.save(accessToken,refreshToken);// 后续请求fetch(/v1/game/profile,{headers:{Authorization:Bearer${accessToken}}});优点:完全绕开 Cookie 和 SameSite,跨域只需处理 CORS,行为可预测。缺点:localStorage无法防 XSS(Cookie 至少有HttpOnly),所以必须配套严格的 CSP 和输入过滤。方案 C:Partitioned Cookie(CHIPS)较新的方案,Set-Cookie: ...; SameSiteNone; Secure; Partitioned。Cookie 按顶级站点 Cookie 域双键分区存储。适合 iframe 嵌入场景,但兼容性尚在铺开。1.5 WebSocket 的跨域:一个常见误解很多人以为 WebSocket 有 CORS。没有。WebSocket 握手是一个特殊的 HTTP Upgrade 请求,浏览器会带上Origin头,但不执行 CORS 检查——服务端返回 101 就直接连上了。这意味着:WebSocket 的来源校验完全是服务端的责任。// 错误写法:Gorilla WebSocket 的经典坑varupgraderwebsocket.Upgrader{CheckOrigin:func(r*http.Request)bool{returntrue// 灾难:任何网站都能连你的战斗服},}// 正确写法varallowedOriginsmap[string]bool{https://game.example.com:true,https://cdn.example.com:true,}varupgraderwebsocket.Upgrader{CheckOrigin:func(r*http.Request)bool{origin:r.Header.Get(Origin)iforigin{// 原生客户端(Unity/UE)不带 Origin,走另一套鉴权returnisNativeClient(r)}returnallowedOrigins[origin]},}对射击游戏来说这尤其重要:如果战斗服的 WS 端点不校验 Origin,恶意网站可以在玩家登录状态下建立连接、发送操作包(所谓 “Cross-Site WebSocket Hijacking”)。正确做法是:WS 连接建立时必须携带一次性 Ticket,而不是依赖 Cookie 隐式鉴权。第二层:账号体系的跨域——单点登录(SSO)浏览器层面的跨域解决了能不能通信,接下来的问题是登录态怎么在多个域之间共享。2.1 大厂的账号中台形态观察主流厂商的公开架构(以下基于其公开文档与 SDK 行为的通用抽象):腾讯:MSDK 统一封装 QQ/微信登录,底层通过 OAuth 拿到openid access_token,再换取游戏侧的game_token。关键设计是openid 按应用 平台隔离——同一个微信用户在不同游戏里的 openid 是不同的,需要unionid才能跨产品关联。网易:URS(用户注册系统)作为统一账号层,游戏侧通过 SDK 拿到 SSO Token,服务端向 URS 做验签。米哈游:HoYoverse 通账号,一套账号跨原神/星铁等多产品,官网、社区、游戏客户端共享登录态。抽象出来,通用范式都是:[ 账号域 account.xxx.com ] ← 唯一持有长期登录凭证 ↓ 颁发短期 Ticket [ 业务域 game-a.xxx.com ] [ 业务域 game-b.xxx.com ] [ 社区域 bbs.xxx.com ] ↓ 各自用 Ticket 换取自己域的 Session2.2 跨域 SSO 的经典流程以基于 OIDC(OpenID Connect)的实现为例:1. 玩家访问 game.example.com,无本域 session 2. 302 跳转 → account.example.com/authorize ?client_idgame-client redirect_urihttps://game.example.com/callback response_typecode scopeopenid profile state随机防CSRF code_challengePKCE 3. 账号域检查自己的 Cookie(第一方,无 SameSite 问题) - 已登录 → 直接生成 authorization code - 未登录 → 展示登录页,验证后生成 code 4. 302 回跳 → game.example.com/callback?codexxxstateyyy 5. 游戏域后端用 code client_secret code_verifier 向账号域的 /token 端点换取 id_token / access_token (这是服务端到服务端调用,不经浏览器,无跨域问题) 6. 游戏域在自己的域下 Set-Cookie,建立本域 session为什么要绕这么一大圈?核心思想是:把跨域的信任传递,转换为账号域的第一方 Cookie 一次性凭证的 URL 传递。这样全程不需要跨站 Cookie,不受 SameSite 影响。几个必守的安全细节:state必须校验,否则有 CSRF 登录注入风险(攻击者把自己的 code 塞给受害者,让受害者登进攻击者账号)。公开客户端(游戏客户端、H5)必须用PKCE,因为无法安全保存client_secret。code必须一次性、短时效(建议 ≤60s)、绑定redirect_uri。redirect_uri必须精确匹配白名单,不能做前缀匹配。前缀匹配会被https://game.example.com.evil.com或开放重定向绕过。2.3 原生游戏客户端的差异Unity/UE 客户端没有浏览器的同源策略,看起来没有跨域问题。但实际上问题变了形:维度浏览器原生客户端同源策略强制不存在Cookie自动管理需手动/或直接不用凭证存储localStorage/CookieKeychain / Keystore主要风险XSS、CSRF逆向、内存读取、中间人原生客户端的登录态一般是纯 Token 方案,但要注意:证书绑定(Certificate Pinning):防止玩家用 Charles/Fiddler 抓包改登录请求。射击游戏尤其需要。Token 与设备指纹绑定:防止 Token 被盗用后在其他设备登录。内嵌 WebView 登录时的跨域:如果用 WebView 做第三方 OAuth 登录,回调是通过自定义 Scheme(mygame://callback?codexxx)或者拦截 URL 加载来完成的,这里又回到了 WebView 与原生的通信隔离问题。第三层:游戏架构的跨域——跨区服撮合这是游戏行业最原生的跨域含义,和浏览器毫无关系。3.1 为什么需要跨域传统 MMO 采用分区分服:玩家注册时选华东一区,数据落在该区的独立数据库,玩家只能和同区的人交互。这个设计的好处是简单、可水平扩展、故障隔离。但它带来两个问题:老服人少:开服半年后,单区活跃玩家从 5 万掉到 3000,排队匹配等不到人。玩法受限:跨服战、跨服排位、大规模赛事无法进行。于是有了跨域服:一组物理上独立的区服,通过中间层聚合成一个逻辑上的域。3.2 射击游戏的架构特殊性FPS/TPS 类射击游戏的架构和 MMO 有本质区别,这直接影响跨域的实现方式:MMO射击游戏世界模型持久化大世界,玩家常驻大厅 短局对战(3-20分钟)分服原因单服承载上限主要是延迟/地理,而非承载数据归属角色数据绑定区服账号数据全局唯一跨域诉求跨服活动天然全局匹配关键洞察:射击游戏的分服粒度通常是地理区域(Region),而不是一区二区。因为 FPS 对延迟极度敏感,北美玩家和亚洲玩家撮合在一起,150ms 的 RTT 会毁掉射击手感。所以射击游戏的架构通常是:[ 全球账号服务 Global Account ] [ 全球玩家档案 Player Profile ] ↓ ┌────────────────┼────────────────┐ [ 亚太区域 ] [ 欧洲区域 ] [ 北美区域 ] 匹配服务 匹配服务 匹配服务 战斗服集群 战斗服集群 战斗服集群账号是全局的(玩家在哪都能登录),但匹配和战斗是区域化的。这里的跨域问题变成:如何让一个全局账号,在不同区域的服务集群间无缝切换登录态。案例分析:某射击游戏的跨区域登录链路下面是一个贴近实际的技术案例,基于典型的射击游戏架构。为避免涉及具体商业信息,做了抽象与化名处理。4.1 背景《Project Frontline》,一款 5v5 战术射击游戏,支持 PC 客户端(UE5) 官网(Web) 移动端伴侣 App。部署三个区域:ap-southeast(新加坡)、eu-central(法兰克福)、us-west(俄勒冈)。需求:玩家账号全球唯一,任意区域都能登录。玩家可以手动切换区域打排位(接受高延迟惩罚),数据不丢。官网(www.frontline.gg)、赛事页(esports.frontline.gg)、社区(community.frontline.gg)共享登录态。反外挂系统需要在登录时下发设备指纹校验。4.2 登录链路设计阶段一:全局认证客户端启动,向全局认证服务(部署在多区域,通过 GeoDNS 就近接入,但共享同一账号数据源)发起登录:POST https://auth.frontline.gg/v1/token Content-Type: application/json X-Device-Fingerprint: 硬件指纹哈希 X-Client-Build: 1.24.3-win64 { grant_type: password, username: ..., password_hash: ..., // 客户端预哈希 服务端再加盐 client_id: frontline-pc, code_challenge: ... // PKCE }返回:{access_token:eyJhbGc...,// JWT, TTL 15minrefresh_token:rt_9f8a...,// opaque, TTL 30d, 可撤销token_type:Bearer,expires_in:900,player_id:PID_7A3F2E91,home_region:ap-southeast,available_regions:[ap-southeast,eu-central,us-west]}设计要点:access_token 用 JWT:因为它需要被各区域的服务本地验签。如果用 opaque token,每个区域的匹配服都要回调全局认证服务查询,跨洋 RTT 200ms,登录体验直接崩。JWT 的自包含特性在跨区域场景下是刚需。JWT 签名用非对称算法(RS256/ES256):私钥只在认证服务,各区域只需持有公钥。公钥通过 JWKS 端点分发,支持密钥轮换(kid字段标识)。refresh_token 用 opaque:必须可即时撤销(封号、异地登录踢下线),所以必须落库查询。它只跟认证服务打交道,不涉及跨区域。JWT 里放什么:{iss:https://auth.frontline.gg,sub:PID_7A3F2E91,aud:[match,profile,store],exp:1735689600,iat:1735688700,jti:tok_c8d91f,scope:play:ranked play:casual store:read,dev:df_a91b2c,// 设备指纹摘要ban:0,// 封禁标记(快速拦截)reg:ap-southeast// 归属区域}注意不要把频繁变化的数据(段位、货币余额)放进 JWT——它无法在有效期内更新,会导致数据不一致。阶段二:区域会话建立拿到全局 token 后,客户端连接目标区域的大厅网关:POST https://ap-southeast.frontline.gg/v1/session Authorization: Bearer eyJhbGc...区域网关做三件事:本地验签 JWT(用缓存的 JWKS 公钥,零网络开销)检查aud/scope/exp,以及ban标记拉取/创建区域档案:如果该玩家首次进入此区域,从全局档案服务同步一份基础数据然后颁发区域会话票据:{session_id:sess_ap_5f3c9e,ws_endpoint:wss://lobby-ap-07.frontline.gg/ws,ws_ticket:wt_e8a1c9...,// 一次性,TTL 30sregion:ap-southeast}阶段三:WebSocket 长连接客户端连接大厅 WS:GET wss://lobby-ap-07.frontline.gg/ws?ticketwt_e8a1c9这里是关键的安全设计:WS 握手不依赖 Cookie,也不在 URL 里放长效 token,而是用一次性 Ticket。原因:不依赖 Cookie → 彻底规避 SameSite / 跨站 WebSocket 劫持不放长效 token → URL 会被 Nginx access log、CDN log、浏览器历史记录记下来,长效 token 泄露风险高Ticket 一次性 30s TTL 绑定 IP 段 → 即使泄露也基本无法利用服务端校验流程:funchandleWSUpgrade(w http.ResponseWriter,r*http.Request){// 1. Origin 校验(Web 客户端)iforigin:r.Header.Get(Origin);origin!{if!allowedOrigins[origin]{http.Error(w,forbidden origin,403)return}}// 2. Ticket 校验(Redis GETDEL 保证原子消费)ticket:r.URL.Query().Get(ticket)sess,err:redis.GetDel(ctx,wt:ticket).Result()iferr!nil{http.Error(w,invalid ticket,401)return}// 3. IP 段一致性校验(防 ticket 被中途窃取)if!sameSubnet(sess.IssuedIP,clientIP(r)){metrics.Inc(ws.ticket.ip_mismatch)http.Error(w,ticket ip mismatch,401)return}conn,_:upgrader.Upgrade(w,r,nil)goservePlayer(conn,sess)}阶段四:战斗服交接匹配成功后,匹配服分配一台战斗服(Dedicated Game Server),并给双方各发一个战斗入场票:{match_id:m_ap_8c2f91,gs_endpoint:udp://gs-ap-1142.frontline.gg:7777,join_token:jt_...,// HMAC 签名,含 match_id player_id team exp(60s)expected_map:de_harbor}战斗服用预共享密钥本地校验join_token,不做任何网络调用。这是必须的:战斗服的启动窗口只有几秒,任何跨服务调用都是风险点。4.3 遇到的实际问题与解法问题 1:Web 端登录后进不了游戏内嵌页游戏客户端内嵌 WebView 展示商城页(store.frontline.gg),但玩家在客户端已登录,商城页却要求重新登录。根因:客户端持有的是 Bearer Token,存在内存里;WebView 是独立的 Cookie 容器,没有任何登录态。解法:实现一个Token→Cookie 桥接。客户端打开 WebView 前,用自己的 access_token 向认证服务申请一个web_login_code(一次性,10s TTL),然后让 WebView 加载:https://store.frontline.gg/sso?codeweb_login_codereturn_to/shop商城后端消费 code,验证后在.frontline.gg域下Set-Cookie(HttpOnly; Secure; SameSiteLax),再 302 到return_to。这样store/esports/community三个子域共享 Cookie,一次桥接搞定全部子域。注意return_to必须白名单校验为站内相对路径,否则就是开放重定向漏洞。问题 2:官网 CORS 预检导致登录慢 400ms官网www.frontline.gg调用api.frontline.gg,跨子域。登录流程有 4 个串行接口,每个都触发 OPTIONS 预检,亚太玩家 RTT 约 50ms,白白多耗 200ms;欧洲玩家绕远更严重。解法组合:配Access-Control-Max-Age: 7200,让预检结果在浏览器缓存 2 小时。把 4 个串行接口合并为 1 个/v1/bootstrap聚合接口。更彻底的做法:在 CDN 层把www.frontline.gg/api/*反代到后端,变成同源请求,CORS 直接消失。这是大厂常见操作——能用同源解决的,就别用 CORS。问题 3:跨区域切换后段位数据错乱玩家从ap-southeast切到eu-central打排位,回来发现段位对不上。根因:区域档案是各自缓存的,两边同时写,产生冲突。解法:明确数据归属策略:数据类型归属一致性账号、皮肤、货币全局,单一写入点强一致排位段位按区域独立区域内强一致战绩、生涯统计全局,区域异步上报最终一致好友、组队全局最终一致排位段位按区域独立,是行业内的通行做法(玩家在不同区域有不同段位)。这不是技术妥协,而是产品设计上的合理选择——避免了跨区域强一致的巨大成本,同时符合玩家预期。对于必须全局强一致的资源(货币、道具),采用单一写入区域 幂等事务:所有扣费请求都路由到玩家home_region的账务服务,用idempotency_key保证重试安全。问题 4:JWT 无法即时撤销导致封号延迟反外挂系统检测到作弊,执行封号,但玩家的 access_token 还有 12 分钟有效期,期间仍能正常游戏。解法(分层):JWT TTL 压到 5 分钟(权衡:refresh 请求量增加,但认证服务是无状态可水平扩展的)。引入撤销位图:各区域网关订阅一个 Redis Pub/Sub 频道,封号事件实时推送player_id到本地布隆过滤器 短 TTL 黑名单缓存。验签通过后额外查一次本地缓存,成本 0.1ms。长连接主动断开:大厅服和战斗服维护player_id → connection映射,收到封号事件直接 kick。这条路径最快,通常 1s 生效。这是一个典型的**JWT 的无状态优势与即时撤销需求的矛盾**,解法本质是:用本地缓存的撤销名单,把全局状态查询降级为本地内存查询。总结:三层跨域的关系图┌─────────────────────────────────────────────────┐ │ 浏览器跨域(能不能通信) │ │ 同源策略 → CORS / SameSite / WS Origin 校验 │ └─────────────────────────────────────────────────┘ ↓ 解决了通道 ┌─────────────────────────────────────────────────┐ │ 账号跨域(登录态怎么共享) │ │ OIDC/OAuth2 一次性 code PKCE │ │ 账号域第一方 Cookie → 业务域本域 Session │ └─────────────────────────────────────────────────┘ ↓ 解决了身份 ┌─────────────────────────────────────────────────┐ │ 架构跨域(多区域怎么协同) │ │ 全局账号 区域会话 JWT 本地验签 │ │ 数据归属分层 一次性入场票交接 │ └─────────────────────────────────────────────────┘给开发者的几条实践建议优先用同源。CDN 反代、路径路由,能消除的跨域就别留着。原生客户端别用 Cookie,纯 Token 方案更可控。跨区域一定用非对称签名的 JWT,避免跨洋验票。长连接鉴权用一次性 Ticket,别用长效凭证,别用 Cookie。JWT 里只放稳定数据,变化的数据走查询。redirect_uri、return_to一律精确白名单,前缀匹配是漏洞之源。WebSocket 必须自己校验 Origin,浏览器不帮你。数据归属要在设计阶段定死:哪些全局强一致,哪些区域独立,哪些最终一致。事后改代价极高。跨域从来不是一个有 bug 要修的问题,而是一个在安全边界、性能、一致性之间做权衡的架构问题。理解了这三层,再看到跨域这个词,你就知道对方在说哪一件事了。