WangQiFei

Keefer


思想提纯
  • 首页
  • 归档
  • 标签
  • 关于
  •     

© 2026  by  Wangqifei

JWT 与 Supabase 认证机制全解析

发布于 2026-10-13 23:10 stremio jwt 

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 在生产环境落地的完整工程实践。


目录

  1. 引言
  2. JWT 基础结构与解码原理
  3. 为什么 Authorization 头中是 Supabase 签发的 JWT
  4. JWT 中关键字段详解
  5. 客户端如何向服务端发起请求获取认证
  6. 登录后 Authorization 是否”确定”了?
  7. access_token 与完整 JWT 的关系
  8. Token 过期机制与 refresh_token 续期
  9. Token 消费的六层降级链(核心设计)
  10. 双写策略:Redis + .env.local + 内存
  11. 油猴脚本:前端侧 Token 捕获
  12. Bark 告警与可观测性
  13. 工程实践:Node.js + supabase-js 实现自动刷新与持续获取 Stream
  14. 难点剖析与思考
  15. 总结

一、引言

在现代 Web 应用的身份认证体系中,JWT(JSON Web Token)已经成为事实上的标准。本文以 TryBox 应用为案例,结合其实际请求中的 JWT 样本、Supabase Auth 认证流程、服务端降级脚本以及前端油猴捕获脚本,系统性地梳理 JWT 的原理、Supabase 签发机制、Token 生命周期管理,以及如何在工程实践中实现 Token 的自动捕获、刷新与持久化。

全文从以下维度展开:

  1. JWT 基础结构与解码原理
  2. 为什么 Authorization 头中的是 Supabase 签发的 JWT
  3. JWT 中关键字段的含义与来源(重点剖析 exp、iat、amr.timestamp、session_id)
  4. 客户端如何向 Supabase 发起认证请求并获取 Token
  5. 登录后 Authorization 是否”确定”了?—— Token 的动态性
  6. access_token 与完整 JWT 的关系
  7. Token 过期机制与 refresh_token 续期流程
  8. Token 消费的六层降级链(核心设计)
  9. 双写策略:Redis + .env.local + 内存
  10. 油猴脚本:前端侧 Token 捕获
  11. Bark 告警与可观测性
  12. 工程实践:在 Node.js 中使用 supabase-js 实现 Token 自动刷新与持续获取 Stream
  13. 难点剖析与思考
  14. 总结

二、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
2
3
4
5
{
"alg": "ES256",
"kid": "f330a998-164d-4df8-843c-1c82eec1d49a",
"typ": "JWT"
}
  • alg: ES256:使用 ECDSA P-256 非对称签名算法。
  • kid:密钥 ID,服务端据此选择对应的公钥进行验签。
  • typ: JWT:令牌类型。

2.3 Payload 解码

对第二段进行 Base64URL 解码,得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"iss": "https://saichcuwzbncuqnqhuul.supabase.co/auth/v1",
"sub": "b5640d62-f7a3-4d2a-b520-dbf28ec417b0",
"aud": "authenticated",
"exp": 1791710467,
"iat": 1791706867,
"email": "[email protected]",
"phone": "",
"app_metadata": {
"provider": "email",
"providers": ["email"]
},
"user_metadata": {
"email": "[email protected]",
"email_verified": true,
"phone_verified": false,
"sub": "b5640d62-f7a3-4d2a-b520-dbf28ec417b0"
},
"role": "authenticated",
"aal": "aal1",
"amr": [
{
"method": "otp",
"timestamp": 1791694481
}
],
"session_id": "f97b4fb4-4fa0-4546-ac47-657e534fae5c",
"is_anonymous": false
}

2.4 解码方法

Python 示例:

1
2
3
4
5
6
7
8
import base64
import json

token = "eyJhbGciOiJFUzI1NiIs..."
payload_b64 = token.split(".")[1]
payload_b64 += "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
print(json.dumps(payload, indent=2, ensure_ascii=False))

命令行一行版:

1
2
3
4
5
6
7
python3 -c "
import base64, json
t='你的JWT'
p=t.split('.')[1]
p+='='*(-len(p)%4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2, ensure_ascii=False))
"

Node.js 示例:

1
2
3
const token = "eyJhbGciOiJFUzI1NiIs...";
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
console.log(payload);

