Flutter Web token 存储陷阱:crypto.subtle 在非安全上下文失效排查实录

Flutter Web token 存储陷阱:crypto.subtle 在非安全上下文失效排查实录

作者:FungLeo | 适用:Flutter / Web

现象:登录(public 接口)正常,但登录后所有带 token 的接口都不发请求;只在 HTTP + 非 localhost 的"非安全上下文"下才暴露。

前言

说实话,这个坑我是在一个跨端项目里踩的。我们那个项目,Android 原生和 Web 端同一套代码,token 这事儿我本想"一套通吃"。

原生端我用 flutter_secure_storage(Keystore / Keychain),一直稳得一批。做跨端最爽的就是一套 Dart 代码两边跑,但"存储"偏偏是平台差异最大的坑之一------我那时候图省事,寻思 Web 端也用同一个库得了,反正都是 read / write 一个 key,能有多大事儿。结果一跑 Web,登录能进,登录之后所有带 token 的接口全灭------页面转圈圈,控制台还给你刷 Selected call is null 这种迷惑噪声,真正的错被静默吞了。

我当时就纳闷了:登录明明是 public 接口好好的,怎么一带上 token 就全挂?各位看官,这就是典型的"只在非安全上下文才暴露"的坑,我折腾了大半天才定位到。今天把它写出来,希望您别再走一遍我的弯路。

现象:登录能,其它全挂

Web 端登录(public 接口)正常,但登录后所有带 token 的接口都不发请求。控制台有 Selected call is null 之类噪声,但真正的错误被静默吞了------一眼看过去,根本不知道是哪一步炸的。

为什么偏偏是"登录能、其它全挂"?关键在上下文类型 。浏览器对 window.crypto.subtle 的可用性,是根据当前页面的"安全上下文"来定的:

访问方式 是否安全上下文 crypto.subtle 可用 token 读取表现
https:// 任意域名 ✅ 是 可用 正常
http://localhost / http://127.0.0.1 ✅ 是(localhost 豁免) 可用 正常
http://192.168.x.x:端口(局域网 IP) ❌ 否 undefined 抛异常、读取失败

看最后一行就明白了:开发时用局域网 IP 的 HTTP 跑 Web,恰好踩在非安全上下文上。

根因:crypto.subtle 在非 HTTPS 下是 undefined

