返回博客列表
技术文章Supabase微信小程序身份认证JWT

身份认证全链路 — 微信登录与 JWT 生命周期

2026年04月🇨🇳 中文

Supabase Auth 的认证模型

Supabase Auth 是一个标准的 JWT-based 无状态认证系统。服务端不存储 session,所有鉴权信息都编码在 JWT 里,每次请求时服务端通过验证 JWT 签名确认身份。

每次登录成功,Supabase 返回两个 token:

  • access_token(JWT):默认有效期 1 小时,每次 API 请求都要携带这个 token。它是一个标准的 JWT(JSON Web Token,其 Header 和 Payload 部分采用 Base64Url 编码),包含 sub(用户 UUID)、role(数据库角色)、exp(过期时间戳)等 claim。
  • refresh_token:默认有效期 7 天(可在仪表盘配置),用于在 access_token 过期后向服务器换取一组全新的 Token Pair。

双 Token 机制的工作原理

  1. 日常请求:客户端(小程序)每次向服务器发请求时,只在 HTTP Header 里带上短期的 access_token。因为 access_token 很快会过期(1小时),就算黑客截获了它,也只能用一小段时间,大幅降低了安全风险。
  2. 过期无感刷新:当 1 小时后,客户端继续发请求,服务器会返回 401 Unauthorized 报错,提示 token 过期。此时,客户端底层的拦截器会自动拿出身上的长期 refresh_token,发送专门的刷新请求给服务器。
  3. 换发与一次性机制(Token Rotation):服务器验证 refresh_token 无误后,会下发一套全新access_tokenrefresh_token划重点:Supabase 的 refresh_token 是一次性的。一旦旧的 refresh_token 被用来兑换了新的,它本身就立即作废了,客户端保存最新的即可。
  4. 防盗安全网:这种一次性设计极其精妙。万一黑客偷走了你的 refresh_token 并偷偷换取了新 token,当真正的用户再次打开小程序,用身上那个还没被替换的旧 refresh_token 试图刷新时,Supabase 就会发现异常(一个一次性的凭证怎么被使用了两次?),从而直接判定此账号存在被盗用的风险,立刻强制注销该用户所有的 Session(踢下线),要求重新进行微信登录,以此斩断黑客的后路。

为了更直观地理解,您可以参考以下的时序图:


JWT 在小程序端的存储选择

小程序有两套存储 API:同步的 wx.getStorageSync / wx.setStorageSync 和异步的 wx.getStorage / wx.setStorage

不能用 JavaScript 模块变量存储 token。 小程序在被微信切入后台一段时间后会被系统回收,内存中的 JS 变量随之消失。用户下次打开小程序时是冷启动,所有变量都被重置,用户会被强制重新登录。wx.storage 持久化到磁盘,冷启动后仍可读取,这是 token 必须存在 storage 里的根本原因。

同步 vs 异步的选择。 读取 token 是一个高频操作(每次 API 请求前都要读),异步读取意味着每次发请求前都要 await,会增加请求链路的复杂度。实践中,读 token 用同步 API(wx.getStorageSync),写 token 用同步 API(wx.setStorageSync),保持简单。写 token 是低频操作(只在登录和刷新时发生),同步写的阻塞时间可以忽略不计。

存储键命名。 推荐用统一前缀(如 sb_)管理所有 Supabase 相关的 storage key,便于集中清理(退出登录时遍历删除)和避免与其他模块冲突:sb_access_tokensb_refresh_tokensb_expires_atsb_user


Token 刷新的并发安全问题

这是整个认证层最容易出 bug 的地方,值得深入理解。

问题的来源。 微信小程序页面在 onLoad 时通常会发出多个并发请求(首页信息流、消息未读数、用户信息等)。如果这批请求发出时 access_token 恰好已过期,每个请求都会检测到过期,都会尝试用 refresh_token 换取新 token。

由于 Supabase 的 refresh_token 是一次性的,第一个刷新请求成功后,原来的 refresh_token 即失效,第二、三个刷新请求带着已失效的 refresh_token 去换取新 token,Supabase 返回 401,触发强制退出登录。

解决方案:刷新锁(Promise 复用)。tokenManager 模块中维护一个模块级变量 _refreshPromise。刷新开始时,把刷新操作赋给 _refreshPromise;刷新结束时(无论成功还是失败),把 _refreshPromise 置回 null。