重要提示: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
2
3
4
5
6
7
8
9
10
11
12
用户浏览器
│
├── 登录请求 ──► Supabase Auth (/auth/v1/otp, /auth/v1/verify)
│ │
│ ▼
│ 签发 access_token (JWT) + refresh_token
│
└── 业务请求 ──► TryBox Server Function (/_serverFn/...)
│
▼
验证 JWT 签名与声明
(通过 Supabase JWKS 公钥)

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?

  1. 服务端无需持有 secret:TryBox 只需从 Supabase 的 JWKS 端点拉公钥,不用保管任何密钥,减少了密钥泄露面。
  2. 多服务友好:任何想验证 Supabase JWT 的服务(TryBox、第三方集成方)都可以独立拉公钥,不需要向 Supabase 索要 secret。
  3. 符合 OIDC 标准:OIDC 规范推荐非对称签名,Supabase 遵循此实践。
  4. 私钥集中管理:只有 Supabase Auth 服务持有私钥,攻击面收窄。

HS256 的遗留问题:

  • 如果你在旧项目里看到 alg: HS256,说明用的是 Supabase 的 JWT secret 对称签名。
  • 验签方必须持有 secret,secret 泄露即全线崩溃。
  • Supabase 已逐步迁移到 ES256,新项目不再暴露 HS256 的 secret。

如何识别当前项目用的是哪种算法?

看 JWT Header 的 alg 字段:

1
2
3
4
5
{
"alg": "ES256", // ← 这里
"kid": "f330a998-...",
"typ": "JWT"
}
  • ES256:非对称,用 JWKS 验签。
  • HS256:对称,用 secret 验签(旧项目)。

验证代码差异:

1
2
3
4
5
6
7
8
9
// ES256:用 JWKS 公钥验签
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://<project>.supabase.co/auth/v1/.well-known/jwks.json'));
await jwtVerify(token, JWKS, { issuer: ..., audience: 'authenticated' });

// HS256:用共享 secret 验签(旧项目,不推荐)
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(SUPABASE_JWT_SECRET);
await jwtVerify(token, secret, { issuer: ..., audience: 'authenticated' });

结论:ES256 是非对称签名,Supabase 新项目默认使用,验签方只需公钥;HS256 是对称签名,旧项目遗留,验签方需持有 secret,安全性较低。TryBox 案例中的 alg: ES256 表明它使用新式非对称签名,配合 JWKS 端点验签。

3.4 Node.js 验证 JWT 示例

使用 jose 库:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
new URL('https://saichcuwzbncuqnqhuul.supabase.co/auth/v1/.well-known/jwks.json')
);

async function verifySupabaseJWT(token) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: 'https://saichcuwzbncuqnqhuul.supabase.co/auth/v1',
audience: 'authenticated',
});
return payload;
}

注意:

  • 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
2
3
4
5
6
"amr": [
{
"method": "otp",
"timestamp": 1791694481
}
]
  • amr:Authentication Methods Reference,认证方法引用。
  • **method: “otp”**:表示用户通过 OTP(一次性密码/邮箱验证码)登录。
  • timestamp:该认证方法发生的服务器时间,Unix 时间戳,UTC。
  • 来源:Supabase Auth 在用户完成 OTP 验证时记录。
  • 与 iat 的关系:
    • amr.timestamp = 1791694481
    • iat = 1791706867
    • 差值 = 12386 秒 ≈ 3.44 小时。
    • 说明用户最初通过 OTP 登录是在约 3.44 小时前,而当前 Token 是后来刷新得到的(iat 是刷新时间)。
  • 作用:记录认证事件;用于多因素认证(MFA)判断;服务端可据此决定是否要求重新认证。
  • 变动:重新登录或重新进行 OTP 验证时,timestamp 会更新。

4.4 amr 数组可能有多个元素

amr 是数组,因为一个会话可能经历多次认证:

1
2
3
4
"amr": [
{ "method": "password", "timestamp": 1791690000 },
{ "method": "otp", "timestamp": 1791694481 }
]

多元素场景:

  • 用户先用密码登录,再完成 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
2
3
4
{
"expires_in": 3600,
"expires_at": 1791716034
}
字段 含义 类型
expires_in 有效期相对时长(秒) 相对值
expires_at 过期绝对时间戳(秒) 绝对值

