JWT 与 Supabase 认证机制全解析:从 Token 结构到多级降级续期的工程实践
本文以 TryBox 应用为案例,结合真实 JWT 样本、Supabase Auth 认证流程、六层 Token 消费降级链、油猴脚本前端捕获、服务端双写策略,系统梳理 JWT 原理、Supabase 签发机制、Token 生命周期管理,以及如何在工程实践中实现 Token 的自动捕获、刷新与持久化。
本文经过多轮修订,修正了 refresh_token 生命周期、补充了六层降级链、双写策略、并发锁、JWKS 验签等关键细节,可作为 Supabase 认证落地的完整参考。
** 测试落地项目因涉及影视站点逆向,暂时private状态,后续调整。 **
核心结论速查表
| 维度 | 结论 |
|---|---|
| JWT 本质 | Base64URL 编码的三段式字符串,非加密,可解码不可篡改 |
| 签发者 | Supabase Auth(由 iss 字段证明) |
| 时间戳 | exp/iat/amr.timestamp 全部是服务器 UTC 时间 |
| access_token | 就是完整 JWT,有效期默认 1 小时 |
| refresh_token | 不是 JWT,一次性使用,用后即失效(rotation) |
| session_id | 登录时生成,刷新时不变,重新登录才变 |
| 核心设计 | 六层降级链(内存→用户配置→Redis→refresh刷新→旧access刷新→Bark告警) |
| 存储策略 | 双写:Redis(权威)+ .env.local(备份)+ 内存(缓存) |
| 前端采集 | 油猴脚本重写 localStorage.setItem 钩子,只推 access 不推 refresh |
| 关键约束 | refresh_token 绝不能下发到客户端,必须服务端集中管理 |
| 并发安全 | 刷新需加锁(Redis SET NX EX),避免多实例竞争导致 refresh 失效 |
| 可观测性 | Bark 告警 + 诊断 stream,失败时主动通知而非静默 |
JWT 的设计哲学是短期凭证 + 长期刷新,在安全性和用户体验之间取得平衡。而这套系统在标准模式之上,又叠加了多级降级、双写、前端采集、告警诊断四层工程优化,本质上是一个**”以 Redis 为中心的 Token 分发与续期中间件”**,配合前端采集和后端降级,实现了跨服务、跨实例、跨客户端的统一认证。理解它,就理解了 Supabase 在生产环境落地的完整工程实践。
目录
- 引言
- JWT 基础结构与解码原理
- 为什么 Authorization 头中是 Supabase 签发的 JWT
- JWT 中关键字段详解
- 客户端如何向服务端发起请求获取认证
- 登录后 Authorization 是否”确定”了?
- access_token 与完整 JWT 的关系
- Token 过期机制与 refresh_token 续期
- Token 消费的六层降级链(核心设计)
- 双写策略:Redis + .env.local + 内存
- 油猴脚本:前端侧 Token 捕获
- Bark 告警与可观测性
- 工程实践:Node.js + supabase-js 实现自动刷新与持续获取 Stream
- 难点剖析与思考
- 总结
一、引言
在现代 Web 应用的身份认证体系中,JWT(JSON Web Token)已经成为事实上的标准。本文以 TryBox 应用为案例,结合其实际请求中的 JWT 样本、Supabase Auth 认证流程、服务端降级脚本以及前端油猴捕获脚本,系统性地梳理 JWT 的原理、Supabase 签发机制、Token 生命周期管理,以及如何在工程实践中实现 Token 的自动捕获、刷新与持久化。
全文从以下维度展开:
- JWT 基础结构与解码原理
- 为什么 Authorization 头中的是 Supabase 签发的 JWT
- JWT 中关键字段的含义与来源(重点剖析 exp、iat、amr.timestamp、session_id)
- 客户端如何向 Supabase 发起认证请求并获取 Token
- 登录后 Authorization 是否”确定”了?—— Token 的动态性
- access_token 与完整 JWT 的关系
- Token 过期机制与 refresh_token 续期流程
- Token 消费的六层降级链(核心设计)
- 双写策略:Redis + .env.local + 内存
- 油猴脚本:前端侧 Token 捕获
- Bark 告警与可观测性
- 工程实践:在 Node.js 中使用 supabase-js 实现 Token 自动刷新与持续获取 Stream
- 难点剖析与思考
- 总结
二、JWT 基础结构与解码原理
2.1 JWT 是什么
JWT(JSON Web Token)是一个由点号(.)分隔的三段式字符串:
1 | header.payload.signature |
以案例中的 Token 为例:
1 | eyJhbGciOiJFUzI1NiIsImtpZCI6... . eyJpc3MiOiJodHRwczovL3NhaWNo... . iTwfJ4vEMu0tCHy0Zhfo... |
三段分别对应:
| 部分 | 内容 | 编码方式 |
|---|---|---|
| Header | 算法与密钥 ID | Base64URL |
| Payload | 声明(Claims) | Base64URL |
| Signature | 签名 | 二进制转 Base64URL |
2.2 Header 解码
对第一段进行 Base64URL 解码,得到:
1 | { |
alg: ES256:使用 ECDSA P-256 非对称签名算法。kid:密钥 ID,服务端据此选择对应的公钥进行验签。typ: JWT:令牌类型。
2.3 Payload 解码
对第二段进行 Base64URL 解码,得到:
1 | { |
2.4 解码方法
Python 示例:
1 | import base64 |
命令行一行版:
1 | python3 -c " |
Node.js 示例:
1 | const token = "eyJhbGciOiJFUzI1NiIs..."; |
重要提示:JWT 的 Header 和 Payload 只是 Base64URL 编码,并非加密。任何人都可以解码查看内容,但无法篡改,因为签名会校验失败。
三、为什么 Authorization 头中是 Supabase 签发的 JWT
3.1 从 Payload 中寻找证据
判断签发者的核心依据是 iss(Issuer)声明:
1 | "iss": "https://saichcuwzbncuqnqhuul.supabase.co/auth/v1" |
这明确指向 Supabase 项目的 Auth 端点,说明该 Token 由 Supabase Auth 服务签发。
此外,Payload 中的其他字段也完全符合 Supabase Auth 的规范:
aud: "authenticated":受众为已认证用户。role: "authenticated":角色为已认证。app_metadata.provider: "email":认证提供者为邮箱。amr: [{ method: "otp", timestamp: ... }]:认证方法为 OTP。session_id、aal、is_anonymous:均为 Supabase 特有声明。
3.2 Supabase Auth 的工作模式
TryBox 采用 Supabase 作为认证服务。其架构如下:
1 | 用户浏览器 |
TryBox 服务端并不签发 Token,它只是信任并验证 Supabase 签发的 JWT。验证时使用 Supabase 项目的 JWKS 端点获取公钥:
1 | https://<project>.supabase.co/auth/v1/.well-known/jwks.json |
JWT Header 中的 kid 正是用来匹配 JWKS 中对应公钥的。
3.3 为什么用 ES256 而非 HS256
- HS256 是对称加密,服务端和客户端共享同一个 secret,一旦泄露全线崩溃。
- ES256 是非对称,Supabase 持有私钥,任何人可用公钥验证,服务端不需要持有任何 secret。
- 这也是 Supabase 新版默认用 ES256 的原因。
3.3.1 HS256 与 ES256 的完整对比
JWT 支持多种签名算法,Supabase 历史上用过 HS256,新项目默认用 ES256。两者在安全性、密钥管理、验签方式上有本质区别:
| 维度 | HS256 | ES256 |
|---|---|---|
| 全称 | HMAC with SHA-256 | ECDSA with SHA-256 (P-256) |
| 密钥类型 | 对称:同一个 secret 用于签名和验签 | 非对称:私钥签名,公钥验签 |
| 签名方 | 持有 secret 的一方 | 持有私钥的一方(Supabase) |
| 验签方 | 必须持有同一个 secret | 只需持有公钥 |
| 密钥分发 | secret 必须安全地分发给所有验签方 | 公钥可公开发布(JWKS) |
| 泄露风险 | secret 一旦泄露,攻击者可伪造任意 token | 私钥泄露才危险;公钥泄露无影响 |
| JWKS 支持 | ❌ 不支持(没有公钥概念) | ✅ 支持(通过 /.well-known/jwks.json 分发) |
| 算法标识 | HS256 |
ES256 |
| 签名长度 | 较短(32 字节) | 较长(64 字节) |
| 计算开销 | 较低 | 略高(但可忽略) |
| Supabase 默认 | 旧项目 | 新项目默认 |
| 典型场景 | 单体应用、内部服务 | 多服务、微服务、第三方验签 |
为什么 Supabase 新项目改用 ES256?
- 服务端无需持有 secret:TryBox 只需从 Supabase 的 JWKS 端点拉公钥,不用保管任何密钥,减少了密钥泄露面。
- 多服务友好:任何想验证 Supabase JWT 的服务(TryBox、第三方集成方)都可以独立拉公钥,不需要向 Supabase 索要 secret。
- 符合 OIDC 标准:OIDC 规范推荐非对称签名,Supabase 遵循此实践。
- 私钥集中管理:只有 Supabase Auth 服务持有私钥,攻击面收窄。
HS256 的遗留问题:
- 如果你在旧项目里看到
alg: HS256,说明用的是 Supabase 的 JWT secret 对称签名。 - 验签方必须持有 secret,secret 泄露即全线崩溃。
- Supabase 已逐步迁移到 ES256,新项目不再暴露 HS256 的 secret。
如何识别当前项目用的是哪种算法?
看 JWT Header 的 alg 字段:
1 | { |
ES256:非对称,用 JWKS 验签。HS256:对称,用 secret 验签(旧项目)。
验证代码差异:
1 | // ES256:用 JWKS 公钥验签 |
结论:ES256 是非对称签名,Supabase 新项目默认使用,验签方只需公钥;HS256 是对称签名,旧项目遗留,验签方需持有 secret,安全性较低。TryBox 案例中的 alg: ES256 表明它使用新式非对称签名,配合 JWKS 端点验签。
3.4 Node.js 验证 JWT 示例
使用 jose 库:
1 | import { createRemoteJWKSet, jwtVerify } from 'jose'; |
注意:
issuer必须精确匹配,包括末尾的/auth/v1。audience是authenticated,不是项目 URL。kid自动从 Header 匹配 JWKS 中的公钥。
四、JWT 中关键字段详解
案例中 Payload 包含多个时间戳和会话标识,其中 exp、iat、amr.timestamp、session_id 是每次刷新或登录时可能变动的字段。下面逐一剖析。
4.1 exp:过期时间
1 | "exp": 1791710467 |
- 含义:JWT 的过期时间(Expiration Time)。
- 来源:Supabase Auth 服务器在签发时,用
服务器当前时间 + 有效期计算得出。 - 默认有效期:Supabase 默认
access_token有效期为 3600 秒(1 小时),可在项目设置中修改。 - 验证:
exp - iat = 1791710467 - 1791706867 = 3600,正好 1 小时。 - 时间性质:Unix 时间戳(秒),UTC,服务器时间。
- 作用:服务端验证 Token 是否过期;过期后需要用
refresh_token换新的access_token。
4.2 iat:签发时间
1 | "iat": 1791706867 |
- 含义:Issued At,JWT 的签发时间。
- 来源:Supabase Auth 服务器在生成 Token 时写入的服务器当前时间。
- 时间性质:Unix 时间戳(秒),UTC,服务器时间。
- 作用:判断 Token 新旧;与
exp配合计算有效期。 - 变动:每次刷新 Token,
iat都会更新。
4.3 amr.timestamp:认证方法时间戳
1 | "amr": [ |
- amr:Authentication Methods Reference,认证方法引用。
- **method: “otp”**:表示用户通过 OTP(一次性密码/邮箱验证码)登录。
- timestamp:该认证方法发生的服务器时间,Unix 时间戳,UTC。
- 来源:Supabase Auth 在用户完成 OTP 验证时记录。
- 与 iat 的关系:
amr.timestamp = 1791694481iat = 1791706867- 差值 =
12386秒 ≈ 3.44 小时。 - 说明用户最初通过 OTP 登录是在约 3.44 小时前,而当前 Token 是后来刷新得到的(
iat是刷新时间)。
- 作用:记录认证事件;用于多因素认证(MFA)判断;服务端可据此决定是否要求重新认证。
- 变动:重新登录或重新进行 OTP 验证时,
timestamp会更新。
4.4 amr 数组可能有多个元素
amr 是数组,因为一个会话可能经历多次认证:
1 | "amr": [ |
多元素场景:
- 用户先用密码登录,再完成 MFA(TOTP/OTP),会有两条记录。
aal(Authenticator Assurance Level)反映最高认证等级:aal1:单因素aal2:多因素(如密码 + TOTP)
服务端可据此判断是否需要敏感操作二次验证。
4.5 session_id:会话 ID
1 | "session_id": "f97b4fb4-4fa0-4546-ac47-657e534fae5c" |
- 含义:Supabase Auth 为这次登录会话分配的唯一标识符,UUID 格式。
- 来源:用户登录成功时,由 Supabase Auth 服务器生成。
- 作用:
- 标识一次完整的登录会话。
- 刷新 Token 时,
session_id保持不变。 - 登出时,服务端会使该
session_id失效。 - 可用于会话管理、强制下线、审计等。
- 变动:每次重新登录会生成新的
session_id;刷新 Token 不会改变。
4.6 role 与 aud 的区别
容易混淆的两个字段:
| 字段 | 含义 | Supabase 中的典型值 |
|---|---|---|
aud |
Audience,令牌的预期接收方 | authenticated |
role |
Role,用户的角色 | authenticated / anon / service_role |
aud用于跨服务验证:TryBox 验签时会检查aud === 'authenticated',防止匿名 token 混入。role用于权限控制:PostgreSQL 的 RLS(行级安全)策略里会用到role。
两者可能同值但语义不同,不要混淆。
4.7 is_anonymous 字段
1 | "is_anonymous": false |
Supabase 支持匿名登录(Anonymous Sign-ins),即无需邮箱/密码,直接获取临时身份。此时 is_anonymous: true,role: 'authenticated',aud: 'authenticated',但 email 为空。
TryBox 若允许匿名用户,需在服务端额外判断 is_anonymous,避免匿名用户访问付费内容。
4.8 时间戳性质总结
| 字段 | 含义 | 来源 | 时间类型 | 是否变动 |
|---|---|---|---|---|
exp |
过期时间 | 服务器签发时计算 | Unix UTC | 每次刷新变 |
iat |
签发时间 | 服务器当前时间 | Unix UTC | 每次刷新变 |
amr.timestamp |
认证方法发生时间 | 服务器记录 OTP 验证时刻 | Unix UTC | 重新登录变 |
session_id |
会话 ID | 登录时生成 | UUID | 重新登录变 |
关键结论:所有时间戳均为服务器时间(Unix UTC),不是客户端本地时间。客户端只负责接收和存储,无法参与生成或篡改。
4.9 expires_in 与 expires_at 的区别
你提供的 session JSON 里有:
1 | { |
| 字段 | 含义 | 类型 |
|---|---|---|
expires_in |
有效期相对时长(秒) | 相对值 |
expires_at |
过期绝对时间戳(秒) | 绝对值 |
expires_at = 当前时间戳 + expires_in。
JWT 里的 exp 是 UNIX 秒级时间戳,**session.expires_at** 也是秒级时间戳,两者应该一致(但如果刷新有延迟可能略有差异)。
**代码里判断 token 是否快过期应使用 expires_at**:
1 | const expiresAtMs = session.expires_at * 1000; // 注意乘以 1000 转毫秒 |
这是常见隐患:如果直接用 expires_in 计算,会随会话时长漂移;用 expires_at 更稳。
五、客户端如何向服务端发起请求获取认证
客户端获取 JWT 并不是向 TryBox 服务端请求,而是向 Supabase Auth 服务 请求。TryBox 只是信任并验证 Supabase 签发的 JWT。
5.1 整体认证流程
1 | 用户输入邮箱 |
5.2 第一步:请求发送 OTP
1 | POST https://saichcuwzbncuqnqhuul.supabase.co/auth/v1/otp |
apikey:Supabase 项目的匿名公开密钥,用于标识项目,可以暴露在前端。create_user: true:如果用户不存在则自动创建。
5.3 第二步:提交 OTP 验证
1 | POST https://saichcuwzbncuqnqhuul.supabase.co/auth/v1/verify |
验证成功后,Supabase 返回 session:
1 | { |
5.4 第三步:客户端保存 Token
前端通常把 session 保存在:
localStoragesessionStorage- Cookie(取决于配置)
supabase-js 默认存在 localStorage,键名类似 sb-<project-ref>-auth-token。
5.5 第四步:调用 TryBox 接口
1 | const res = await fetch('https://trybox.app/_serverFn/5a020077...', { |
TryBox 服务端会:
- 从
Authorization头取出 JWT。 - 用 Supabase 项目的公钥(JWKS)验证签名。
- 检查
exp、iss、aud、role等。 - 确认用户身份后,返回播放地址等数据。
5.5.1 TanStack Start Server Function 序列化格式解析
TryBox 使用 TanStack Start 框架,其 Server Function 的请求体不是普通 JSON,而是一种压缩序列化格式。以电视剧请求为例:
1 | { |
字段含义解析:
| 字段 | 含义 | 说明 |
|---|---|---|
t |
Type,序列化类型标记 | 0 = 数字,1 = 字符串,10 = 对象,11 = 数组 |
i |
Index,索引 | 在同一层级中的位置标识 |
p |
Payload,载荷 | 对象/数组的实际内容 |
k |
Keys,键名数组 | 对象的键名列表 |
v |
Values,值数组 | 与 k 一一对应的值列表 |
s |
Scalar,标量值 | 叶子节点的实际值 |
o |
Order,排序标记 | 用于保持键序,通常为 0 |
f |
Function ID | Server Function 的标识符,127 对应目标函数 |
m |
Middleware,中间件列表 | 空数组表示无额外中间件 |
反序列化后的等效 JSON:
1 | { |
为什么用这种格式?
- 压缩体积:相比普通 JSON,省略了引号、冒号、空格,减少网络传输量。
- 保留类型信息:
t字段显式标注类型,避免JSON.parse后的类型歧义(例如"1"vs1)。 - 防篡改:框架层可校验序列化结构,拒绝畸形请求。
- 流式支持:适合 Server Function 的流式响应场景。
电影 vs 电视剧请求体的差异:
| 请求类型 | 字段 |
|---|---|
| 电影 | tmdb_id、kind: "movie" |
| 电视剧 | tmdb_id、kind: "tv"、season、episode |
其他序列化标记(t、i、p、k、v、o、f、m)完全一致。接口根据 kind 选择对应的 URL 模板,返回相应季集的播放地址。
客户端是否需要手动构造?
不需要。如果使用 @tanstack/react-start 或 @tanstack/start 的 Server Function 调用 API,框架会自动序列化/反序列化。只有在手动用 curl 或 fetch 直接调用时,才需要理解这个格式。
调试建议:
- 用浏览器 DevTools 的 Network 面板查看实际请求体。
- 用
JSON.stringify打印前先JSON.parse反序列化,便于阅读。 - 不要手动构造这种格式,容易出错;优先用框架提供的调用方法。
5.6 使用 supabase-js 的简化写法
1 | import { createClient } from '@supabase/supabase-js' |
六、登录后 Authorization 是否”确定”了?
6.1 登录成功那一刻确定了什么
用户通过 OTP 验证成功后,Supabase Auth 返回一个 session,包含:
access_token:JWT,即第一个 access token。refresh_token:用于后续换新 token。expires_in:通常 3600 秒。user:用户信息。
此时确定了:
- 会话身份(
sub、role、session_id、amr.timestamp)。 - 初始
access_token。 refresh_token。
6.2 但 Authorization 头不是固定不变的
access_token 有有效期,通常 1 小时。过期后,客户端会用 refresh_token 向 Supabase 请求新的 access_token。
刷新后:
| 字段 | 是否变化 | 说明 |
|---|---|---|
access_token |
变 | 新的 JWT |
iat |
变 | 新的签发时间 |
exp |
变 | 新的过期时间 |
| 签名 | 变 | 因为内容变了 |
refresh_token |
变 | rotation 机制,旧的立即失效 |
session_id |
通常不变 | 还是同一个登录会话 |
amr.timestamp |
不变 | 认证事件没有重新发生 |
sub、email、role |
不变 | 用户身份不变 |
因此:
1 | 登录时: Authorization: Bearer A |
Authorization 头的值一直在变,但代表的还是同一个用户、同一个会话。
6.3 每次请求都是动态取当前 Token
supabase-js 会自动:
- 检查 token 是否快过期。
- 如果快过期,先用
refresh_token刷新。 - 把最新的
access_token返回。
所以 Authorization 头不是登录时写死的一个字符串,而是每次请求前根据当前会话状态动态生成的。
6.4 什么时候会真正”重新确定”
以下情况会生成全新的会话和 token:
- 用户重新登录(重新输入 OTP)。
- 用户登出后再登录。
- 会话被服务端撤销。
refresh_token失效或过期。
这时:
- 新的
session_id生成。 - 新的
amr.timestamp。 - 新的
refresh_token。 - 新的
access_token序列。
6.5 时间线示例
1 | 10:00 用户 OTP 登录成功 |
七、access_token 与完整 JWT 的关系
7.1 access_token 就是完整 JWT
登录成功后拿到的 access_token 本身就是一个完整的 JWT,不需要再”进一步获取”。
1 | { |
其中:
access_token:完整的 JWT,格式为header.payload.signature。refresh_token:不是 JWT,是普通字符串,用于刷新。
7.1.1 id_token 与 access_token 的区别
在 Supabase 的 OIDC(OpenID Connect)模式下,登录成功后可能同时返回 id_token 和 access_token。两者都是 JWT,但用途和内容有本质区别:
| 维度 | access_token | id_token |
|---|---|---|
| 用途 | 调用 API(授权) | 标识用户身份(认证) |
受众 aud |
资源服务器(如 TryBox) | 客户端应用(如 TryBox 前端) |
| 使用位置 | Authorization: Bearer <access_token> |
通常只在前端解析,不发给业务服务 |
| 是否发给第三方 | ✅ 发给资源服务器 | ❌ 不发给资源服务器 |
| 有效期 | 短(默认 1 小时) | 短(通常与 access_token 一致) |
| 是否可刷新 | ✅ 用 refresh_token 刷新 | ✅ 随 access_token 一起刷新 |
| 包含用户信息 | 部分(sub、email 等) |
完整(name、picture、email_verified 等) |
| 标准 | OAuth 2.0 | OIDC |
| Supabase 默认返回 | ✅ 总是返回 | ⚠️ 仅在 OIDC 流程中返回 |
在 TryBox 案例中:
- 你贴的
Authorization: Bearer eyJhbGci...里的 token 是 access_token。 - 它用于调用 TryBox 的 Server Function,TryBox 服务端验证它来确认用户身份。
id_token如果存在,通常只用于前端显示用户信息(如头像、昵称),不会发给 TryBox 服务端。
两者的 JWT 结构对比:
access_token 的 Payload(案例中的):
1 | { |
id_token 的 Payload(典型):
1 | { |
关键区别:
aud不同:- access_token 的
aud是authenticated(资源服务器受众)。 - id_token 的
aud是客户端应用 ID(client_id)。
- access_token 的
用途不同:
- access_token 用于授权:证明”你有权访问 TryBox”。
- id_token 用于认证:证明”你是谁”。
接收方不同:
- access_token 发给 TryBox 服务端。
- id_token 只在前端使用,不发给服务端。
包含信息不同:
- id_token 通常包含更完整的用户资料(姓名、头像等)。
- access_token 只包含必要的授权信息。
Supabase 中的实际行为:
- 使用
supabase.auth.signInWithOtp或verifyOtp时,返回的session中:- 一定有
access_token。 - 如果启用了 OIDC,可能也有
id_token(在session.id_token或data.id_token中)。
- 一定有
- 使用
supabase.auth.getSession()获取的 session 通常只含access_token。 - 使用
supabase.auth.getUser()可以获取完整用户信息(通过 access_token 调用/auth/v1/user端点)。
为什么容易混淆?
- 两者都是 JWT,都有三段结构。
- 两者都由 Supabase 签发,
iss相同。 - 两者的
sub相同(都是同一个用户)。 - 但
aud不同,用途不同,接收方不同。
安全提醒:
- access_token 不能给第三方:它等同于登录态。
- id_token 也不能随意公开:虽然用途不同,但它也包含用户身份信息,泄露可能导致隐私问题。
- 不要混用:不要把 id_token 当 access_token 用,反之亦然。TryBox 服务端只认 access_token。
如何区分你拿到的是哪个?
看
aud字段:authenticated→ access_token。- 客户端 ID → id_token。
看使用位置:
Authorization: Bearer头 → access_token。- 前端解析显示用户信息 → id_token。
看返回来源:
session.access_token→ access_token。session.id_token或data.id_token→ id_token。
结论:
access_token用于调用 API,受众是资源服务器,必须发给 TryBox。id_token用于标识用户,受众是客户端应用,只在前端使用。- 两者都是 JWT,但用途、受众、接收方不同,不能混用。
- TryBox 案例中,
Authorization: Bearer里的是access_token,不是id_token。
7.2 refresh_token 不是 JWT
refresh_token 通常长这样:
1 | v1.Mr... |
它不是 JWT,不能直接放在 Authorization: Bearer 里。它的唯一用途是:当 access_token 过期后,向 Supabase 换取新的 access_token。
7.3 通过 access_token 还能做什么
虽然 JWT 本身已经完整,但可以用 access_token 做更多事:
调用 Supabase 获取用户信息:
1 | GET https://saichcuwzbncuqnqhuul.supabase.co/auth/v1/user |
调用 TryBox 业务接口:
1 | POST https://trybox.app/_serverFn/... |
八、Token 过期机制与 refresh_token 续期
8.1 access_token 会过期
access_token 会过期,这是 JWT 的固有设计,也是安全机制的一部分。案例中:
1 | "iat": 1791706867 |
差值正好是 3600 秒(1 小时),所以它 1 小时后就会失效。失效后,服务端会拒绝请求,通常返回 401 Unauthorized。
8.2 refresh_token 是一次性的(重要修正)
这是全文最容易被误解、也最关键的修正点。
你的油猴脚本里有一条极其重要的注释:
1 | // refresh 为一次性token,使用一次及失效,使用端在使用时会获取并推送新的值, |
这是 Supabase 的 refresh token rotation(轮换)机制:
- 每次用 refresh_token 换新 token 时,旧的 refresh_token 立即失效,Supabase 返回一个全新的 refresh_token。
- 如果客户端推送的是”当前页面 localStorage 里的旧 refresh_token”,可能会覆盖掉其他服务/其他标签页刚刚推送的新 refresh_token,导致整个系统 refresh_token 失效。
- 所以油猴脚本只推送 access_token,不推送 refresh_token。refresh_token 的更新由服务端脚本(
#!binbash.txt)负责。
**这解释了为什么系统要设计成”服务端集中管理 refresh_token”**:refresh_token 是单点的、一次性的、易失效的,必须由唯一权威来源管理。
8.3 refresh_token 生命周期对照表
| 属性 | 说明 |
|---|---|
| 是否 JWT | ❌ 不是,是普通字符串(如 v1.Mr... 或 axmnt7pfwkrx) |
| 是否一次性 | ✅ 是,Supabase 默认启用 rotation,用一次即失效 |
| 谁管理 | 服务端(#!binbash.txt 脚本),不由前端直接推送 |
| 失效后 | 必须重新登录,生成全新 session_id |
| 存储位置 | Redis + 本地 .env.local(双写) |
8.4 用 refresh_token 续期
过期后,客户端需要拿 refresh_token 向 Supabase 换新的 access_token:
1 | POST https://saichcuwzbncuqnqhuul.supabase.co/auth/v1/token?grant_type=refresh_token |
Supabase 返回新的 session:
1 | { |
然后客户端用新的 access_token 继续请求。
8.5 refresh_token 也会过期吗
会。refresh_token 也有有效期,并且可以被撤销。具体时长取决于 Supabase 项目的配置,通常比 access_token 长很多。
以下情况会让 refresh_token 失效:
- 超过配置的有效期。
- 用户主动登出。
- 管理员撤销会话。
- 检测到 refresh token 重用(如果启用了 rotation 和 reuse detection)。
- 用户修改密码等安全事件。
如果 refresh_token 也失效了,就无法再自动刷新,必须重新登录。
8.6 为什么设计成会过期
- 限制泄露风险:
access_token一旦泄露,最多只能用 1 小时。 - 可撤销会话:服务端可以通过
refresh_token撤销长期访问。 - 最小权限:短期凭证减少被滥用窗口。
- 符合 OAuth 2.0 / OIDC 最佳实践。
九、Token 消费的六层降级链(核心设计)
#!binbash.txt 开头有一段极其关键的注释,描述了 token 的完整消费流程。这是整个系统的核心设计,也是分布式系统中非常经典的”多级缓存 + 降级”模式。
9.1 完整流程图
1 | 请求到达 |
9.2 逐层剖析
| 层 | 来源 | 职责 | 写回策略 |
|---|---|---|---|
| 1 | 内存缓存 | 最快路径,命中即零延迟 | 无需写回 |
| 2 | 用户配置 | 允许用户手动覆盖(调试/应急) | 命中后写内存 |
| 3 | Redis | 跨进程/跨实例共享的持久化缓存 | 命中后写内存 |
| 4 | refresh_token 刷新 | 标准续期路径 | 写内存 + 写 Redis |
| 5 | 旧 access_token 刷新(降级) | refresh 失效时的兜底续期 | 写内存 + 写 Redis |
| 6 | Bark 告警 + 诊断 stream | 全部失败后通知运维 | 返回空 token |
设计哲学:每一层都在”更快”和”更可靠”之间权衡。前 3 层是读取优化,第 4-5 层是写入/续期,第 6 层是兜底告警。这比单纯用 supabase-js 自动刷新要健壮得多,适用于服务端多实例共享 token 的场景。
9.3 第 5 层”用旧 access_token 刷新”的特殊性
这是整个降级链中最容易被忽略、也最精妙的一层:
- 标准路径是”refresh_token → 新的 access_token + 新的 refresh_token”。
- 但如果 refresh_token 已经失效(比如被其他实例用过一次),标准路径就走不通。
- 此时尝试用旧的 access_token 去换新的 token。Supabase 在某些场景下允许尚未过期的 access_token 触发刷新(具体取决于配置),这是最后的续期机会。
- 成功则恢复;失败则进入告警层。
9.4 第 6 层:Bark 告警与诊断 stream
Bark 是 iOS 上的一个开源推送工具(通过 HTTP API 推送到 iPhone)。这说明系统设计者:
- 把 token 获取失败视为需要立即人工介入的严重事件。
- 失败时推送手机通知,同时返回空 token。
- “诊断 stream” 可能是返回给客户端的一个特殊响应,用于排查问题。
这是可观测性(Observability)的良好实践:不是静默失败,而是主动告警 + 提供诊断入口。
十、双写策略:Redis + .env.local + 内存
10.1 服务端脚本的双写逻辑
#!binbash.txt 脚本中有两处关键更新:
更新 .env.local:
1 | awk -v ac="$NEW_ACCESS" -v re="$REFRESH" ' |
更新 Redis:
1 | WRITE_RESULT=$(curl -s "$UPSTASH_REDIS_REST_URL/pipeline" \ |
10.2 发现一个潜在 bug
注意这里的一个细节(也可能是 bug):
access_token写入的是$NEW_ACCESS(刷新后的新值)✅refresh_token写入的是$REFRESH(刷新前的旧值)❌
这看起来是个潜在 bug:因为 Supabase 返回了 NEW_REFRESH,但 .env.local 写入的是旧的 $REFRESH。而 Redis 写入的是正确的 $NEW_REFRESH。
可能的原因:
- 作者有意为之:
.env.local只作为本地参考,真实 refresh_token 以 Redis 为准。 - 疏忽:忘记改成
$NEW_REFRESH。
建议修正:
1 | awk -v ac="$NEW_ACCESS" -v re="$NEW_REFRESH" ' |
同时,脚本中 Redis 写入用 $NEW_REFRESH 是正确的。
10.3 另一处健壮性问题
脚本对 READ_RESULT 的判断有 bug:
1 | if [ -z "$READ_RESULT" ]; then |
这个判断不充分。READ_RESULT 即使 Redis 里没值也会是 [{"result":null}],不是空字符串。正确判断应该是:
1 | REFRESH=$(echo "$READ_RESULT" | python3 -c " |
即:应该判断解析后的 $REFRESH 是否为空,而不是原始的 $READ_RESULT。
10.4 建议增加备份
.env.local 覆盖前建议先备份:
1 | cp .env.local .env.local.bak.$(date +%s) |
否则一旦脚本出错,会永久丢失旧 token。
10.5 双写策略总结
| 存储 | 写什么 | 原因 |
|---|---|---|
| Redis | 新的 access + 新的 refresh | 权威来源,多实例共享 |
| .env.local | 新的 access + 旧 refresh(疑似 bug) | 本地备份/调试 |
| 内存缓存 | 新的 access | 最快路径 |
| 油猴脚本 | 只写 access | 避免覆盖 refresh |
十一、油猴脚本:前端侧 Token 捕获
11.1 脚本核心思路:重写 localStorage.setItem
1 | const originalSetItem = localStorage.setItem; |
11.2 设计意图
- 被动捕获:Supabase JS SDK 每次登录/刷新都会调用
localStorage.setItem('sb-trybox-auth-token', JSON),钩子自动捕获。 - 自动推送:
AUTO_PUSH = true时,捕获后立即推送到 Upstash。 - 绕过 CORS:用
GM_xmlhttpRequest而非fetch,因为油猴脚本的跨域权限更宽松。 - 兜底手动按钮:悬浮按钮
Get Trybox Tokens,用户可以手动触发一次读取。 - 不推 refresh:避免覆盖其他服务推送的新 refresh_token。
11.3 关键代码:只推送 access_token
1 | const body = JSON.stringify([ |
这是一个”客户端采集 + 服务端集中管理”的经典架构。适合:
- 用户端不方便长期运行服务进程。
- 想让多个后端服务共享同一份 token。
- 需要”借用户浏览器”作为 token 获取渠道。
11.4 手动兜底读取
1 | function getCurrentToken() { |
配合悬浮按钮:
1 | const btn = document.createElement('button'); |
11.5 安全注意
油猴脚本能读取 localStorage,意味着它能读取所有同源数据,需谨慎授予权限。建议只在可信环境安装,并定期审查脚本内容。
十二、Bark 告警与可观测性
12.1 告警触发点
如第九章所述,当六层降级链全部失败时,触发 Bark 告警:
1 | └─ 6. Bark 告警 → 返回空 token → 触发诊断 stream |
12.2 设计意义
- 主动通知:不是静默失败,而是立即推送到运维人员手机。
- 提供诊断入口:返回空 token 并触发诊断 stream,便于排查具体是哪一层出了问题。
- P0 事件定位:token 失效意味着整个业务链路中断,必须最高优先级处理。
12.3 可观测性实践建议
| 实践 | 说明 |
|---|---|
| 分层打点 | 每一层命中/未命中都记录日志,便于定位 |
| 告警分级 | token 刷新失败 = P0;缓存未命中 = P3 |
| 诊断 stream | 返回结构化的失败原因,而非只有空 token |
| 环境区分 | 本地开发时避免触发生产告警 |
十三、工程实践:Node.js + supabase-js 实现自动刷新与持续获取 Stream
13.1 场景说明
用户提供了一个完整的 Supabase session JSON,包含 access_token、refresh_token、user 等信息,希望:
- 使用
@supabase/supabase-js初始化客户端。 - 将已有的 session 导入。
- 实现自动刷新 Token。
- 持续获取 TryBox 的 stream 数据。
13.2 安装依赖
1 | npm install @supabase/supabase-js |
在 package.json 中添加 "type": "module" 以消除 ESM 警告:
1 | { |
或者把文件重命名为 author.mjs,显式声明 ESM。
13.3 完整实现代码
1 | // author.js |
13.4 关键点说明
| 功能 | 实现方式 |
|---|---|
| 导入已有 session | supabase.auth.setSession({ access_token, refresh_token }) |
| 自动刷新 | createClient 时设置 autoRefreshToken: true |
| 手动刷新 | supabase.auth.refreshSession() |
| 获取当前 token | supabase.auth.getSession() |
| 过期判断 | 用 session.expires_at * 1000 与 Date.now() 比较 |
| 并发刷新保护 | refreshPromise 单例锁,复用进行中的刷新 |
| 调用 TryBox | fetch + Authorization: Bearer <token> |
| 持续获取 stream | 定时器 + 每次请求前检查 token 有效性 |
13.5 常见错误处理
错误:Cannot find package '@supabase/supabase-js'
1 | npm install @supabase/supabase-js |
错误:MODULE_TYPELESS_PACKAGE_JSON
在 package.json 中添加:
1 | { |
错误:access_token 为空
检查 setSession 时传入的 access_token 和 refresh_token 是否完整。如果 access_token 为空,Supabase 会尝试用 refresh_token 刷新,但如果 refresh_token 也无效,则会失败。
十四、难点剖析与思考
14.1 JWT 的无状态性与撤销难题
JWT 是自包含的,服务端验证时不需要查询数据库。但这带来了一个难题:如何撤销一个尚未过期的 JWT?
Supabase 的解决方案是:
access_token短期有效(1 小时),减少撤销需求。refresh_token长期有效但可撤销,登出时服务端使session_id失效。- 如果强制下线,服务端可以撤销
refresh_token,但已签发的access_token在过期前仍然有效。
这是一个权衡:无状态带来性能优势,但有状态才能精确控制撤销。
14.2 时间戳的服务器权威性
JWT 中的 iat、exp、amr.timestamp 全部由服务器生成,客户端无法篡改。这保证了:
- 即使用户本地时间不准,Token 的有效期判断仍然准确。
- 认证事件的时间记录可信,可用于审计。
- 防止客户端通过修改时间戳来延长 Token 有效期。
14.3 Token 刷新与并发请求
在高并发场景下,多个请求可能同时发现 Token 即将过期,然后同时发起刷新请求。这可能导致:
- 多个刷新请求同时到达 Supabase。
- 由于 refresh_token 是一次性的,第一次刷新后旧的 refresh_token 失效,导致后续刷新失败。
- 整个系统 refresh_token 失效,必须重新登录。
解决方案:
- 单例锁:使用 Promise 单例,确保同一时间只有一个刷新请求。
- 分布式锁:多实例场景下,用 Redis 的
SET NX EX实现分布式锁。
1 | SET tryboxStream:refresh:lock "1" NX EX 10 |
谁拿到锁,谁负责刷新;拿不到锁的实例等待并读取刷新后的新 token。
supabase-js内部已经处理了单进程内的这个问题,它会复用正在进行的刷新 Promise。
14.4 安全性思考
- access_token 是 Bearer Token:谁拿到它,谁就能在有效期内冒充用户。因此绝不能泄露。
- refresh_token 更敏感:泄露后可以长期换取新的
access_token,危害更大。且它是一次性的,管理不当会导致整个系统失效。 - anon key 可以公开:它只是项目标识,不能用于冒充用户。
- JWT 只能解码查看,不能篡改:签名会校验,修改 Payload 会导致验签失败。
- HTTPS 必不可少:防止 Token 在传输中被窃取。
14.5 工程实践中的最佳做法
| 实践 | 说明 |
|---|---|
使用 supabase-js |
自动处理存储、刷新、并发 |
启用 autoRefreshToken |
避免手动管理刷新逻辑 |
| 设置合理的 JWT 有效期 | 默认 1 小时通常足够,可根据安全需求调整 |
| 启用 refresh token rotation | 每次刷新生成新的 refresh_token,旧的失效 |
| 监控 Token 刷新失败 | 及时提示用户重新登录 |
| 不要在前端日志中打印完整 Token | 避免泄露 |
| refresh_token 只由服务端管理 | 前端只持有短期 access_token |
| 分布式刷新加锁 | Redis SET NX EX 避免多实例竞争 |
使用 expires_at 判断过期 |
比 expires_in 更稳定 |
14.6 这套设计的精妙之处
- 分层降级:六层链每一层都有明确职责,从最快到最可靠,最后兜底告警,是分布式系统的教科书式设计。
- refresh_token 单点管理:利用 refresh_token 的”一次性”特性,反推必须由单一权威来源管理,天然避免了多端竞争。
- 前端只推 access:油猴脚本不推 refresh 的决策,体现了对 Supabase rotation 机制的深刻理解。
- 可观测性:Bark 告警不是可有可无的装饰,而是把”token 失效”视为 P0 事件。
14.7 风险与改进建议
.env.local写入 bug:建议立即修正为$NEW_REFRESH。- Redis 读取判断 bug:应判断解析后的
$REFRESH,而非$READ_RESULT。 - 缺分布式锁:多实例并发刷新会导致 refresh_token 失效,建议加 Redis 锁。
- 本地调试体验:六层链在本地开发时可能触发不必要的告警,建议加环境判断。
- 安全边界:油猴脚本权限较大,应确保只在可信环境安装。
- 备份策略:
.env.local覆盖前应先备份。
十五、总结
本文以 TryBox 应用为案例,系统性地梳理了 JWT 与 Supabase 认证机制的核心知识点:
- JWT 结构:
header.payload.signature,Base64URL 编码,非加密,可解码查看但不可篡改。 - Supabase 签发证据:
iss指向 Supabase 项目,字段符合 Supabase Auth 规范。 - 关键字段:
exp、iat、amr.timestamp、session_id均由服务器生成,使用 Unix UTC 时间戳。 - 认证流程:客户端向 Supabase Auth 发起 OTP 登录,获取
access_token和refresh_token。 - Token 动态性:登录时确定会话,但
access_token会定期刷新,session_id通常不变。 - access_token 即完整 JWT:拿到
access_token就等于拿到完整 JWT。 - 过期与续期:
access_token默认 1 小时过期,用refresh_token刷新;refresh_token 是一次性的(rotation)。 - 六层降级链:内存 → 用户配置 → Redis → refresh 刷新 → 旧 access 刷新 → Bark 告警,是分布式系统的核心设计。
- 双写策略:Redis(权威)+ .env.local(备份)+ 内存(缓存),并发现潜在 bug。
- 前端采集:油猴脚本重写
localStorage.setItem钩子,只推 access 不推 refresh。 - 可观测性:Bark 告警 + 诊断 stream,失败时主动通知而非静默。
- 工程实践:使用
supabase-js导入 session,启用自动刷新,用单例锁保护并发刷新,实现持续获取 stream。 - 难点思考:无状态与撤销的权衡、并发刷新、refresh_token 单点问题、分布式锁、安全性最佳实践。
