做前端或者全栈的同学大概率都在某个下午被同一个问题卡住过本地调得好好的登录接口联调环境一部署接口返回 401后端日志里干干净净连 Session 都没查一下。打开 DevTools 一看Request Headers 里压根没有 Cookie 这一行控制台里躺着一句黄澄澄的 SameSite 警告。你回头翻代码前端withCredentials: true写了后端Access-Control-Allow-Credentials: true也写了可就是不带。这个坑在 Chrome 91 前后集中爆发过一波。Chrome 80 那会儿官方其实已经宣布要强制 SameSiteLax后来因为特殊情况延期警告照打、强制不执行大家就当没看见。到了 Chrome 91强制执行真的落地了一堆以前能跑的跨域带 Cookie 方案当天集体阵亡。我前后帮三个团队收拾过这套烂摊子从 Express 到 Spring Boot、从 Vue 代理到 Nginx 反代都踩过一遍。下面把整个链路拆开讲清楚Chrome 91 到底改了什么、SameSite 和 Secure 以及 CORS 这三道关卡怎么配合、服务端和前端分别要动哪几行代码、本地和内网环境怎么绕、以及排查时最容易误判的几个假象。1. 先把现象说清楚Chrome 91 之后到底哪里不一样了1.1 一次典型的翻车现场先描述一个我遇到的真实场景你对号入座一下。前端跑在http://localhost:8080后端跑在http://localhost:9000或者http://api.dev.xxx.com登录接口用 axios 发axios.post(http://localhost:9000/api/login, form, { withCredentials: true })登录本身是成功的响应头里也确确实实有Set-Cookie: SESSIONabc123; Path/; HttpOnly。然后紧接着发第二个请求/api/user/info照样带withCredentials: true结果后端说未登录。Network 面板点开第二个请求Request Headers 里没有Cookie。如果你在 Chrome 91 之前测过同样一套代码是通的。差别就在那几个月里浏览器规则变了。更迷惑人的是有时候第一次登录请求会带上 Cookie因为登录请求本身没带凭据只是写入刷新页面后 Cookie 又消失了。这不是后端问题也不是网络问题就是浏览器按新规则拒绝写入或者拒绝回传。1.2 Chrome 91 实际改的两条规则网上很多文章把这件事说得含含糊糊其实核心就两条都是从 Chrome 80 开始预热、在 Chrome 91 真正落地的第一条没有显式声明 SameSite 的 Cookie一律按SameSiteLax处理。以前不写SameSite属性浏览器默认当None处理也就是任何跨站请求都带上。现在不写就等于Lax只有顶级导航的 GET 请求比如点链接跳转才会带 Cookiefetch、XMLHttpRequest、表单 POST、iframe 里的请求统统不带。第二条声明为SameSiteNone的 Cookie必须同时带Secure属性否则直接被拒。这是 Chrome 91 强制执行后最致命的点。因为很多人的第一反应是那我加个SameSiteNone不就完了结果加完还是不行——因为他们的环境是 HTTPSecure加不上浏览器就把整个Set-Cookie丢掉了连 Console 都是警告而不是报错非常不显眼。这两条合起来的效果是在 HTTP 环境下做跨站带 Cookie从 Chrome 91 开始基本被判了死刑除非你把环境挪到 HTTPS或者把跨域改成同源。注意SameSiteNone被拒这件事DevTools 的 Console 只会给一条黄色警告标题类似 Cookie xxx has been rejected because it is in a cross-site context and its SameSite value is None but it lacks the Secure attribute。很多人只看 Console 的红色报错黄色警告一划就过去了白白卡半天。1.3 影响面谁会被打到我列了个清单基本上中招的都在这几类里场景是否受影响原因前后端同域路径不同不受影响同源请求Cookie 是第一方前后端不同端口localhost 之间部分受影响端口不同算跨站但 localhost 有豁免前后端不同域名HTTP严重受影响跨站 无 SecureCookie 直接被拒前后端不同域名HTTPS可修复加SameSiteNone; Secure即可第三方 iframe 嵌入严重受影响典型跨站上下文老版本 Android WebView 调用部分受影响部分内核不认SameSiteNone微信内置浏览器打开 H5视版本X5 内核版本差异较大还有一类容易被忽略同一台机器上localhost:8080请求127.0.0.1:9000。这俩在站点判定上不是同一个 host算跨站但 Chrome 对这两者都有可信来源豁免所以表现会比较微妙——有的能通有的不能通取决于具体版本和 Cookie 的 Domain 写法。这个后面第 5 章会细说。2. 原理层拆解SameSite、Secure、CORS 三道关卡怎么配合想一次性搞定这类问题必须把三个概念的分工搞清楚。它们各自管一件事缺任何一个Cookie 都到不了。2.1 SameSite 的三种取值与站点的判定标准SameSite管的是这个 Cookie 愿不愿意在跨站请求里被发送三个值Strict最严。任何跨站请求都不带包括从别的网站点链接跳过来那次请求也不带。安全但体验差用户从外部链接进来会看到未登录状态。Lax折中也是现在的默认值。只有顶级窗口的导航类 GET 请求会带比如用户点a href跳转、地址栏输入。fetch、XHR、POST表单、iframe 一律不带。None明确表示跨站也要带。但前提是必须配Secure。关键难点在于跨站怎么判定。很多人的直觉是域名不同就算跨站实际上比这个细。浏览器用的是schemeful same-site概念要按顺序比较协议scheme是否相同http和https就算不同。注册域eTLD1是否相同a.example.com和b.example.com算同站但example.com和example.org算跨站。端口不影响站点判定但影响同源origin判定。这里有个很实际的区别同源Same-Origin和同站Same-Site不是一回事。http://localhost:8080和http://localhost:9000是跨源端口不同但按站点算它们同源域属于同站。所以 CORS 会拦你需要后端配 CORS 头但 SameSite 不会拦你Cookie 照带。反过来https://a.example.com和http://b.example.com站点判定上因为 scheme 不同就成了跨站。把这个区分想明白很多为什么这个接口带 Cookie 那个不带的疑惑就解开了。2.2 Secure 与可信来源清单Secure属性的规则很直白这个 Cookie 只能通过 HTTPS 传输。Chrome 91 强制执行的是——声明SameSiteNone的 Cookie 如果没有Secure整条 Cookie 被丢弃不是降级处理是直接扔。那本地开发怎么办总不能每人配一套证书吧。浏览器规范里有个Potentially Trustworthy Origin的概念也就是虽然协议是 HTTP但我认为它是安全的。这个清单大致包括http://localhosthttp://127.0.0.1整个 127.0.0.0/8 段http://[::1]任何以.localhost结尾的域名部分浏览器支持file://开头的本地文件已经通过 HTTPS 访问的页面这意味着在http://localhost上设置带Secure的 Cookie 是允许的Chrome 从 89 开始就支持了。但http://192.168.1.100这种内网 IP不在可信清单里http://dev.company.internal也不在。所以你在内网机器上跑 HTTP 跨站 CookieChrome 91 之后是彻底走不通的这不是配置问题是设计如此。实测心得这套规则里最坑的是 Safari。Safari 12 及以下版本会把SameSiteNone当成Strict处理也就是比 Chrome 更狠。如果你的用户里有老 Mac 或者老 iPad只盯着 Chrome 调是不行的后面 7.1 节会讲兼容写法。2.3 CORS 凭据模式前端一句话后端三个头浏览器发跨域请求时默认是匿名模式——不携带任何凭据也不允许响应设置 Cookie。要让浏览器带上 Cookie链条上每一环都得显式打开前端要声明withCredentials: trueXHR/axios或credentials: includefetch。注意 fetch 的默认值是same-origin跟 axios 的默认值false不一样很容易搞混。后端要回三个头Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Credentials: true Access-Control-Allow-Headers: Content-Type, X-Requested-With这里有个硬性限制Access-Control-Allow-Origin绝对不能是*。一旦响应里带了Access-Control-Allow-Credentials: true通配符就被禁止了浏览器会直接报错The value of the Access-Control-Allow-Origin header in the response must not be the wildcard * when the requests credentials mode is include.这条规则从 CORS 规范诞生就存在不是 Chrome 91 新加的但很多人配 CORS 中间件时习惯性写origin: *然后在 Chrome 91 之后才发现问题。解决办法是把实际请求的 Origin 反射回去并且配一个白名单校验别无条件反射那是安全漏洞。还有一个细节Access-Control-Allow-Credentials只在真实请求的响应里需要预检请求OPTIONS的响应里也要带否则预检会被判失败。3. 服务端改造Set-Cookie 与 CORS 响应头一起改才有用服务端是这套问题的核心战场改动集中但细节多。3.1 最小可用的 Cookie 属性组合HTTPS 环境下的最小组合Set-Cookie: SESSIONabc123; Path/; HttpOnly; Secure; SameSiteNone逐条解释为什么Path/不限制路径全站可用。如果只写/api那前端在别的路径下 JS 也读不到虽然 HttpOnly 本来也读不到但请求携带是会带 Path 匹配的。HttpOnly禁止 JS 通过document.cookie读取防 XSS 窃取。这是安全加分项但也是排查时的陷阱——JS 读不到不代表 Cookie 不存在后面 6.3 节细说。Secure配合 SameSiteNone 必须有。SameSiteNone明确声明跨站可带。如果同一台服务还要兼容老浏览器属性顺序上建议把SameSiteNone放最后某些老解析器遇到不认识的属性会截断后续内容。Cookie 的Domain属性要特别注意你只能设置当前域或其父域。后端在api.example.com想通过Set-Cookie: Domainwww.example.com把 Cookie 种到前端域上浏览器会拒绝。想实现跨子域共享得设成Domain.example.com共同父域而且前端域和后端域都得在这个父域下。这个限制没有绕过办法只能通过统一域名或者反向代理解决。3.2 六种主流后端的写法Node.js Express// Express 4.17.0 之后原生支持 sameSite 选项 app.use(cors({ origin: function (origin, callback) { const allowList [https://app.example.com, https://admin.example.com]; if (!origin || allowList.includes(origin)) { callback(null, origin || true); } else { callback(new Error(Not allowed by CORS)); } }, credentials: true, methods: [GET, POST, PUT, DELETE, OPTIONS] })); res.cookie(SESSION, token, { httpOnly: true, secure: true, sameSite: none, path: /, maxAge: 7 * 24 * 3600 * 1000 });Express 版本低于 4.17 的话sameSite选项会被忽略得手动拼Set-Cookie头或者用express-session 自定义逻辑。Spring BootSpring Boot 2.6 及以上版本可以直接配server.servlet.session.cookie.same-sitenone server.servlet.session.cookie.securetrue server.servlet.session.cookie.http-onlytrue如果是自定义 Cookie不走 Session或者 Spring Boot 版本较低建议用 Filter 统一处理这是最稳的方式Component public class SameSiteCookieFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletResponse response (HttpServletResponse) res; chain.doFilter(req, res); CollectionString headers response.getHeaders(Set-Cookie); if (headers null || headers.isEmpty()) return; ListString rewritten new ArrayList(); for (String header : headers) { if (!header.contains(SameSite)) { header header ; SameSiteNone; Secure; } rewritten.add(header); } response.setHeader(Set-Cookie, rewritten.get(0)); for (int i 1; i rewritten.size(); i) { response.addHeader(Set-Cookie, rewritten.get(i)); } } }这里有个坑response.setHeader会覆盖多条 Set-Cookie 必须用addHeader逐条加。我第一次写的时候用了循环setHeader结果只保留了最后一条Session 和 CSRF Token 的 Cookie 互相覆盖排查了两小时。Koactx.cookies.set(SESSION, token, { httpOnly: true, secure: true, sameSite: none, path: /, maxAge: 7 * 24 * 3600 * 1000 });Nginx 层统一改写推荐给无法改代码的老系统Nginx 1.19.3 之后提供了proxy_cookie_flags指令location /api/ { proxy_pass http://backend_upstream/; proxy_cookie_flags ~ secure samesitenone httponly; }~表示匹配所有 Cookie 名。这个方案的好处是后端代码一行不动特别适合那些编译一次要半小时的老项目。我在一个银行内网项目上用过效果很干净。Nginx 版本低于 1.19.3 的话只能用proxy_cookie_path做路径改写属性改写做不到得升级 Nginx。DjangoSESSION_COOKIE_SAMESITE None SESSION_COOKIE_SECURE True CSRF_COOKIE_SAMESITE None CSRF_COOKIE_SECURE True SESSION_COOKIE_HTTPONLY True注意 CSRF Cookie 必须一起改否则登录能通、提交表单的时候 CSRF 校验又挂了这个坑我见得太多了。PHPsession_set_cookie_params([ lifetime 0, path /, domain , secure true, httponly true, samesite None ]); session_start();PHP 7.3 才支持samesite这个 key7.2 及以下得用header()手动拼。另外如果 PHP 跑在 Nginx PHP-FPM 后面注意secure判断要用$_SERVER[HTTPS]或者HTTP_X_FORWARDED_PROTO否则 HTTPS 环境下程序会以为自己是 HTTP。3.3 CORS 中间件的正确配置这块单拎出来说因为出问题最多的就是它。一个常见错误配置长这样app.use(cors({ origin: *, credentials: true }));这段代码在 Chrome 91 之前看起来能用是因为浏览器在某些条件下容忍了。Chrome 91 之后彻底不行。正确写法有两种。第一种是白名单反射const allowList new Set([https://app.example.com, https://h5.example.com]); app.use((req, res, next) { const origin req.headers.origin; if (origin allowList.has(origin)) { res.setHeader(Access-Control-Allow-Origin, origin); res.setHeader(Access-Control-Allow-Credentials, true); res.setHeader(Access-Control-Allow-Methods, GET,POST,PUT,DELETE,OPTIONS); res.setHeader(Access-Control-Allow-Headers, Content-Type,Authorization,X-Requested-With); res.setHeader(Access-Control-Max-Age, 86400); } if (req.method OPTIONS) { return res.sendStatus(204); } next(); });第二种是加Vary: Origin。这个头很多人不加后果是 CDN 或者中间层缓存了带Access-Control-Allow-Origin的响应A 域名的用户拿到了 B 域名的 CORS 头随机性故障。加上res.setHeader(Vary, Origin)能避免这个。这个坑在生产环境才暴露本地永远测不出来特别阴。实操提醒Access-Control-Max-Age设太大会导致改配置后浏览器还在用旧的预检结果排查时以为没生效。调试阶段建议设成 60 秒以内稳定后再调回 86400。4. 前端改造withCredentials 与代理方案4.1 三种请求方式的写法差异同一件事在三种 API 里的写法完全不同这是最容易漏的地方// axios axios.get(/api/user, { withCredentials: true }) // fetch fetch(/api/user, { credentials: include }) // 原生 XHR const xhr new XMLHttpRequest(); xhr.open(GET, /api/user); xhr.withCredentials true; xhr.send();默认值也不一样这点特别容易混API默认凭据模式跨域带 Cookie 需要axiosfalse不携带withCredentials: truefetchsame-origincredentials: includeXHRfalsewithCredentials truejQuery ajaxfalsexhrFields: { withCredentials: true }jQuery 那行是个老坑crossDomain和xhrFields.withCredentials是两个不同的东西只写前者不带 Cookie。如果项目里封装了统一请求库建议在拦截器里全局设一次const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, withCredentials: true, timeout: 15000 });但要注意一旦开了withCredentials所有请求都会带 Cookie包括那些打到第三方 CDN 或者地图服务的请求可能触发它们的 CORS 校验失败。所以更稳的做法是按需开启而不是全局开。4.2 预检请求不带 Cookie别被误导这是排查时最容易误判的点。跨域带凭据的请求会先发一个OPTIONS预检而这个预检请求本身不携带 Cookie也不携带Access-Control-Allow-Credentials之外的凭据信息。这是规范规定的浏览器故意的。所以你打开 Network 面板看到第一个请求是OPTIONS而且没有 Cookie 头这是正常的别在这个上面浪费时间。真正要看的是紧随其后的真实请求同样的 URLmethod 是 POST/GET。另外预检请求的响应状态码如果是 401 或者 403浏览器会认为预检失败直接不发真实请求。很多后端框架默认拦截未认证的 OPTIONS 请求这就是本地能通、上了权限框架就挂的原因。解决办法是在权限过滤器里放行 OPTIONS// Spring Security 配置示例 http.authorizeRequests() .antMatchers(HttpMethod.OPTIONS, /**).permitAll() .anyRequest().authenticated();4.3 开发环境代理把跨域变成同源聊到这里必须说一个降维打击的方案与其费劲让跨站请求带 Cookie不如让请求根本不跨站。前端框架自带的 devServer 代理就是干这个的。Vue CLI 的配置// vue.config.js module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:9000, changeOrigin: true, pathRewrite: { ^/api: }, cookieDomainRewrite: localhost } } } };配完之后前端请求/api/login浏览器认为是同源请求都是http://localhost:8080Cookie 正常写入正常回传SameSite 那套规则完全用不上。cookieDomainRewrite: localhost是关键它会把后端返回的Set-Cookie里的 Domain 属性改写成localhost否则如果后端写死了Domainapi.example.comCookie 种不进来。Vite 的写法略有不同// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:9000, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } });生产环境同理用 Nginx 做同域反向代理server { listen 443 ssl http2; server_name app.example.com; location / { root /var/www/app; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend_upstream/; 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; } }这样前端页面和后端接口都在app.example.com下Cookie 是第一方SameSite随便写什么都不影响。我一直推荐这个方案因为它不只是解决 Cookie 问题顺带还解决了下面要说的真实 IP 问题。4.4 代理之后怎么拿到真实请求地址用了代理之后会冒出一个新问题后端拿到的request.getRemoteAddr()全是 Nginx 的 IP日志里所有请求都长一个样排查问题基本瞎。同时如果后端用 URL 生成逻辑比如拼接回调地址、生成签名拿到的是内网地址生成的链接外部根本访问不了。解决办法是让 Nginx 把原始信息透传后端再解析。Nginx 侧加这三个头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_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port;Spring Boot 侧开启转发头解析server.forward-headers-strategyframework这一个配置会把X-Forwarded-*系列头自动应用到request.getRemoteAddr()、request.getRequestURL()等方法的返回值上不用改业务代码。如果用的是 Tomcat 且没走 Spring Boot 的自动配置需要在server.xml的 Connector 上加remoteIpValveValve classNameorg.apache.catalina.valves.RemoteIpValve remoteIpHeaderX-Forwarded-For protocolHeaderX-Forwarded-Proto protocolHeaderHttpsValuehttps /Node.js 侧如果是 Express加一行就够app.set(trust proxy, true);设完之后req.ip就是真实客户端 IP。但这里有个安全注意点trust proxy不要无条件开启。如果服务直接暴露在公网而没有前置代理任何人都能伪造X-Forwarded-For头把它当成真实 IP 用会导致限流失效、日志造假。正确做法是配成具体的代理 IP 段app.set(trust proxy, [127.0.0.1, 10.0.0.0/8]);至于前端想知道我的请求最终打到了哪个真实地址可以在代理配置里加日志或者在响应头里回一个调试标记。Vue CLI 的 proxy 支持onProxyReq回调proxy: { /api: { target: http://localhost:9000, changeOrigin: true, onProxyReq(proxyReq, req, res) { console.log([proxy], req.method, req.originalUrl, -, proxyReq.path); } } }这个日志在排查路径重写有没有生效的时候特别有用不然你只能靠猜。5. 本地开发与内网环境的特殊处理5.1 localhost 的豁免与 127.0.0.1 的陷阱前面提过localhost和127.0.0.1都在浏览器的可信来源清单里所以 HTTP 环境下也能设置SecureCookie。但这两个字符串在站点判定上是不同的 hosthttp://localhost:8080请求http://127.0.0.1:9000属于跨站需要SameSiteNone; Secure。更麻烦的是 Chrome 在某个版本区间里对这两者的处理存在差异。我实测过的组合前端地址后端地址Cookie 能否正常携带localhost:8080localhost:9000正常属同站localhost:8080127.0.0.1:9000需要SameSiteNone; Secure127.0.0.1:8080localhost:9000需要SameSiteNone; Secure127.0.0.1:8080127.0.0.1:9000正常属同站结论很简单本地开发时前后端统一用localhost或统一用127.0.0.1别混着来。这一条能省掉一半莫名其妙的调试时间。如果后端是别人启动的改不了那就用 4.3 节的 devServer 代理绕过去。5.2 内网 IP 加 HTTP 的死局怎么破内网环境是最难受的http://192.168.1.100:8080访问http://192.168.1.200:9000私有 IP 段不在可信来源清单里所以设不了SecureCookie加不了SameSiteNone缺 Secure 会被拒不加SameSite就默认Lax跨站请求不带死局。三个出路按推荐程度排序出路一Nginx 同域反代。在 192.168.1.100 上装个 Nginx把/api反代到 192.168.1.200:9000前端也部署在这台机器上。这样所有请求都是同源问题彻底消失还不用改任何业务代码。这是最省事的方案我每次都优先推这个。出路二给内网域名签证书。用内部 CA 签一张*.dev.internal的证书所有机器的 hosts 文件指向内网 IP。麻烦在于每台开发机都要装根证书新人入职配置一次要半小时但配好之后一劳永逸。mkcert 这个工具能让本地证书生成变得简单得多mkcert -install mkcert dev.internal *.dev.internal生成的证书直接给 Nginx 用。出路三前端本地起代理后端用 IP。开发机上跑 devServer 代理因为localhost是可信来源所以只要后端把SameSiteNone; Secure带上代理这条链路是通的。但这个方案只对开发者本人有效测试和产品拿手机测还是会挂所以只能作为临时方案。5.3 自签证书落地时的几个细节如果你选了 HTTPS 路线有几个操作会让后续少踩坑证书的SANSubject Alternative Name必须写清楚只写 CN 在 Chrome 上早就被忽略了。用 mkcert 或者 openssl 生成时都要显式指定openssl req -x509 -newkey rsa:2048 -nodes \ -keyout server.key -out server.crt -days 825 \ -subj /CNdev.internal \ -addext subjectAltNameDNS:dev.internal,DNS:*.dev.internal,IP:192.168.1.100注意-days别超过 825 天Chrome 对有效期超过 825 天的证书会直接判不可信这是硬性限制。另一个细节是证书和 Cookie 的关系。如果你用自签证书给192.168.1.100签了证书那必须通过https://192.168.1.100访问才能生效。如果你 hosts 里写的是dev.internal指向这个 IP那就必须用https://dev.internal访问混着用还是会报证书不匹配。提醒Chrome 对 HSTS 有缓存一旦某个域名被标记了 HSTS之后即使你换成 HTTP 也会被强制跳转 HTTPS。清理入口在chrome://net-internals/#hsts排查证书问题时记得顺手清一下。6. 排查实录从 DevTools 到常见报错对照表6.1 五个必看的观测点排查这类问题我有一套固定流程按顺序看这五处基本能定位到 90% 的问题第一处Network 面板的 Request Headers。看有没有Cookie这一行。没有的话是浏览器没发问题在前端或者 Cookie 的 SameSite 属性有 Cookie 但后端说没有那是后端的问题。第二处Network 面板的 Response Headers。看Set-Cookie那几行浏览器有没有给它们标黄。标黄说明被拒绝了鼠标悬停能看到拒绝原因。这是最直接的证据。第三处Application → Storage → Cookies。看目标 Cookie 在不在重点看两列SameSite和Secure。如果SameSite显示Lax但你代码里写的是None说明代码没生效可能是框架版本问题。如果Secure那列是空的但你写了Secure说明整条被拒了。第四处Console 面板。过滤Cookie关键词看有没有黄色警告。Chrome 91 关于 SameSite 的警告都是黄色的不是红色很多人漏看。第五处后端的请求日志。如果用了 Nginx先看 Nginx 的 access log 确认请求有没有到再看后端的日志。有几次我遇到的是请求被 WAF 拦了压根没到应用这时候盯着代码看是白费功夫。6.2 常见报错对照表控制台报错信息根因解决方向has been rejected because it is in a cross-site context and its SameSite value is None but it lacks the Secure attributeCookie 缺 Secure 属性上 HTTPS或用同域代理This Set-Cookie was blocked because it had the SameSiteNone attribute but did not have the Secure attribute同上强调被拒同上The value of the Access-Control-Allow-Origin header must not be the wildcard *后端配了origin: *加 credentials改成白名单反射Response to preflight request doesnt pass access control check预检请求被拦401/403/404放行 OPTIONS 请求Request header field xxxx is not allowed by Access-Control-Allow-Headers自定义头没在后端声明补Access-Control-Allow-Headershas been blocked because it has the Secure attribute but was not received over a secure connectionHTTP 环境下设了 Secure上 HTTPSIndicate whether to send a cookie in a cross-site request by specifying its SameSite attribute未声明 SameSite警告非报错显式声明The request client is not a secure context and the resource is in more-private address space私有网络访问限制用同域或 HTTPS6.3 三个容易误判的假象假象一用document.cookie判断 Cookie 是否存在。如果 Cookie 带了HttpOnlyJS 根本读不到document.cookie里没有不代表 Cookie 不存在。正确做法是在 Application 面板里看或者看请求头。我被这个坑过当时以为后端没种上 Cookie查了两小时后端才发现是HttpOnly的问题。假象二Network 面板的 Cookie 标签显示暂存状态。Chrome 有个Preserve log选项如果不勾选页面跳转后之前的请求记录会清空。SPA 里登录成功后路由跳转Network 面板被清了看起来像没有 Cookie其实是记录没了。勾上 Preserve log 再看。假象三Postman 能通浏览器不通。Postman 不受 CORS 和 SameSite 约束它就是个 HTTP 客户端浏览器规则它一条都不管。所以Postman 能通完全不能说明浏览器应该通这俩是两套规则。反过来也一样Postman 里手动设的 Cookie 和浏览器自动管理的 Cookie 不是一回事。7. 兼容性与特殊场景7.1 老浏览器的兼容策略SameSiteNone这个值不是所有浏览器都认识主要问题在几个老内核上Safari 12 及以下把SameSiteNone当Strict处理比 Chrome 还严老版本 Android WebViewChromium 内核 51 以下完全不认识SameSite属性整条忽略部分国产浏览器兼容内核行为不一致前两个的处理策略是User-Agent 嗅探 差异化下发。对 Safari 12 及以下不发SameSite属性对老 WebView 也一样。示意代码String ua request.getHeader(User-Agent); boolean legacy ua ! null ( ua.contains(Version/12) || ua.contains(Version/11) ); String cookie SESSION token ; Path/; HttpOnly; if (!legacy) { cookie ; SameSiteNone; Secure; } response.addHeader(Set-Cookie, cookie);这个逻辑看着丑但确实是业界通用做法。Chrome 自己的官方迁移文档里也推荐类似思路。至于 Android WebView如果是自己的 App 在用最好的办法是升级 WebView 版本或者升级 App 内嵌的 Chromium 内核。这个改动周期长短期内还是得靠 UA 嗅探兜着。7.2 iframe 与第三方嵌入场景如果你的页面是被别人用 iframe 嵌进去的那就是最纯粹的第三方上下文Cookie 规则最严必须SameSiteNone; Secure必须 HTTPS没有 localhost 豁免iframe 里的页面如果本身是 HTTP跨站 Cookie 直接不可用部分场景还需要处理 Storage Access API 和权限声明Chrome 从 88 开始对第三方 Cookie 逐步收紧如果你的业务是开放给第三方嵌入的建议尽早做架构层面的调整。常见的替代方案是token 方案替代 Cookie 方案不用 Cookie 传递会话状态改用前端存储 token、请求头带 Authorization。这条路完全绕开了 Cookie 的所有规则限制。代价是安全性上要重新设计token 存在 localStorage 里容易被 XSS 读取所以要么用短有效期 刷新机制要么把 token 存在内存里配合刷新 token 用 HttpOnly Cookie 保存。这是个架构取舍没有银弹。7.3 灰度与回滚怎么做最后说一个实操层面的建议。改 Cookie 属性这个事一旦上线是全量生效的出问题是全站登录挂掉影响面比想象中大。我习惯的做法是第一步先只加SameSite属性不加Secure。这个阶段可以观察到有多少跨站请求但不会真的改变行为因为缺 SecureChrome 91 会拒绝整条 Cookie这里要小心实测是拒绝而不是忽略。更正一下这个做法在 Chrome 91 上不可行因为缺 Secure 就直接被拒了。实际上应该反过来第一步先确保 HTTPS 全链路通。这一步独立于 Cookie 配置先确认所有用户访问的都是 HTTPS 页面没有混合内容告警。第二步在预发环境全量验证。用真实浏览器、真实域名、真实网络环境跑一遍完整的登录、操作、退出流程特别是那些用了 iframe 的页面。第三步生产环境按比例灰度。通过配置中心控制 Cookie 属性先给 5% 的流量加SameSiteNone观察登录成功率、接口 401 率、错误日志。确认无异常后逐步放大。第四步保留回滚开关。这个很重要配置中心里留一个开关出问题能一键切回旧行为。回滚时要注意已经种下的SameSiteNoneCookie 在用户浏览器里还会存在一段时间得同时清一下或者换个新的 Cookie 名。至于浏览器层面的--disable-featuresSameSiteByDefaultCookies启动参数我提一下是因为排查阶段确实有用能快速验证是不是 SameSite 导致的。但它只是个诊断手段要求用户改启动参数是不现实的Chrome 后续版本也已经把这个开关移除了别往这个方向做方案。我在几个项目上折腾下来最深的体会是遇到跨域 Cookie 问题第一反应不该是去调SameSite而是先问一句这个请求为什么非得跨站。绝大多数场景下用 Nginx 反代或者 devServer 代理把请求变成同源问题就从根本上消失了不用跟浏览器规则较劲也不用担心下一版 Chrome 又改了什么。真正必须跨站的场景其实不多主要是第三方嵌入和开放式 API这类场景老老实实按规范把SameSiteNone、Secure、CORS 白名单三样配齐然后做好老浏览器兼容就足够了。反倒是那些先把 SameSite 加上试试的临时方案后面往往要还更多的技术债。