expires_at = 当前时间戳 + expires_in。

JWT 里的 exp 是 UNIX 秒级时间戳,**session.expires_at** 也是秒级时间戳,两者应该一致(但如果刷新有延迟可能略有差异)。

**代码里判断 token 是否快过期应使用 expires_at**:

1
2
3
4
5
const expiresAtMs = session.expires_at * 1000;  // 注意乘以 1000 转毫秒
const remaining = expiresAtMs - Date.now();
if (remaining < 60_000) {
// 60 秒内过期,主动刷新
}

这是常见隐患:如果直接用 expires_in 计算,会随会话时长漂移;用 expires_at 更稳。


五、客户端如何向服务端发起请求获取认证

客户端获取 JWT 并不是向 TryBox 服务端请求,而是向 Supabase Auth 服务 请求。TryBox 只是信任并验证 Supabase 签发的 JWT。

5.1 整体认证流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
用户输入邮箱
│
▼
客户端 → Supabase /auth/v1/otp (发送验证码)
│
▼
用户收到验证码,输入
│
▼
客户端 → Supabase /auth/v1/verify (验证 OTP)
│
▼
Supabase 返回 access_token + refresh_token
│
▼
客户端保存 session
│
▼
客户端 → TryBox /_serverFn/... (带 Bearer access_token)
│
▼
TryBox 验证 JWT,返回播放地址

5.2 第一步:请求发送 OTP

1
2
3
4
5
6
7
8
POST https://saichcuwzbncuqnqhuul.supabase.co/auth/v1/otp
apikey: <Supabase anon key>
Content-Type: application/json

{
"email": "[email protected]",
"create_user": true
}
  • apikey:Supabase 项目的匿名公开密钥,用于标识项目,可以暴露在前端。
  • create_user: true:如果用户不存在则自动创建。

5.3 第二步:提交 OTP 验证

1
2
3
4
5
6
7
8
9
POST https://saichcuwzbncuqnqhuul.supabase.co/auth/v1/verify
apikey: <Supabase anon key>
Content-Type: application/json

{
"type": "email",
"email": "[email protected]",
"token": "123456"
}

验证成功后,Supabase 返回 session:

1
2
3
4
5
6
7
{
"access_token": "eyJhbGciOiJFUzI1NiIs...",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "v1.Mr...",
"user": { ... }
}

5.4 第三步:客户端保存 Token

前端通常把 session 保存在:

  • localStorage
  • sessionStorage
  • Cookie(取决于配置)

supabase-js 默认存在 localStorage,键名类似 sb-<project-ref>-auth-token。

5.5 第四步:调用 TryBox 接口

1
2
3
4
5
6
7
8
9
const res = await fetch('https://trybox.app/_serverFn/5a020077...', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-trybox-lang': 'zh-CN'
},
body: JSON.stringify({ ... })
});

TryBox 服务端会:

  1. 从 Authorization 头取出 JWT。
  2. 用 Supabase 项目的公钥(JWKS)验证签名。
  3. 检查 exp、iss、aud、role 等。
  4. 确认用户身份后,返回播放地址等数据。

5.5.1 TanStack Start Server Function 序列化格式解析

TryBox 使用 TanStack Start 框架,其 Server Function 的请求体不是普通 JSON,而是一种压缩序列化格式。以电视剧请求为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"t": {
"t": 10,
"i": 0,
"p": {
"k": ["data"],
"v": [
{
"t": 10,
"i": 1,
"p": {
"k": ["tmdb_id", "kind", "season", "episode"],
"v": [
{ "t": 0, "s": 299167 },
{ "t": 1, "s": "tv" },
{ "t": 0, "s": 1 },
{ "t": 0, "s": 5 }
]
},
"o": 0
}
]
},
"o": 0
},
"f": 127,
"m": []
}

字段含义解析:

字段 含义 说明
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
2
3
4
5
6
7
8
{
"data": {
"tmdb_id": 299167,
"kind": "tv",
"season": 1,
"episode": 5
}
}

为什么用这种格式?

  1. 压缩体积:相比普通 JSON,省略了引号、冒号、空格,减少网络传输量。
  2. 保留类型信息:t 字段显式标注类型,避免 JSON.parse 后的类型歧义(例如 "1" vs 1)。
  3. 防篡改:框架层可校验序列化结构,拒绝畸形请求。
  4. 流式支持:适合 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
