4. Verify
Five checks, in order. Each one fails differently, so stopping at the first red tells you exactly what is wrong — which is the point of doing them in sequence rather than just loading the app and hoping.
a. Log in
Open the web app and sign in with the seeded email and password.
Bounced back to /auth/login? The browser session never established. Check
SUPABASE_URL and the publishable/anon key — not the JWT signer, which login
does not use.
b. Mint and exchange a PAT
Create a token at /auth/token, then exchange it the way a headless agent
does:
curl -sS -X POST http://localhost:3000/api/auth/access-token \
-H "Authorization: Bearer pat_…"Expect JSON with access_token, expires_at, api_url, api_key, user_id.
- 500 — the signer env is unset or unreadable. The route refuses rather than minting a token no verifier would accept.
- 401 — the PAT itself did not match.
c. Confirm Supabase accepts the JWT
The previous step only proves you could sign a token. This proves it was signed the way your project verifies:
TOKEN=$(curl -sS -X POST http://localhost:3000/api/auth/access-token \
-H "Authorization: Bearer pat_…" | jq -r .access_token)
curl -sS "$SUPABASE_URL/rest/v1/documents?select=id&limit=1" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $TOKEN"[] or a row is success.
{"code":"PGRST301"} means the signer is wrong for this project — re-check
the ES256/HS256 choice against
what /auth/v1/.well-known/jwks.json actually publishes.
d. Join the private realtime channel
Start the agent and watch it appear in the web’s sessions view. Both sides join
private:{userId}:bb-agents; presence showing up on both is the pass.
CHANNEL_ERROR on subscribe means the realtime.messages policies are
missing or do not match the topic. Re-run ./scripts/setup-supabase.sh — it is
idempotent, and this is exactly what it fixes.
e. Upload an image
curl -sS -X POST "$SUPABASE_URL/storage/v1/object/images/$USER_ID/probe/1.png" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: image/png" --data-binary @some.pngThen fetch $SUPABASE_URL/storage/v1/object/public/images/$USER_ID/probe/1.png
with no headers — the bucket is public on read, private on write.
400 Unauthorized on upload means the images_own policy is missing. Note
the first path segment must equal auth.uid(), which is why the key starts with
the user id.
No S3 credentials are involved anywhere — this is Storage’s REST API with a user JWT, not the S3-compatible endpoint. Leaving the dashboard’s S3 access keys empty is correct.
Still stuck?
The in-repo reference carries the failure modes in more depth, including the
API-key generations and the Authorization-header rule that trips up
hand-rolled HTTP calls:
docs/reference/setup-supabase.md.