当其他请求检测到 token 过期并试图刷新时,先检查 _refreshPromise 是否为 null:如果不为 null,说明刷新正在进行,直接 await _refreshPromise 等待结果,复用同一个 Promise,不发出新的刷新请求。

这个模式被称为 Promise deduplication,核心利用了 JavaScript 的 Promise 可以被多次 await 的特性——同一个 Promise 对象 resolve 后,所有等待它的 await 都会得到同样的结果。

过期检测的时间缓冲。 expires_at 是 token 的精确过期时间戳,但实践中应该提前 60 秒判断为"即将过期"。原因:网络请求有延迟,如果等到精确过期时刻才刷新,请求在传输中 token 恰好过期,服务端会返回 401。提前 60 秒刷新提供了足够的缓冲。

请求拦截器的集成位置。 Token 刷新逻辑应该在 request.js 的基础请求函数中,而不是在各个业务模块里。这样所有 API 请求都自动受到保护,业务代码无感知。拦截器的处理顺序:检查并刷新 token → 注入 Authorization Header → 发出请求。

冷启动和后台唤醒的处理。 小程序 onLaunch(冷启动)和 onShow(从后台切回)时,应该主动触发一次 refreshIfNeeded()。冷启动的刷新不需要强制跳转到登录页,即使刷新失败也静默处理,等到用户真正触发需要登录的操作时再引导。后台唤醒的刷新时机更重要——用户在后台挂了几小时,access_token 很可能已过期,在页面显示前完成刷新可以避免页面加载后立刻出现一批 401 错误。


微信登录的核心约束

Supabase Auth 原生不支持微信登录。微信的 OAuth 流程和 Google/GitHub OAuth 的核心差异在于:openid 的获取必须在服务端完成,不能在客户端直接调用

微信 jscode2session 接口需要 AppSecret,这是小程序的私钥,一旦暴露到客户端代码里,任何人都可以冒充这个小程序向微信申请任意 openid。所以微信规定:小程序端只拿 code(一次性凭证,5 分钟有效),服务端用 code + AppSecret 换取 openid