'https://saichcuwzbncuqnqhuul.supabase.co',
'<anon key>'
)

// 1. 发送 OTP
await supabase.auth.signInWithOtp({ email: '[email protected]' })

// 2. 验证 OTP
const { data, error } = await supabase.auth.verifyOtp({
email: '[email protected]',
token: '123456',
type: 'email'
})

// 3. 拿到 access_token
const accessToken = data.session.access_token

// 4. 调用 TryBox Server Function
const res = await fetch('https://trybox.app/_serverFn/5a020077...', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-trybox-lang': 'zh-CN'
},
body: JSON.stringify({ ... })
})

六、登录后 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
2
3
登录时: Authorization: Bearer A
1 小时后刷新: Authorization: Bearer B
再 1 小时后刷新: Authorization: Bearer C

Authorization 头的值一直在变,但代表的还是同一个用户、同一个会话。

6.3 每次请求都是动态取当前 Token

supabase-js 会自动:

  1. 检查 token 是否快过期。
  2. 如果快过期,先用 refresh_token 刷新。
  3. 把最新的 access_token 返回。

所以 Authorization 头不是登录时写死的一个字符串,而是每次请求前根据当前会话状态动态生成的。

6.4 什么时候会真正”重新确定”

以下情况会生成全新的会话和 token:

  • 用户重新登录(重新输入 OTP)。
  • 用户登出后再登录。
  • 会话被服务端撤销。
  • refresh_token 失效或过期。

这时:

  • 新的 session_id 生成。
  • 新的 amr.timestamp。
  • 新的 refresh_token。
  • 新的 access_token 序列。

6.5 时间线示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
10:00 用户 OTP 登录成功
→ access_token A, refresh_token R
→ session_id = S1
→ amr.timestamp = 10:00

10:05 请求 TryBox
→ Authorization: Bearer A

10:55 access_token A 快过期
→ 用 R 刷新
→ 得到 access_token B, refresh_token R'
→ session_id 仍为 S1
→ amr.timestamp 仍为 10:00

11:00 请求 TryBox
→ Authorization: Bearer B

12:00 再次刷新
→ access_token C
→ session_id 仍为 S1

次日 用户重新登录
→ 新 session_id = S2
→ 新 amr.timestamp
→ 新 access_token D

七、access_token 与完整 JWT 的关系

7.1 access_token 就是完整 JWT

登录成功后拿到的 access_token 本身就是一个完整的 JWT,不需要再”进一步获取”。

1
2
3
4
5
6
7
{
"access_token": "eyJhbGciOiJFUzI1NiIs...",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "v1.Mr...",
"user": { ... }
}

其中:

  • 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
2
3
4
5
6
7
8
9
10
11
{
"iss": "https://saichcuwzbncuqnqhuul.supabase.co/auth/v1",
"sub": "b5640d62-...",
"aud": "authenticated", // ← 受众是资源服务器
"exp": 1791710467,
"iat": 1791706867,
"email": "[email protected]",
"role": "authenticated",
"session_id": "...",
"is_anonymous": false
}

id_token 的 Payload(典型):

1
2
3
4
5
6
7
8
9
10
11
12
{
"iss": "https://saichcuwzbncuqnqhuul.supabase.co/auth/v1",
"sub": "b5640d62-...",
"aud": "your-client-id", // ← 受众是客户端应用
"exp": 1791710467,
"iat": 1791706867,
"email": "[email protected]",
"email_verified": true,
"name": "王梓墨",
"picture": "https://...",
"nonce": "..." // ← OIDC 防重放
}

关键区别:

  1. aud 不同:

    • access_token 的 aud 是 authenticated(资源服务器受众)。
    • id_token 的 aud 是客户端应用 ID(client_id)。
  2. 用途不同:

    • access_token 用于授权:证明”你有权访问 TryBox”。
    • id_token 用于认证:证明”你是谁”。
  3. 接收方不同:

    • access_token 发给 TryBox 服务端。
    • id_token 只在前端使用,不发给服务端。
  4. 包含信息不同:

    • 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。

