Auth — passwordless code login + session discipline
Passwordless email-code login (customer-accounts card, Shopify Customer
Account API direction). Two calls: request emails a 6-digit code,
verify exchanges it for a session. Password login
(supabase.auth.signInWithPassword client-side) stays available and yields
an equivalent JWT — nothing here is needed for it.
The session discipline (one rule)
verify mints a real Supabase session. Its access_token is the SAME
authorization: Bearer <jwt> every /api/store/* route accepts — wire it
into StorefrontClient once and every customer surface (customers/me,
orders, documents, cart attach) is authenticated:
// new StorefrontClient({ baseUrl, clientId, getAuthToken: () => storedAccessToken })Persist access_token + refresh_token; refresh client-side with the
Supabase SDK (setSession → refreshSession) — the Cartbase API does not
proxy token refresh. expires_in is seconds; expires_at epoch-seconds.
Server-enforced security posture (the SDK adds nothing)
- Request NEVER reveals whether the email is registered —
{ok: true}always (no enumeration oracle). The code exists ONLY in the sent email. - Codes: hashed at rest, TTL 10 min, single-use, one live code per email (a new request supersedes prior codes), 5-wrong-attempt lockout.
- Rate limits per 15-min window: 5 requests per email, 20 per IP →
429 rate_limited— the only distinguishable request failure. - Every verify failure — wrong code, expired, consumed, locked out, unknown
email — is the SAME
401 invalid_code.
POST /api/store/auth/code/request — step 1
- Purpose: email a 6-digit one-time login code. First-time emails register lazily at VERIFY, not here.
- Auth: anon (
x-client-idonly). - Request:
{email: string}. - Response:
{ok: true}— always, by design. - Errors:
429 rate_limited·400 validation_failed(malformed email) ·400 missing_client_id. - SDK:
auth.requestLoginCode(client, {email}) - Components: login form (account pages family).
- Settings: the
auth-codenotification template carries the code.
# The request contract: {ok:true}, nothing else — no code echo, no
# registered-or-not oracle. RUN-stamped email → no rate-limit collisions.
curl -sf -X POST "$BASE/api/store/auth/code/request" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"email": "doc-auth-'$RUN'@doc.test"}' \
| grep -q '^{"ok":true}$'POST /api/store/auth/code/verify — step 2
- Purpose: exchange (email, code) for a session. This is ALSO
passwordless registration — a first-time email lazy-creates the
customer (
account_statusper store policy) and triggers the welcome email. - Auth: anon (
x-client-idonly). - Request:
{email: string, code: string}— code is exactly 6 digits (leading zeros count; any other shape →400 validation_failed). - Response:
{
"access_token": "eyJ…", // the Bearer JWT for every store route
"refresh_token": "…",
"expires_in": 3600, // seconds
"expires_at": 1789300000, // epoch seconds (may be absent)
"token_type": "bearer",
"customer": { /* full customer + addresses — see customers.md */ }
}- Errors:
401 invalid_code(every auth failure, generic) ·400 validation_failed·400 missing_client_id. - SDK:
auth.verifyLoginCode(client, {email, code}) - Components: code-entry form (account pages family).
- Settings: store approval policy (new-customer
account_status);account-welcometemplate.
# The 401 invalid_code contract — a well-formed but wrong code against the
# RUN-stamped email (its real code lives only in the email we never read).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST "$BASE/api/store/auth/code/verify" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"email": "doc-auth-'$RUN'@doc.test", "code": "000000"}')
test "$STATUS" = 401
curl -s -X POST "$BASE/api/store/auth/code/verify" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"email": "doc-auth-'$RUN'@doc.test", "code": "000000"}' \
| grep -q '"code":"invalid_code"'Cleanup note: the code rows this doc creates are single-per-run (RUN-stamped email), superseded on any later request and dead after the 10-minute TTL — there is no store-surface delete for them by design (they are the auth trail). The happy-path session mint is exercised with a real inbox-free code by
tests/store/customer-accounts-auth.test.tsand the SDK contract test (server-side code issue via the lib).