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 的 read 走 AES-GCM 解密 ,强依赖 window.crypto.subtle。
window.crypto.subtle只在安全上下文 (HTTPS 或localhost)可用。- 如果 dev server 用 HTTP、且不是 localhost (比如局域网 IP
http://192.168.x.x:端口),就是非安全上下文 →crypto.subtle为undefined→read抛异常。 - 而登录是 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 兼容说明,能少踩一半坑。

我是怎么一步步排查的
既然踩了坑,我也跟各位看官唠唠当时的弯路,免得你们再绕一遍。
- 先看控制台 。满屏
Selected call is null,但 Dart 层没红屏、没明确异常(Web 下异常也容易被吞,这块后面 Flutter Release 吞异常 vs Debug 红屏:看不见的 release-only bug 专门讲过),一眼望过去根本不知道哪炸的。 - 怀疑拦截器逻辑写错。把鉴权 header 那一段注释掉,请求照样发不出去------才意识到不是"加 header"的问题,是"读 token"那一步就挂了。
- 怀疑 flutter_secure_storage 不支持 Web 。去翻源码,发现 1.2.x 的 web 实现走 AES-GCM 解密,而解密强依赖
window.crypto.subtle。 - 最后查上下文 。把 dev server 从局域网 IP 的 HTTP 换成
localhost再跑,问题消失。回头一看------哦,原来是crypto.subtle在非安全上下文里是undefined。其实当时只要在 console 里敲一句crypto.subtle就能看到它返回undefined,直接实锤,我愣是绕了前面三步才想起来。所以各位看官,遇到诡异的 Web 问题,先看一眼isSecureContext和crypto.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() |
kIsWeb 走 shared_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 在非安全上下文里是 undefined,flutter_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 报错,先别急着重写请求层,多半是上下文或加密那档子事。
相关阅读
- Flutter fl_chart 0.70 破坏性 API 迁移实录:duration/getTooltipColor/withValues 全解
- Flutter 吸顶分组列表实战:语义桶分组 + 点击头平滑滚动
- Flutter 两个反直觉布局坑:ListTile 水波纹 / VerticalDivider 踩坑实录
- Flutter Release 吞异常 vs Debug 红屏:看不见的 release-only bug
- Flutter 可复用公共组件库设计与落地:AppDialog/BottomSheet 等实战
最后,如果这篇文章帮你省下了大半天折腾时间,希望看官您用发财的小手点个小赞哈!要是你也被这个坑打过脸,欢迎在评论区吐槽,让更多同学看到,少走弯路。谢谢大家!
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!