如何区分你拿到的是哪个?

  1. 看 aud 字段:

    • authenticated → access_token。
    • 客户端 ID → id_token。
  2. 看使用位置:

    • Authorization: Bearer 头 → access_token。
    • 前端解析显示用户信息 → id_token。
  3. 看返回来源:

    • 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
2
3
GET https://saichcuwzbncuqnqhuul.supabase.co/auth/v1/user
Authorization: Bearer <access_token>
apikey: <anon key>

调用 TryBox 业务接口:

1
2
POST https://trybox.app/_serverFn/...
Authorization: Bearer <access_token>

八、Token 过期机制与 refresh_token 续期

8.1 access_token 会过期

access_token 会过期,这是 JWT 的固有设计,也是安全机制的一部分。案例中:

1
2
"iat": 1791706867
"exp": 1791710467

差值正好是 3600 秒(1 小时),所以它 1 小时后就会失效。失效后,服务端会拒绝请求,通常返回 401 Unauthorized。

8.2 refresh_token 是一次性的(重要修正)

这是全文最容易被误解、也最关键的修正点。

你的油猴脚本里有一条极其重要的注释:

1
2
3
// refresh 为一次性token,使用一次及失效,使用端在使用时会获取并推送新的值,
// 这里不能推送的原因是会把失效的token覆盖其他服务推送的新refreshtoken
// ['SET', `${APP_PREFIX}:token:refresh`, refreshToken],

这是 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
2
3
4
5
6
7
POST https://saichcuwzbncuqnqhuul.supabase.co/auth/v1/token?grant_type=refresh_token
apikey: <anon key>
Content-Type: application/json

{
"refresh_token": "v1.Mr..."
}

Supabase 返回新的 session:

1
2
3
4
5
6
{
"access_token": "新的JWT",
"refresh_token": "新的refresh_token",
"expires_in": 3600,
"user": { ... }
}

然后客户端用新的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
请求到达
│
├─ 1. 内存缓存 → access 有效?
│ ├─ 是 → 直接用
│ └─ 否 → 往下
│
├─ 2. 用户配置的 access_token → 有效?
│ ├─ 是 → 缓存到内存 + 用
│ └─ 否 → 往下
│
├─ 3. 读 Redis → access 有效?
│ ├─ 是 → 缓存到内存 + 用
│ └─ 否 → 往下
│
├─ 4. 用 refresh_token 刷新 Supabase
│ ├─ 成功 → 写内存 + 写 Redis + 用
│ └─ 失败 → 往下
│
├─ 5. 用旧 access_token 刷新(降级)
│ ├─ 成功 → 写内存 + 写 Redis + 用
│ └─ 失败 → 往下
│
└─ 6. Bark 告警 → 返回空 token → 触发诊断 stream

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
2
3
4
5
awk -v ac="$NEW_ACCESS" -v re="$REFRESH" '
/^ACCESS_TOKEN=/ { print "ACCESS_TOKEN=\"" ac"\""}
/^REFRESH_TOKEN=/ { print "REFRESH_TOKEN=\"" re"\""; next }
{ print }
' .env.local > .env.local.tmp && mv .env.local.tmp .env.local

更新 Redis:

