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 优化校阅,转发请注明首发地址,谢谢大家!

相关推荐
用户2930750976691 小时前
TypeScript 必考题:Type 与 Interface 全面对比
前端
hunterandroid1 小时前
[鸿蒙从零到一] HarmonyOS 媒体能力实战:图片、音频与视频处理
前端
打呵欠的猫1 小时前
我让 AI 封装了一个 ImageUpload 组件,它设计的 5 层校验链路比我想的周全
前端·ai编程
AlexMaybeBot1 小时前
躺在沙发上开发 Openclaw 的移动端APP
前端·flutter
用户2181697049301 小时前
Flutter(十三)Text Image TextField
前端
程序员黑豆2 小时前
深入解析Java数据类型:基本类型与引用类型的本质区别与实战选择
前端·ai编程·全栈
hz567892 小时前
视频会议终端品牌如何选择?主流产品特点与选型标准
安全·音视频·实时音视频·信息与通信
天空之城--2 小时前
Android Flutter行业动态与学习参考(2026年8月)
android·flutter
东方小月3 小时前
从零开发一个 Coding Agent(七):实现纯文本 Agent Loop
前端·人工智能
大家的林语冰3 小时前
👉 尤雨溪再次成立新公司,同时官宣 Pinia 4 正式发布!
前端·javascript·vue.js