> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cryptocheckout.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> SIWE-only sign-in, the JWT shape PostgREST requires, multi-role wallets, and how agents authenticate.

There is no email or password anywhere. Every dashboard is SIWE-only.

## Flow

<Steps>
  <Step title="Nonce">
    `siwe-nonce` issues a single-use nonce. The `siwe_nonces` table has explicit `service_role` ALL ALLOW and anon/authenticated ALL DENY policies.
  </Step>

  <Step title="Sign">
    The wallet signs an EIP-4361 message. Role hints and referral codes travel as `resource` lines — `cc:role-hint:partner`, `cc:ref:<CODE>`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Session">
    The JWT is set as an `httpOnly` `cc_session` cookie, valid 24 hours. Never readable from JavaScript.
  </Step>

  <Step title="Rehydrate">
    On mount, `siwe-whoami` probes the cookie and echoes the raw JWT so the SPA can call `supabase.auth.setSession`.
  </Step>
</Steps>

## JWT shape

```json theme={null}
{
  "wallet_address": "0x199b…bb24",
  "role": "authenticated",
  "wallet_roles": ["merchant", "partner", "platform_admin"],
  "iat": 1234567890,
  "exp": 1234654290
}
```

<Warning>
  `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`.
</Warning>

`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

<Danger>
  `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.
</Danger>

`_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.

<Warning>
  Still outstanding: set `SIWE_ALLOWED_DOMAINS` and `SIWE_ALLOWED_ORIGINS` explicitly on the production project and redeploy those two functions.
</Warning>

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.

```bash theme={null}
ROLE={merchant|partner|platform_admin} node scripts/mint-siwe-jwt.mjs
```

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:

```bash theme={null}
JWT=$(ROLE=merchant node scripts/mint-siwe-jwt.mjs 2>/dev/null)
ANON=$(grep '^VITE_SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-)
curl -sS "$VITE_SUPABASE_URL/rest/v1/paid_events?select=tx_hash,gross_amount&order=observed_at.desc&limit=5" \
  -H "apikey: $ANON" -H "Authorization: Bearer $JWT"
```

A clean run returns rows. PGRST301 or `role "merchant" does not exist` means something regressed.

<Danger>
  Minted JWTs are bearer tokens for real testnet wallets holding funds. They expire after 24 hours. **Never commit them.**
</Danger>
