Flow
1
Nonce
siwe-nonce issues a single-use nonce. The siwe_nonces table has explicit service_role ALL ALLOW and anon/authenticated ALL DENY policies.2
Sign
The wallet signs an EIP-4361 message. Role hints and referral codes travel as
resource lines — cc:role-hint:partner, cc:ref:<CODE>.3
Verify
siwe-verify validates the signature, resolves roles, screens merchant wallets for sanctions, and mints a JWT. Sanctioned wallets get 403 wallet_blocked and no session.4
Session
The JWT is set as an
httpOnly cc_session cookie, valid 24 hours. Never readable from JavaScript.5
Rehydrate
On mount,
siwe-whoami probes the cookie and echoes the raw JWT so the SPA can call supabase.auth.setSession.JWT shape
verifyWalletJwt accepts three shapes for backward compatibility: the multi-role array, a parallel-session intermediate with a singular wallet_role, and the legacy V1 shape where role held the wallet role.
The signing secret is vault.secrets.SUPABASE_JWT_SECRET, read at function boot via the read_secret RPC so SIWE JWTs are HMAC-aligned with the key PostgREST validates against. Without this, RLS-gated reads fail with PGRST301 “No suitable key”.
Multi-role wallets
A single wallet can hold any subset of merchant, partner, and platform admin simultaneously.resolveRoles unions membership across the merchants, referrers, and platform_admins tables and stamps the result into the JWT.
The navigation renders a workspace switcher when more than one role is present, and every dashboard shows a cross-role pill offering one-click hops — all without re-signing.
Route guards
AdminRoute, ProtectedRoute, and PartnerRoute wait for a rehydrated flag before deciding redirect versus render. The flag flips when the on-mount siwe-whoami probe settles on any outcome — authenticated, unauthenticated, or network error. Without that gate, a cookie-authenticated user gets synchronously bounced to / before rehydration completes.
useIsPlatformAdmin queries PostgREST directly via fetch with the SIWE JWT as bearer, rather than through supabase-js. This sidesteps a residual race in supabase.auth.setSession where TanStack Query fires before the bearer is committed.
Origin allowlisting
siwe-verify and siwe-whoami once shipped localhost:5173,localhost:8080 inside their default allowlists, and production never overrode them. A page on those ports could mint a real 24-hour production session. This was reproduced live._shared/dev-origins.ts now strips loopback unless SIWE_DEV_ORIGINS=1 and SUPABASE_URL is genuinely localhost. Setting the flag on a hosted URL throws at boot. buildCorsHeaders refuses to credential-reflect loopback as a second line of defence.
CORS Allow-Headers must include sentry-trace and baggage, or Sentry’s auto-injected tracing headers fail every preflight.
Session expiry
Any/functions/v1/* response of 401 dispatches a cc:siwe-401 event. The wallet context catches it and raises a session_expired state with a calm reconnect modal rather than a silent failure.
Authenticating an agent or headless tool
There is no password fallback, so a script mints a real session from a test wallet’s private key.siwe-nonce, signs the EIP-4361 message, POSTs siwe-verify, and prints the bare JWT — which is the cc_session cookie value. It also writes /tmp/jwt-{role}-payload.json containing the full message, signature, role, address, and JWT, so a browser context can re-issue the verify call and land the cookie on the Supabase domain.
To land the cookie in Playwright, navigate to the site and then re-issue the verify call from page context with credentials: "include" and the correct Origin. Reload, and the wallet context rehydrates via siwe-whoami.
Verify RLS is working end to end:
role "merchant" does not exist means something regressed.
Minted JWTs are bearer tokens for real testnet wallets holding funds. They expire after 24 hours. Never commit them.