1
2
3
4
5
6
7
WRITE_RESULT=$(curl -s "$UPSTASH_REDIS_REST_URL/pipeline" \
-H "Authorization: Bearer $UPSTASH_REDIS_REST_TOKEN" \
-H "Content-Type: application/json" \
-d "[
[\"SET\",\"$APP_PREFIX:token:refresh\",\"$NEW_REFRESH\"],
[\"SET\",\"$APP_PREFIX:token:access\",\"$NEW_ACCESS\"]
]")

10.2 发现一个潜在 bug

注意这里的一个细节(也可能是 bug):

  • access_token 写入的是 $NEW_ACCESS(刷新后的新值)✅
  • refresh_token 写入的是 $REFRESH(刷新前的旧值)❌

这看起来是个潜在 bug:因为 Supabase 返回了 NEW_REFRESH,但 .env.local 写入的是旧的 $REFRESH。而 Redis 写入的是正确的 $NEW_REFRESH。

可能的原因:

  1. 作者有意为之:.env.local 只作为本地参考,真实 refresh_token 以 Redis 为准。
  2. 疏忽:忘记改成 $NEW_REFRESH。

建议修正:

1
2
3
4
5
awk -v ac="$NEW_ACCESS" -v re="$NEW_REFRESH" '
/^ACCESS_TOKEN=/ { print "ACCESS_TOKEN=\"" ac"\""}
/^REFRESH_TOKEN=/ { print "REFRESH_TOKEN=\"" re"\""; next }
{ print }
' .env.local > .env.local.tmp && mv .env.local.tmp .env.local

同时,脚本中 Redis 写入用 $NEW_REFRESH 是正确的。

10.3 另一处健壮性问题

脚本对 READ_RESULT 的判断有 bug:

1
2
3
4
if [ -z "$READ_RESULT" ]; then
echo "❌ Redis 中没有 refresh_token"
exit 1
fi

这个判断不充分。READ_RESULT 即使 Redis 里没值也会是 [{"result":null}],不是空字符串。正确判断应该是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
REFRESH=$(echo "$READ_RESULT" | python3 -c "
import json, sys
d = json.load(sys.stdin)
v = d[0]['result']
if v is None:
print('')
else:
try:
print(json.loads(v))
except:
print(v)
")

if [ -z "$REFRESH" ]; then
echo "❌ Redis 中没有 refresh_token"
exit 1
fi

即:应该判断解析后的 $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
2
3
4
5
6
7
8
9
10
11
12
const originalSetItem = localStorage.setItem;

localStorage.setItem = function (key, value) {
originalSetItem.call(localStorage, key, value); // 先执行原逻辑

if (key === TARGET_KEY) { // 'sb-trybox-auth-token'
console.log('🔔 捕获到 sb-trybox-auth-token 更新!');
const authObj = JSON.parse(value);
const { access_token, refresh_token } = authObj;
handleToken(access_token, refresh_token, 'auto');
}
};

11.2 设计意图

  1. 被动捕获:Supabase JS SDK 每次登录/刷新都会调用 localStorage.setItem('sb-trybox-auth-token', JSON),钩子自动捕获。
  2. 自动推送:AUTO_PUSH = true 时,捕获后立即推送到 Upstash。
  3. 绕过 CORS:用 GM_xmlhttpRequest 而非 fetch,因为油猴脚本的跨域权限更宽松。
  4. 兜底手动按钮:悬浮按钮 Get Trybox Tokens,用户可以手动触发一次读取。
  5. 不推 refresh:避免覆盖其他服务推送的新 refresh_token。

11.3 关键代码:只推送 access_token

1
2
3
4
5
6
const body = JSON.stringify([
['SET', `${APP_PREFIX}:token:access`, accessToken],
// refresh 为一次性token,使用一次及失效,使用端在使用时会获取并推送新的值,
// 这里不能推送的原因是会把失效的token覆盖其他服务推送的新refreshtoken
// ['SET', `${APP_PREFIX}:token:refresh`, refreshToken],
]);

这是一个”客户端采集 + 服务端集中管理”的经典架构。适合:

  • 用户端不方便长期运行服务进程。
  • 想让多个后端服务共享同一份 token。
  • 需要”借用户浏览器”作为 token 获取渠道。

11.4 手动兜底读取

1
2
3
4
5
6
7
8
9
10
function getCurrentToken() {
const rawStr = localStorage.getItem(TARGET_KEY);
if (!rawStr) {
showToast('❌ 当前 localStorage 无 ' + TARGET_KEY, true);
return;
}
const authObj = JSON.parse(rawStr);
const { access_token, refresh_token } = authObj;
handleToken(access_token, refresh_token, 'manual');
}

配合悬浮按钮:

1
2
3
4
5
const btn = document.createElement('button');
btn.innerText = 'Get Trybox Tokens';
// ...样式...
document.body.appendChild(btn);
btn.onclick = getCurrentToken;

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 等信息,希望:

  1. 使用 @supabase/supabase-js 初始化客户端。
  2. 将已有的 session 导入。
  3. 实现自动刷新 Token。
  4. 持续获取 TryBox 的 stream 数据。

13.2 安装依赖

1
npm install @supabase/supabase-js

在 package.json 中添加 "type": "module" 以消除 ESM 警告:

1
2
3
4
5
6
7
{
"name": "trybox",
"type": "module",
"dependencies": {
"@supabase/supabase-js": "^2.x"
}
}

或者把文件重命名为 author.mjs,显式声明 ESM。

13.3 完整实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
// author.js
import { createClient } from '@supabase/supabase-js';

// ========== 配置区 ==========
const SUPABASE_URL = 'https://saichcuwzbncuqnqhuul.supabase.co';
const SUPABASE_ANON_KEY = '你的Supabase anon key';

// 已有的 session JSON
const sessionJson = {
"access_token": "",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 1791716034,
"refresh_token": "axmnt7pfwkrx",
"user": {
"id": "b5640d62-f7a3-4d2a-b520-dbf28ec417b0",
"aud": "authenticated",
"role": "authenticated",
"email": "[email protected]",
"email_confirmed_at": "2026-10-10T04:29:48.976394Z",
"phone": "",
"confirmed_at": "2026-10-10T04:29:48.976394Z",
"recovery_sent_at": "2026-10-11T09:53:37.334373Z",
"last_sign_in_at": "2026-10-11T09:53:54.292465767Z",
"app_metadata": {
"provider": "email",
"providers": ["email"]
},
"user_metadata": {
"email": "[email protected]",
"email_verified": true,
"phone_verified": false,
"sub": "b5640d62-f7a3-4d2a-b520-dbf28ec417b0"
},
"identities": [
{
"identity_id": "bfcd2acd-4262-439e-8b1c-58955a5c535d",
"id": "b5640d62-f7a3-4d2a-b520-dbf28ec417b0",
"user_id": "b5640d62-f7a3-4d2a-b520-dbf28ec417b0",
"identity_data": {
"email": "[email protected]",
"email_verified": false,
"phone_verified": false,
"sub": "b5640d62-f7a3-4d2a-b520-dbf28ec417b0"
},
"provider": "email",
"last_sign_in_at": "2026-10-10T04:29:48.970701Z",
"created_at": "2026-10-10T04:29:48.97076Z",
"updated_at": "2026-10-10T04:29:48.97076Z",
"email": "[email protected]"
}
],
"created_at": "2026-10-10T04:29:48.96366Z",
"updated_at": "2026-10-11T09:53:54.300029Z",
"is_anonymous": false
}
};

// ========== 初始化 Supabase ==========
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});

// ========== 导入已有 session ==========
async function loadSession() {
const { data, error } = await supabase.auth.setSession({
access_token: sessionJson.access_token,
refresh_token: sessionJson.refresh_token,
});

if (error) {
console.error('❌ 导入 session 失败:', error.message);
return false;
}

console.log('✅ Session 导入成功');
console.log('用户:', data.user?.email);
console.log('access_token 前 60 字符:', data.session?.access_token?.slice(0, 60) + '...');
return true;
}

// ========== 获取当前有效 token(带单例锁) ==========
let refreshPromise = null;

async function getValidAccessToken() {
const { data: { session }, error } = await supabase.auth.getSession();

if (error || !session) {
console.error('❌ 无有效 session:', error?.message);
return null;
}

// 用 expires_at 判断,更稳定
const expiresAtMs = session.expires_at * 1000;
const remaining = expiresAtMs - Date.now();

if (remaining > 60_000) {
return session.access_token;
}

// 已有刷新在进行中,复用同一个 Promise
if (!refreshPromise) {
console.log('⏳ Token 即将过期,主动刷新...');
refreshPromise = supabase.auth.refreshSession()
.finally(() => { refreshPromise = null; });
}

const { data, error: refreshError } = await refreshPromise;
if (refreshError) {
console.error('❌ 刷新失败:', refreshError.message);
return null;
}
return data.session.access_token;
}

// ========== 调用 TryBox 接口 ==========
async function fetchStream(tmdbId, kind, season, episode) {
const accessToken = await getValidAccessToken();
if (!accessToken) {
console.error('❌ 无法获取有效 token');
return null;
}

const body = {
t: {
t: 10,
i: 0,
p: {
k: ["data"],
v: [
{
t: 10,
i: 1,
p: {
k: ["tmdb_id", "kind", "season", "episode"],
v: [
{ t: 0, s: tmdbId },
{ t: 1, s: kind },
{ t: 0, s: season },
{ t: 0, s: episode }
]
},
o: 0
}
]
},
o: 0
},
f: 127,
m: []
};

const res = await fetch('https://trybox.app/_serverFn/5a020077e9367d29abeab1946108104517a066628ad6d94609eea11918105012', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-trybox-lang': 'zh-CN',
},
body: JSON.stringify(body),
});

