SIWX 与 Supabase 深度集成:基于 RLS 行级安全的去中心化鉴权
传统 Web3 方案通常在应用层自行编写钱包签名验证,并在 Supabase Auth 的 auth.users 表中人工写入假邮箱或用户行。这种做法破坏了多链一致性,且在处理智能合约账户或跨系统统一认证时极易出现断层。
本指南提供更纯粹的原生集成架构:Supabase 直接信任由 SIWX 签发的短时效非对称 JWT,并在 PostgreSQL 内核级别通过 Row Level Security (RLS) 行级策略保障数据物理隔离:
路由配置步骤提供 EVM 与 Solana 切换选项卡。数据库 RLS 部分跨链完全统一。
架构原理与数据流转
Wallet ──signs CAIP-122──▶ POST /api/siwx/verify ──▶ session cookieBrowser ──cookie──▶ GET /api/siwx/token ──▶ JWT (ES256, role: authenticated, 10 minutes)supabase-js ──Authorization: Bearer <JWT>──▶ SupabaseSupabase ──keys of /api/siwx/jwks──▶ checks the signature ──▶ Postgres role `authenticated`RLS policies ──auth.jwt() ->> 'sub'──▶ rows of this wallet only方案对比:SIWX 第三方鉴权 vs Supabase 原生 Web3 登录
| 特性维度 | Supabase 原生 Web3 登录 | SIWX + 第三方 JWKS 授权架构 |
|---|---|---|
| 用户实体存储位置 | 强依赖 Supabase Auth 用户中心表 | 保留在自有 SIWX 会话;Supabase 仅充当无状态验签节点 |
| 跨链协议一致性 | 以太坊与 Solana 逻辑分裂 | 基于 CAIP-122 标准,多链采用统一交互协议 |
| 智能合约钱包 (Safe/AA) | 官方文档未予支持 | 原生支持 EIP-1271 与 ERC-6492 链上验签 |
| RLS 策略权限函数 | auth.uid() | auth.jwt() ->> 'sub' |
开始之前
- 全栈 React 指南中的应用,包含 SIWX 路由配置及
src/lib/authStores.ts。 - JWT 签名密钥:已写入环境变量
SIWX_JWT_PRIVATE_KEY。 - Supabase 项目:已配置
NEXT_PUBLIC_SUPABASE_URL与NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY。 - 公网 HTTPS 域名,供 Supabase 拉取 JWKS 公钥。
pnpm add @supabase/supabase-js步骤 1:为 JWT 载荷注入角色声明 (role)
Supabase 要求 JWT 必须包含 role: "authenticated" 声明以匹配 Postgres 授权角色:
src/app/api/siwx/[...siwx]/route.ts
import type { EvmVerifyClient } from '@tuwaio/evm-sdk/siwx';import { importSiwxJwtKey } from '@tuwaio/sdk/siwx/server';import { createSiwxApiHandler } from '@tuwaio/sdk/siwx/server-next';import { createPublicClient, http } from 'viem'; import { appChains } from '@/configs/appConfig';import { nonceStore, sessionStore } from '@/lib/authStores'; const appUrl = new URL(process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'); const clients = new Map<number, EvmVerifyClient>( appChains.map((chain) => [chain.id, createPublicClient({ chain, transport: http() })]),); export const { GET, POST, DELETE } = createSiwxApiHandler({ sessionStore, nonceStore, policy: { expectedDomain: appUrl.host, expectedUri: appUrl.origin, requireExpirationTime: true, maxIssuedAtAgeSeconds: 300, }, verifyOptions: { publicClient: (chainId) => clients.get(chainId) }, jwt: { signingKey: importSiwxJwtKey({ privateKey: process.env.SIWX_JWT_PRIVATE_KEY ?? '' }), issuer: appUrl.origin, audience: 'authenticated', // Supabase runs the query as the Postgres role named in `role`; without it, as `anon`. claims: () => ({ role: 'authenticated' }), },});步骤 2:在 Supabase 控制台登记第三方 JWKS
通过 Supabase Management API 注册公钥端点:
curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth/third-party-auth" \ -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "jwks_url": "https://<your app>/api/siwx/jwks" }'本地开发时也可直接上传静态公钥集:
curl "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth/third-party-auth" \ -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"步骤 3:编写 Postgres 行级安全防御策略 (RLS)
在数据库迁移脚本中通过 auth.jwt() ->> 'sub' 直接匹配操作者的钱包地址:
supabase/migrations/20261009000000_wallet_notes.sql
create table public.notes ( id bigint generated always as identity primary key, wallet text not null default (auth.jwt() ->> 'sub'), body text not null check (char_length(body) <= 500), created_at timestamptz not null default now()); create index notes_wallet_idx on public.notes (wallet); alter table public.notes enable row level security; -- New Supabase tables are not exposed to the Data API until you grant them.grant select, insert, update, delete on public.notes to authenticated; create policy "Wallets read their notes" on public.notes for select to authenticated using (wallet = (select auth.jwt() ->> 'sub')); create policy "Wallets add their notes" on public.notes for insert to authenticated with check (wallet = (select auth.jwt() ->> 'sub')); create policy "Wallets edit their notes" on public.notes for update to authenticated using (wallet = (select auth.jwt() ->> 'sub')) with check (wallet = (select auth.jwt() ->> 'sub')); create policy "Wallets delete their notes" on public.notes for delete to authenticated using (wallet = (select auth.jwt() ->> 'sub'));步骤 4:初始化 supabase-js 客户端并绑定 Token
在客户端利用内置钩子为每一次 API 调用注入经过加密验证的短时效 Bearer Token:
src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'; type SiwxToken = { token: string; expiresAt: number }; // Realtime asks for the token on every heartbeat (25 seconds), so refresh a minute before it expires.const REFRESH_BEFORE_MS = 60_000; let current: SiwxToken | null = null;let pending: Promise<SiwxToken | null> | null = null; async function fetchSiwxToken(): Promise<SiwxToken | null> { const res = await fetch('/api/siwx/token', { cache: 'no-store' }); return res.ok ? ((await res.json()) as SiwxToken) : null;} /** The SIWX JWT of the signed-in wallet, or `null` without a sign-in (Supabase then uses the `anon` role). */export async function getSiwxAccessToken(): Promise<string | null> { if (current && current.expiresAt - Date.now() > REFRESH_BEFORE_MS) return current.token; pending ??= fetchSiwxToken().finally(() => { pending = null; }); current = await pending; return current?.token ?? null;} /** Drops the cached token: call it when the wallet signs out or another wallet signs in. */export function forgetSiwxAccessToken(): void { current = null;} export const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, { accessToken: getSiwxAccessToken },);步骤 5:读写数据行
创建与当前登录钱包绑定的便签组件:
src/components/WalletNotes.tsx
'use client'; import { useSiwxSession } from '@tuwaio/sdk/siwx';import { type FormEvent, useCallback, useEffect, useState } from 'react'; import { forgetSiwxAccessToken, supabase } from '@/lib/supabase'; type Note = { id: number; body: string; created_at: string }; export function WalletNotes() { const { isAuthenticated, session } = useSiwxSession(); const [notes, setNotes] = useState<Note[]>([]); const loadNotes = useCallback(async () => { const { data, error } = await supabase .from('notes') .select('id, body, created_at') .order('created_at', { ascending: false }); if (error) throw error; setNotes(data); }, []); // A sign-out or another wallet needs a new token and its own rows. useEffect(() => { forgetSiwxAccessToken(); if (isAuthenticated) void loadNotes(); }, [isAuthenticated, session?.address, loadNotes]); async function addNote(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = event.currentTarget; const body = String(new FormData(form).get('body') ?? '').trim(); if (!body) return; // `wallet` is filled in by the database from the token. const { error } = await supabase.from('notes').insert({ body }); if (error) throw error; form.reset(); await loadNotes(); } if (!isAuthenticated) return <p>Sign in with your wallet to see your notes.</p>; return ( <section> <form onSubmit={addNote}> <input name="body" placeholder="A note only this wallet can read" maxLength={500} /> <button type="submit">Save</button> </form> <ul> {notes.map((note) => ( <li key={note.id}>{note.body}</li> ))} </ul> </section> );}步骤 6:通过 Server Action 代表钱包发起查询
Server Action 无需使用绕过 RLS 的 service role 密钥,直接通过会话签署短期 Token:
src/app/actions.ts
'use server'; import { createClient } from '@supabase/supabase-js';import { getSiwxServerSession, importSiwxJwtKey, signSiwxJwt } from '@tuwaio/sdk/siwx/server';import { cookies } from 'next/headers'; import { sessionStore } from '@/lib/authStores'; const appUrl = new URL(process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000');const signingKey = importSiwxJwtKey({ privateKey: process.env.SIWX_JWT_PRIVATE_KEY ?? '' }); /** A Supabase client that acts as the wallet signed in on this request, under the same RLS policies. */async function supabaseForSession() { const session = await getSiwxServerSession({ cookieSource: await cookies(), sessionStore }); if (!session) throw new Error('Unauthorized'); const { token } = await signSiwxJwt({ session, key: await signingKey, issuer: appUrl.origin, audience: 'authenticated', claims: { role: 'authenticated' }, }); return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, { accessToken: async () => token, });} export async function countMyNotes(): Promise<number> { const supabase = await supabaseForSession(); const { count, error } = await supabase.from('notes').select('*', { count: 'exact', head: true }); if (error) throw error; return count ?? 0;}Supabase 读取的 Token 声明
| 声明 (Claim) | 用途说明 |
|---|---|
role | 映射 Postgres 角色:authenticated |
sub | RLS 策略比对的钱包标识 (auth.jwt() ->> 'sub') |
caip10 | 全限定 CAIP-10 钱包标识 |
chain_id | 签名时所在的 CAIP-2 链标识 |
exp | 有效期截止时间戳 |
🌐 EVM 与 Solana 双链协同
单张表同时支持两条主链:sub 自动携带 eip155: 或 solana: 前缀。若需将同一用户的多链钱包聚合至相同数据视图,可通过 sessionStore.bindSubject(id, userId) 绑定统一的用户 ID。
安全防护与密钥轮换
- 务必开启每张表的 RLS: 未开启 RLS 的表将暴露给所有已认证用户。
- 保管好私密密钥:
SIWX_JWT_PRIVATE_KEY严禁外泄。 - 轮换需保留缓冲时间: Supabase 缓存 JWKS 约 30 分钟,轮换时需保持旧密钥共存。
本地开发注意项
本地测试建议使用公网隧道(如 ngrok)或通过 Management API 直接上传 custom_jwks 进行静态配置。
📚 下一步探索
最后更新于