flutter_secure_storage_web 1.2.x 的 readAES-GCM 解密 ,强依赖 window.crypto.subtle

  • window.crypto.subtle 只在安全上下文 (HTTPS 或 localhost)可用。
  • 如果 dev server 用 HTTP、且不是 localhost (比如局域网 IP http://192.168.x.x:端口),就是非安全上下文 → crypto.subtleundefinedread 抛异常。
  • 而登录是 public 接口、拦截器里提前 return 不碰 token 存储,所以只有带 token 的请求才崩 → 表现成"登录能、其它全挂"。

这意味着:Web 端的 token 根本没成功持久化过,每次刷新页面都要重新登录。说白了,这库在 Web 端是把命押在安全上下文上的------它默认你跑在 HTTPS 或 localhost,押输了就原地抛异常,连个友好提示都懒得给。

这就是为什么"登录能、其它全挂"------根子不在网络层,也不在你的请求代码,而在 token 读取那一下就炸了,而且只在 HTTP + 局域网 IP 这种非安全上下文下才炸。这个坑最恶心的地方,是它披着"网络问题"的外衣骗你去翻请求层,实际上根子压根不在那。

原生能用,Web 为什么不行

同样是 flutter_secure_storage,原生端和 Web 端的底层实现天差地别,这直接决定了它能不能在非安全上下文里活下来:

平台 推荐存储 底层实现 非安全上下文是否可用
原生(Android / iOS) flutter_secure_storage Keystore / Keychain(系统级加密) ✅ 无关,始终可用
Web shared_preferences localStorage(明文) ✅ 无关,但别用 flutter_secure_storage(它的 Web 实现依赖 crypto.subtle

一句话:原生端 secure storage 走系统加密库,跟浏览器上下文无关;Web 端它却退化成 crypto.subtle 那套 Web Crypto API,于是被安全上下文卡了脖子。

pub.dev 上 flutter_secure_storage 的文档其实在 Web 一节明确写了:Web 实现把数据 AES 加密后存进 localStorage,且只能在安全上下文使用。只是我当时没看文档,直接把它当跨端通用来使了。所以各位看官,引一个库之前,先扫一眼它的 Web 兼容说明,能少踩一半坑。

我是怎么一步步排查的

既然踩了坑,我也跟各位看官唠唠当时的弯路,免得你们再绕一遍。

  1. 先看控制台 。满屏 Selected call is null,但 Dart 层没红屏、没明确异常(Web 下异常也容易被吞,这块后面 Flutter Release 吞异常 vs Debug 红屏:看不见的 release-only bug 专门讲过),一眼望过去根本不知道哪炸的。
  2. 怀疑拦截器逻辑写错。把鉴权 header 那一段注释掉,请求照样发不出去------才意识到不是"加 header"的问题,是"读 token"那一步就挂了。
  3. 怀疑 flutter_secure_storage 不支持 Web 。去翻源码,发现 1.2.x 的 web 实现走 AES-GCM 解密,而解密强依赖 window.crypto.subtle
  4. 最后查上下文 。把 dev server 从局域网 IP 的 HTTP 换成 localhost 再跑,问题消失。回头一看------哦,原来是 crypto.subtle 在非安全上下文里是 undefined。其实当时只要在 console 里敲一句 crypto.subtle 就能看到它返回 undefined,直接实锤,我愣是绕了前面三步才想起来。所以各位看官,遇到诡异的 Web 问题,先看一眼 isSecureContextcrypto.subtle,能省不少冤枉路。

你看,绕了一大圈,元凶就是"这个环境下压根没有 crypto.subtle"这件小事。

修复:Web 走 shared_preferences 分流

办法是分平台实现:Web 端干脆不碰 crypto,直接用 shared_preferences 落 localStorage。

dart 复制代码
// lib/services/token_storage.dart
Future<String?> readToken() async {
  if (kIsWeb) {
    // Web:localStorage,无 crypto 依赖,兼容非安全上下文
    final sp = await SharedPreferences.getInstance();
    return sp.getString(_key);
  }
  // 原生:Keystore / Keychain
  final storage = FlutterSecureStorage();
  return storage.read(key: _key);
}

同时在 token 读取处加 try/catch,读取失败只降级为"不带鉴权发出",不再中止请求(避免再次静默失败):

dart 复制代码
// lib/core/api_client.dart token 拦截器
try {
  final token = await tokenStorage.readToken();
  if (token != null) options.headers?['Authorization'] = 'Bearer $token';
} catch (e) {
  debugPrint('[API][TOKEN-ERR] $e'); // 常驻打印,保留可观测性
}

这次改动不大,但要把"分平台"这件事落到所有 读写 token 的地方,别只改了 read 忘了 write / delete。我整理了一份清单:

改动点 位置 做什么
读取分流 token_storage.readToken() kIsWebshared_preferences,否则走 flutter_secure_storage
写入分流 token_storage.writeToken() 同上,Web 落 localStorage,原生落 Keystore
删除分流 token_storage.deleteToken() 登出 / 清缓存时两边都要清,否则原生删了 Web 还在
读取兜底 api_client 拦截器 try/catch,读取失败降级为"不带鉴权",常驻 debugPrint

四件事一次做齐,后面就再没被这个坑咬过。

顺带提一句 key 的命名:localStorage按域名 隔离的,多个 Flutter Web 项目起在同一个 localhost 端口容易串味。我习惯给 key 加项目前缀,比如 myapp_token,省得调试时把 A 项目的 token 读到了 B 项目里,查半天以为又是加密的锅。

安全权衡:Web 明文不等于"随便存"

这里得跟各位看官掰扯清楚,别为了修坑把安全底线也一起修了。

  • Web 用 shared_preferences = 明文 localStorage,弱于 Keystore。
  • 仅适合调试 / 内测。正式 Web 发布需评估更稳的方案。
方案 安全性 适用阶段 备注
明文 localStorage(shared_preferences) 调试 / 内测 任何 JS 都能读,别上生产
原生 Keystore / Keychain 原生正式包 Web 上不可用
后端 httpOnly Cookie + HTTPS Web 正式发布 防 XSS 偷 token,推荐 Web 生产方案

我自己的做法是:内测阶段 Web 用 shared_preferences 快速跑通,正式发布前切到 httpOnly Cookie + HTTPS,token 短期化、配 refresh 兜底。这样既不影响开发效率,生产也不裸奔。

这里多说一句为什么推荐 httpOnly Cookie:它被标记后,前端 JS 读不到document.cookie 里也拿不到),哪怕页面被注入了恶意脚本,token 也偷不走------它只跟着请求头走。这比把 token 明文塞进 localStorage 稳得多,是 Web 生产环境的常规操作。

一行自检:window.isSecureContext

如果你也被"登录能、其它全挂"整懵了,别急着翻请求层,先敲一行:

dart 复制代码
// 需 import 'dart:html' as html;
debugPrint('isSecureContext = ${html.window.isSecureContext}');

返回 false,那八成就是今天这个坑------crypto.subtle 在非安全上下文里是 undefinedflutter_secure_storage 的 Web 实现直接傻眼。把它换成 localhost 或挂上 HTTPS,世界就清净了。

顺带提醒:不止 flutter_secure_storage 会踩这个坑。任何在 Web 端依赖 Web Crypto(crypto.subtle)做加密 / 解密的库,在 HTTP + 局域网 IP 下都会哑火------AES-GCM、RSA、签名校验全都一个样。判断方法也通用:先 debugPrint(window.isSecureContext),返回 false 就别在 Web 端硬刚加密了,换明文存储或挂 HTTPS 再说。

小结

回头看,这就是个"Web 特有的非安全上下文陷阱":flutter_secure_storage_web 依赖 crypto.subtle,而它在 HTTP + 非 localhost 下是 undefined,于是 Web 端 token 读取直接炸,表现成"只有带 token 的请求才挂"。最坑的是它伪装成网络问题,骗你去翻请求层代码。

各位看官记住一句话:Web 和原生的 token 存储,别想一套通吃,得分平台实现;遇到"登录能、其它全挂",第一反应确认 dev server 是不是 HTTPS / localhost,别一上来就怀疑人生对吧! 下次再看到 Selected call is null 这种摸不着头脑的 Web 报错,先别急着重写请求层,多半是上下文或加密那档子事。

相关阅读


最后,如果这篇文章帮你省下了大半天折腾时间,希望看官您用发财的小手点个小赞哈!要是你也被这个坑打过脸,欢迎在评论区吐槽,让更多同学看到,少走弯路。谢谢大家!

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关推荐
kyriewen1 小时前
我把今年流传的前端 AI 面试题整理了一遍——4 类场景题+回答框架(附速查表)
前端·面试·程序员
计算机魔术师1 小时前
国产多模态模型正面硬刚Opus旗舰:差距从30%缩到3%
前端
风骏时光牛马2 小时前
程序员进阶:深度思考,解锁职场成长的底层逻辑
前端
IT_陈寒2 小时前
用了Proxy才发现以前的JavaScript白写了
前端·人工智能·后端
爱丶不疚2 小时前
Eval: Agent 说的 Eval 是什么?从单测、TDD 到 Sentry 聊起
前端·ai编程·vibecoding
求道於盲2 小时前
python中的类型标注
前端
计算机魔术师2 小时前
从硅谷测试到全球铺开,ChatGPT广告的10亿美元秘密
前端
专业抄代码选手3 小时前
08|Fiber 上的 `useState`:状态终于属于具体组件
前端·javascript·react.js
默_笙4 小时前
😭 Vibe Coding 翻车实录:AI 编程为什么必须先写"剧本"
前端·javascript
LEE4 小时前
原来 Claude Code 最厉害的工具,一行代码都不写
前端·后端