Використання входу SIWX із Supabase Row Level Security (RLS)
Більшість Web3-додатків зберігають користувацькі дані в базі Supabase і самостійно реалізують авторизацію: підпис довільного повідомлення через signMessage, перевірку підпису в кастомному API-роуті та ручне створення запису користувача в системній таблиці Supabase Auth.
Цей посібник демонструє набагато надійніший підхід без створення користувачів у Supabase Auth: Supabase довіряє короткоживучим токенам із нашого посібника із зовнішньої авторизації SIWX, а механізм Row Level Security (RLS) на рівні ядра PostgreSQL гарантує доступ виключно до даних поточного гаманця.
Архітектура та принцип роботи
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 чи Supabase Web3 Auth?
| Критерій | Supabase Web3 Auth | SIWX + сторонній провайдер JWKS |
|---|---|---|
| Де живуть користувачі | У таблиці auth.users Supabase | У ваших сесіях SIWX; Supabase лише перевіряє JWT |
| Підтримувані мережі | Окремо Ethereum та Solana | EVM та Solana в єдиному CAIP-122 потоці |
| Смарт-гаманці (Safe, AA) | Не підтримуються офіційно | EIP-1271 та ERC-6492 з перевіркою в ончейні |
| У правилах RLS | auth.uid() | auth.jwt() ->> 'sub' |
Перед початком
- Додаток з посібника Full-Stack React, з маршрутами SIWX та
src/lib/authStores.ts. - Ключ підпису JWT у змінній
SIWX_JWT_PRIVATE_KEY. - Проект Supabase: URL та публічний ключ у змінних
NEXT_PUBLIC_SUPABASE_URLтаNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. - Публічний HTTPS URL для завантаження JWKS.
pnpm add @supabase/supabase-jsКрок 1: Додавання клейму ролі в конфігурацію SIWX
PostgreSQL у Supabase визначає права доступу на базі клейму role: для авторизованих користувачів потрібно передавати authenticated. Задайте це у налаштуваннях вашого SIWX-обробника:
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: Реєстрація JWKS у консолі Supabase
Зареєструйте URL публічних ключів додатку через Management API Supabase:
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" }'Під час локальної розробки можна безпосередньо передати публічні ключі через параметр custom_jwks:
curl "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth/third-party-auth" \ -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"Крок 3: Створення таблиці з Row Level Security
Клейм sub зберігає ідентифікатор гаманця. Застосуйте міграцію:
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
Параметр accessToken автоматично надає актуальний токен для кожного запиту:
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: Читання та запис рядків
Компонент нотаток для поточного гаманця:
'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 може безпечно взаємодіяти з базою від імені сесії гаманця:
'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 зчитує з токена
| Клейм | Використання |
|---|---|
role | Postgres роль запиту: authenticated |
sub | Адреса гаманця для політик RLS (auth.jwt() ->> 'sub') |
caip10 | Повний CAIP-10 ідентифікатор гаманця |
chain_id | Мережа авторизації в стандарті CAIP-2 |
exp | Час життя токена (Supabase відхиляє прострочені) |
🌐 EVM та Solana разом
Одна таблиця обслуговує обидві мережі: sub починається з eip155: або solana:. Якщо потрібно об'єднати рядки користувача між різними гаманцями, прив'яжіть єдиний внутрішній userId через sessionStore.bindSubject(id, userId).
Безпека та ротація ключів
- Вмикайте RLS для кожної таблиці: Без цього будь-який автентифікований запит отримає доступ до всіх даних.
- Supabase довіряє підпису: Зберігайте
SIWX_JWT_PRIVATE_KEYвиключно на сервері. - Ротація займає до 30 хвилин: При ротації залишайте старий ключ у
previousKeysщонайменше на 30 хвилин.
Локальна розробка
Для локального тестування використовуйте публічний тунель або передайте безпосередньо вміст ключів через параметр custom_jwks у запиті Management API.