完整流程分四步:

  1. 客户端: 调用 wx.login() 获取一次性 code
  2. 客户端→服务端:code 发给自己的服务端(POST /api/wx-login
  3. 服务端→微信:code + AppSecret 调用 jscode2session,换取用户的 openid
  4. 服务端:openid 在 Supabase 中查找或创建用户,为该用户颁发 Supabase session,把 access_tokenrefresh_token 返回给客户端
  5. 客户端: 保存 token 到 wx.storage,后续请求携带 access_token

为方便理解,这整个交互闭环的时序图如下(该流程符合微信官方的登录时序安全规范):


在 Supabase 中创建微信用户

Supabase Auth 没有内置 openid 登录,需要用 service_role key 在服务端直接操作用户表。这里有一个重要的架构选择:如何将 openid 映射到 Supabase 用户

方案一:虚拟邮箱。{openid}@wx.local 作为邮箱,通过标准邮箱注册流程创建用户。优点是实现简单,完全复用 Auth 体系。缺点是邮箱字段被污染,如果以后要支持真实邮箱登录,需要处理邮箱冲突;也无法通过邮箱发通知。

方案二:独立映射表(推荐)。auth.users 中创建用户,在 profiles 表中额外存储 wx_openid 字段。登录时在 profiles 表中查 openid → 找到 user_id → 为该用户颁发 session。这种设计的好处是:以后可以把手机号登录、邮箱登录和微信登录绑定到同一个账号(一个 user_id 对应多条三方 credentials 记录),账号体系更干净。

service_role 在服务端为指定用户颁发 session 的方式:supabase.auth.admin.createSessionForUser(userId),这会返回一个标准的 access_token + refresh_token 对,与普通登录返回的格式完全一致。


小程序的认证实现:utils/auth.js

以上是认证体系的理论基础。小程序的 utils/auth.js 将这些逻辑具体落地,提供以下几个对外接口:

doWechatLogin:首次登录或主动登录

完整的微信登录流程。调用时序:

  1. wx.login() 获取 code
  2. 直接通过 wx.request 调用 Supabase Edge Function wechat-login(不经过 supabase.from(),因为此时还没有 token)。Header 中携带 apikey 但不携带 Authorization。Edge Function 部署时必须加 --no-verify-jwt 标志,允许未认证请求
  3. Edge Function 负责:code → openid(调用微信 jscode2session)→ 查找或创建用户 → 颁发 Supabase session → 返回 { access_token, refresh_token, user_id }
  4. 客户端将 token 写入 wx.storage同时调用 supabase.auth.setSession() 同步 SDK 内部状态。这一步是关键:supabase-wechat-stable-v2 用内存中的 session 管理 token 注入,只写 storage 而不调用 setSession(),SDK 不知道 token 已更新
  5. 查询 profiles 表获取用户信息,写入 app.globalData.userInfo

登录时可同时传入用户头像和昵称(nicknameavatarData)。这是微信头像授权的最佳实践——在用户主动点击「微信登录」时一并授权获取,避免多次打扰用户。

checkAuth:启动时的 token 有效性验证

app.jsonLaunch 中调用,处理冷启动场景。核心逻辑是一个层层降级的状态检查链:

注意判断逻辑的第一步兜底拦截没有本地 token 时绝不触发 silentRelogin()silentRelogin() 的本质是用 wx.login() 拿 code,再调 Edge Function,如果用户是第一次打开小程序,Edge Function 会直接为其静默注册一个新账号——这通常不是我们想要的行为。只有已知的老用户(本地有过 token)才应该在 Token 失效时去尝试静默重登。

refreshToken:用 refresh_token 续期

调用 supabase.auth.refreshSession() 向 Supabase Auth 服务换取新的 token pair,成功后同步写入 wx.storage

refreshToken() 失败时不立即清除本地 token。原因:可能是网络暂时中断,保留 token 可以在网络恢复后由 silentRelogin() 接管。只有调用方确认所有恢复手段均失败后,再统一清理。

silentRelogin:refresh_token 失效时的最后手段

refresh_token 也彻底失效时(60 天未使用、被 Supabase 主动吊销),走这条路:重新调用 wx.login() 获取新 code,再走一遍 Edge Function 流程。

为什么这能"无限续期"?微信用户的登录态(wx.login() 的成功率)通常长期有效,只要用户还在用微信,这步就能成功。而 Edge Function 拿到 openid 后,会找到已存在的用户并签发新 session——不会创建新账号,因为 profiles 表中已有这个 openid 的记录。

requireLogin / requireAuth:页面级登录守卫

  • requireLogin():同步检查(检查 wx.storageglobalData),未登录时弹出确认框提示跳转登录页,不阻塞当前页面继续加载。适合用在按钮点击回调中。
  • requireAuth():异步检查(调用 supabase.auth.getUser() 网络验证),未登录时弹出确认框并阻止页面继续加载。适合在目标页面的 onLoad 中使用,防止用户直接通过分享链接绕过登录进入需要认证的页面。

clearAuth:退出登录

清除 sb_access_tokensb_refresh_token,并调用 wx.clearStorageSync() 清除全部本地缓存,同时置空 app.globalData.userInfo不调用 supabase.auth.signOut()——该方法会向服务端发请求吊销 refresh_token,在网络不可用时可能失败;直接清除本地状态对用户来说效果一样(token 即使在服务端仍有效,也会在 1 小时后自然过期)。


自定义 JWT Claim

有时业务需要在请求中快速获取用户的角色、权限级别等信息,如果每次都去数据库查,会增加延迟。解决方案是通过 JWT Hook 在签发 token 时把这些信息直接嵌入 JWT payload。

Supabase 支持通过 PostgreSQL 函数拦截 JWT 签发过程,在 auth.users 颁发 token 前调用自定义函数,向 JWT 的 claims 字段注入额外信息。例如:

CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB LANGUAGE PLPGSQL AS $$
DECLARE
  user_role TEXT;
BEGIN
  SELECT role INTO user_role FROM public.profiles
  WHERE user_id = (event->>'user_id')::UUID;

  RETURN jsonb_set(
    event,
    '{claims,user_role}',
    to_jsonb(COALESCE(user_role, 'user'))
  );
END;
$$;

注入后,JWT payload 里就有了 user_role 字段。在 RLS 策略中可以直接读取 auth.jwt() ->> 'user_role',而不需要额外查询 profiles 表,减少了每次鉴权的数据库访问次数。

使用自定义 claim 需要注意两点:第一,claim 是颁发时的快照,如果用户角色变更,需要等当前 token 过期并刷新后才会反映新角色;第二,需要在 Supabase Dashboard 的 Auth → Hooks 中注册这个函数,并授予 supabase_auth_admin 角色执行权限。