Skip to main content
There is no email or password anywhere. Every dashboard is SIWE-only.

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

role must be the literal "authenticated". PostgREST uses that claim to SET ROLE in Postgres, and wallet roles are not real Postgres roles — role: "merchant" fails with SQLSTATE 22023, role "merchant" does not exist. The wallet roles live in wallet_roles.
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.
Still outstanding: set SIWE_ALLOWED_DOMAINS and SIWE_ALLOWED_ORIGINS explicitly on the production project and redeploy those two functions.
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.
It POSTs 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:
A clean run returns rows. PGRST301 or 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.