if (!res.ok) {
console.error(`❌ 请求失败: HTTP ${res.status}`);
console.error(await res.text());
return null;
}

return await res.json();
}

// ========== 持续获取 Stream ==========
async function startStreamLoop() {
const ok = await loadSession();
if (!ok) return;

// 示例:获取剧集 S1E5
const data = await fetchStream(299167, 'tv', 1, 5);
if (data) {
console.log('✅ 获取 stream 成功:');
console.log(JSON.stringify(data, null, 2));
}

// 定时刷新示例:每 50 分钟刷新一次
setInterval(async () => {
console.log('🔄 定时刷新 token...');
const { data, error } = await supabase.auth.refreshSession();
if (error) {
console.error('❌ 定时刷新失败:', error.message);
} else {
console.log('✅ Token 已刷新,新 expires_at:', data.session.expires_at);
}
}, 50 * 60 * 1000);
}

// ========== 启动 ==========
startStreamLoop().catch(console.error);

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
2
3
{
"type": "module"
}

错误: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 这套设计的精妙之处

  1. 分层降级:六层链每一层都有明确职责,从最快到最可靠,最后兜底告警,是分布式系统的教科书式设计。
  2. refresh_token 单点管理:利用 refresh_token 的”一次性”特性,反推必须由单一权威来源管理,天然避免了多端竞争。
  3. 前端只推 access:油猴脚本不推 refresh 的决策,体现了对 Supabase rotation 机制的深刻理解。
  4. 可观测性:Bark 告警不是可有可无的装饰,而是把”token 失效”视为 P0 事件。

14.7 风险与改进建议

  1. .env.local 写入 bug:建议立即修正为 $NEW_REFRESH。
  2. Redis 读取判断 bug:应判断解析后的 $REFRESH,而非 $READ_RESULT。
  3. 缺分布式锁:多实例并发刷新会导致 refresh_token 失效,建议加 Redis 锁。
  4. 本地调试体验:六层链在本地开发时可能触发不必要的告警,建议加环境判断。
  5. 安全边界:油猴脚本权限较大,应确保只在可信环境安装。
  6. 备份策略:.env.local 覆盖前应先备份。

十五、总结

本文以 TryBox 应用为案例,系统性地梳理了 JWT 与 Supabase 认证机制的核心知识点:

  1. JWT 结构:header.payload.signature,Base64URL 编码,非加密,可解码查看但不可篡改。
  2. Supabase 签发证据:iss 指向 Supabase 项目,字段符合 Supabase Auth 规范。
  3. 关键字段:exp、iat、amr.timestamp、session_id 均由服务器生成,使用 Unix UTC 时间戳。
  4. 认证流程:客户端向 Supabase Auth 发起 OTP 登录,获取 access_token 和 refresh_token。
  5. Token 动态性:登录时确定会话,但 access_token 会定期刷新,session_id 通常不变。
  6. access_token 即完整 JWT:拿到 access_token 就等于拿到完整 JWT。
  7. 过期与续期:access_token 默认 1 小时过期,用 refresh_token 刷新;refresh_token 是一次性的(rotation)。
  8. 六层降级链:内存 → 用户配置 → Redis → refresh 刷新 → 旧 access 刷新 → Bark 告警,是分布式系统的核心设计。
  9. 双写策略:Redis(权威)+ .env.local(备份)+ 内存(缓存),并发现潜在 bug。
  10. 前端采集:油猴脚本重写 localStorage.setItem 钩子,只推 access 不推 refresh。
  11. 可观测性:Bark 告警 + 诊断 stream,失败时主动通知而非静默。
  12. 工程实践:使用 supabase-js 导入 session,启用自动刷新,用单例锁保护并发刷新,实现持续获取 stream。
  13. 难点思考:无状态与撤销的权衡、并发刷新、refresh_token 单点问题、分布式锁、安全性最佳实践。

Visitors

 

下一篇: TryBox Stremio Addon 开发全记录 

© 2026  by  Wangqifei