Storefront Customer Auth
Throttle can authenticate a merchant's end buyers — the shoppers on their storefront — as a plane entirely separate from the merchant API you already use for orders, payments, and webhooks. Buyers register, log in, verify their email, reset passwords, manage addresses and saved cards, view their own orders, invoices and subscriptions, and bind an anonymous cart to their account at checkout.
auth.enabled: false until a merchant
turns it on. Guest checkout is completely unaffected either way — a customer with no
credentials is a guest by definition, and nothing about existing carts, orders, or
subscriptions changes when this feature is off.
Two planes, not two auth options on one plane
A buyer session is not a weaker version of a merchant API key, and it can never be used as one. The two are isolated structurally, not by convention:
| Merchant plane (existing) | Buyer plane (this page) | |
|---|---|---|
| Principal | Merchant staff and their servers | The merchant's own end shoppers |
| Credentials | sk_* / pk_* | accessToken (Bearer) + refreshToken |
| Namespace | /api/v1/* | /v1/storefront/* |
| Request property | request.auth | request.buyer |
Buyer routes live at a bare /v1/storefront/* —
never under /api/. A buyer access token presented to any
merchant route (/api/v1/orders, etc.) is never even
parsed; the merchant auth middleware only runs on /api/
and /mcp paths, so it never sees a storefront request in
the first place. The reverse holds too: a merchant sk_*/pk_* key cannot be
used against /v1/storefront/* — those routes require a
buyer session (or, for the credential routes below, none at all).
sequenceDiagram
participant B as Buyer Browser
participant T as Throttle
participant M as Your Backend
B->>T: POST /v1/storefront/auth/login { email, password }
note over B,T: X-API-Key: pk_* + Origin header
T-->>B: { accessToken, refreshToken, customer }
B->>T: GET /v1/storefront/me/orders
note over B,T: Authorization: Bearer accessToken
T-->>B: { data: [...] } (request.buyer, never request.auth)
M->>T: GET /api/v1/orders
note over M,T: X-API-Key: sk_* — a different plane entirely Turning it on
A merchant enables customer accounts per application, per environment, via
PATCH /api/v1/applications/{applicationId}/auth-settings
(see Customer Accounts Settings
in the API reference). Until enabled is true, every /v1/storefront/* route returns 404 auth_not_enabled — a disabled surface does not
confirm it exists.
Every request also carries the storefront's existing publishable pk_* key as X-API-Key —
the same key already used for shipping quotes and tax calculations — plus an Origin header, checked against the application's allowedOrigins list (the same list configured via PUT /api/v1/embed-config, not a separate buyer-auth
setting).
403 origin_not_allowed, naming the settings
page that fixes it. In a non-production (sandbox) environment, an empty list allows any
origin.
Tokens and sessions
Login and registration return a short-lived access token and a
longer-lived refresh token. Keep the access token in memory only — never
write it to storage — and send it as Authorization: Bearer
{accessToken} on every authenticated call.
| Token | Transport | Lifetime | Notes |
|---|---|---|---|
accessToken | Authorization: Bearer | 600 seconds | A JWT. Reads trust it on signature alone — no database round trip. |
refreshToken | Request body | 30 days sliding, 90 days absolute | Opaque. Rotates on every use; a spent one that is replayed revokes the whole session family. |
stepUpToken | X-Step-Up | 300 seconds | Proves the current password was just re-entered. Bound to one session — cannot be replayed on another device. |
The refresh token rotates on every use, and replaying a spent one
revokes the entire session family — not just the one request that used it. If
your client fires a refresh per failed request (e.g. ten concurrent 401s each triggering their own refresh call), nine of
those ten calls are replaying a token some other call already rotated, and the buyer gets
logged out. Queue concurrent refreshes behind a single in-flight call and share the
result — this is the single most important correctness detail in this API, and it is why @usethrottle/auth (below) exists: it does this for you.
The server also keeps a 10-second grace window on the winning side of a race: if a straggler presents the exact token a concurrent request just rotated away, and it does so within 10 seconds, it gets that winning rotation's token back instead of tripping reuse detection. This is a safety net for clients without single-flight refresh (two tabs, a mobile app, a plain-fetch integration) — it does not make single-flighting optional, and a replay of a spent token after the window still revokes the family exactly as above.
const res = await fetch('https://api.usethrottle.dev/v1/storefront/auth/register', {
method: 'POST',
headers: {
'X-API-Key': publishableKey, // pk_*
'Content-Type': 'application/json',
Origin: 'https://shop.example.com', // must be in allowedOrigins
},
body: JSON.stringify({ email, password }),
});
// 201 { data: { accessToken, expiresAt, refreshToken, customer } } — new account
// 202 { data: { status: 'pending' } } — email already has credentials; no session issued
// 400 { error: { code: 'weak_password', message } } {
"data": {
"accessToken": "eyJhbGciOi...",
"expiresAt": "2026-09-12T18:10:00.000Z",
"refreshToken": "rt_bGl2ZS1zZXNzaW9uLWlk.9vQ2f...",
"customer": {
"id": "cus_51...",
"email": "[email protected]",
"emailVerified": false
}
}
} // Single-flight this — see the callout below. Never fire one refresh per
// failed request; queue concurrent 401s behind one in-flight refresh call.
const res = await fetch('https://api.usethrottle.dev/v1/storefront/auth/refresh', {
method: 'POST',
headers: { 'X-API-Key': publishableKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
// 200 { data: { accessToken, expiresAt, refreshToken, customer } } — refreshToken ROTATES;
// store the new one and discard the old one immediately.
// 401 { error: { code: 'session_revoked' } } — expired, revoked, or replayed Reads trust the JWT; writes verify the session row. A GET is accepted on signature and expiry alone. Any POST/PATCH/
DELETE, plus every verified- or step-up-gated route
regardless of method, additionally loads the session row and checks it is not revoked and
the credential's session epoch still matches. The accepted tradeoff: after a merchant
revokes a buyer's sessions, a stolen access token can still be used to read that buyer's data for up to its remaining 600-second lifetime. It cannot
write anything in that window — no order, no address change, no card removal, no
subscription action.
Session lifecycle
| Event | Effect |
|---|---|
| Login | New session, new family, generation 0. |
| Refresh | Rotates the session in place; family unchanged. |
| Refresh reuse detected | Revokes every session in the family, not only the replayed one — unless it falls within the 10-second grace window described above, in which case the winning rotation’s token is returned instead. |
| Logout | Revokes the current session only. |
| Logout-all | Revokes every session for the customer. |
| Password changed (buyer, via step-up) | Bumps the session epoch and revokes every session except the current one. |
| Password reset (via emailed token) | Bumps the session epoch and revokes every session, including the one that requested the reset. |
Step-up: proving the password again for destructive actions
A handful of actions require a fresh step-up proof, obtained by calling POST /v1/storefront/auth/step-up with the current
password and presenting the returned stepUpToken as X-Step-Up alongside the normal bearer token. Missing or
expired returns 403 step_up_required.
// 1. Obtain a step-up proof for a destructive action
const stepUp = await fetch('https://api.usethrottle.dev/v1/storefront/auth/step-up', {
method: 'POST',
headers: {
'X-API-Key': publishableKey,
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
const { data } = await stepUp.json(); // { stepUpToken, expiresIn: 300 }
// 2. Present it alongside the access token on the destructive call
await fetch(`https://api.usethrottle.dev/v1/storefront/me/payment-methods/${id}`, {
method: 'DELETE',
headers: {
'X-API-Key': publishableKey,
Authorization: `Bearer ${accessToken}`,
'X-Step-Up': data.stepUpToken,
},
}); | Action | Step-up required? |
|---|---|
| Change account email | Yes |
| Change password | Yes |
| Remove a saved payment method | Yes |
| Cancel / pause / resume a subscription | Yes |
| Set a saved card as default | No — not destructive |
| Address create / update / delete | No |
Email verification and the claim cutoff
Registration issues a session immediately — a buyer does not wait on an email round trip
before they can browse or check out. An unverified session can do almost
everything: browse, use a cart, check out, place an order, and manage addresses created after registering. It is refused with 403 verification_required on every saved-payment-method
route, on changing the account email, and on every subscription write.
If a buyer registers with an email that already placed guest orders on this application,
Throttle attaches the new credentials to that existing customer record — the unique
index on (applicationId, email, environmentId) means it
cannot create a second row. But that history does not become visible until the email is
verified: every buyer-scoped read of orders, invoices, subscriptions, and addresses is
filtered to records created after registration until verification clears the cutoff.
Concretely: a buyer who registers onto an email with prior guest history can check out normally and will see the orders they place after registering, but their old guest orders, old addresses, and any saved cards on the account will not appear until they click the verification link. This is expected, not a bug — treat "an unverified account looks empty" as a documented state your UI should account for (e.g. a persistent "verify your email to see your order history" banner), not as a support ticket.
Saved payment methods are stricter still: they require emailVerified: true outright rather than a cutoff
timestamp, because what would otherwise leak is a stored payment instrument, not just an
address.
Endpoint reference
All routes below are under /v1/storefront (for example, /v1/storefront/auth/login). All require an X-API-Key publishable key and an allowed Origin. "Session" means a valid Authorization: Bearer {accessToken}.
No session required
| Method | Path | Notes |
|---|---|---|
| POST | auth/register | Creates a new customer or attaches to an existing guest record; issues a session on 201; returns 202 { status: 'pending' } (no session) if the email already has credentials. |
| POST | auth/login | 401 invalid_credentials for every failure mode — wrong password, unknown email, or a guest with no credentials. |
| POST | auth/refresh | Body: { refreshToken }. Rotates the token; detects reuse. See single-flight warning above. |
| POST | auth/verify-email | Body: { token }. Consumes a single-use emailed token; clears the claim cutoff. |
| POST | auth/forgot-password | Body: { email }. Always 202, whether or not the address has an account. |
| POST | auth/reset-password | Body: { token, password }. Consumes the emailed token; revokes every session for the account. |
Session required
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | auth/logout | session | Revokes the current session. |
| POST | auth/logout-all | session | Revokes every session; emits customer.sessions_revoked. |
| POST | auth/verify-email/resend | session | Rate limited; 204 even when already verified. |
| POST | auth/step-up | session | Body: { password }. Returns a 300s stepUpToken. |
| GET | me | session | The buyer's own profile. |
| PATCH | me | session | firstName / lastName / phone / company / acceptsMarketing. Email is not editable here. |
| POST | me/change-password | session + step-up | Body: { password }. Revokes every other session. |
| GET | me/sessions | session | The buyer's own device list. |
| DELETE | me/sessions/:id | session | 404 if the session does not belong to this customer. |
| GET | me/addresses | session | Cutoff-filtered. |
| POST | me/addresses | session | Creates an address; not step-up gated. |
| PATCH | me/addresses/:id | session | 404 (never 403) if the address isn't this buyer's. |
| DELETE | me/addresses/:id | session | 404 (never 403) on a foreign address id. |
| GET | me/payment-methods | session + verified | Verified-only even to list — gated outright, not by cutoff. |
| PATCH | me/payment-methods/:id | session + verified | { isDefault: true }. Not step-up gated — not destructive. |
| DELETE | me/payment-methods/:id | session + step-up | 404 (never 403) on a foreign card id. |
| GET | me/orders | session | Cursor-paginated (cursor, limit up to 100); cutoff-filtered. |
| GET | me/orders/:id | session | Cutoff-filtered; 404 if before the cutoff or foreign. |
| GET | me/invoices | session | Cursor-paginated; cutoff-filtered. |
| GET | me/invoices/:id | session | Cutoff-filtered. |
| GET | me/subscriptions | session | Cursor-paginated; cutoff-filtered. |
| GET | me/subscriptions/:id | session | Cutoff-filtered. |
| POST | me/subscriptions/:id/pause | session + step-up | |
| POST | me/subscriptions/:id/resume | session + step-up | |
| POST | me/subscriptions/:id/cancel | session + step-up | Body: { atPeriodEnd?: boolean }. |
| POST | me/carts/:cartId/claim | session | See cart claim below. Not verified-gated — a buyer must be able to check out before verifying. |
PATCH /me/payment-methods/:id { isDefault: true }.
Cart claim and the retirement of X-Customer-Id
Before this feature, a storefront asserted a cart's owner with an X-Customer-Id header sent on the merchant pk_* key. Because a publishable key is, by construction,
readable in a browser's page source, that header let any visitor to any storefront name any customer id on that application
and be treated as them — there was no proof of identity in the buyer path at all.
POST /v1/storefront/me/carts/{cartId}/claim
replaces the assertion with proof: the browser authenticates to the buyer plane, and the server — not the request body — writes the buyer's own customerId onto the cart. Existing cart and checkout
routes then proceed unchanged on the pk_* key, with the
cart already bound, prefilling saved addresses and cards exactly as a merchant-set customer
does today.
// After the buyer logs in (or right after registration), bind their existing
// anonymous cart to the account so checkout picks up saved addresses and cards.
await fetch(`https://api.usethrottle.dev/v1/storefront/me/carts/${cartId}/claim`, {
method: 'POST',
headers: { 'X-API-Key': publishableKey, Authorization: `Bearer ${accessToken}` },
});
// 200 { data: { cartId, customerId } }
// 404 { error: { code: 'not_found' } } — cart belongs to another application/environment
// 409 { error: { code: 'cart_already_claimed' } } — cart is bound to a different customer Deprecation timeline
- Now (GA 2026-09-12):
X-Customer-Idstill works on both key types. On a publishable key, a request that sends it getsDeprecationandSunsetresponse headers, and the API key id is logged server-side so a merchant can find which storefront still sends it. - Sunset + 90 days (2026-12-11):
X-Customer-Idis rejected on publishable keys with403 header_not_permitted. Migrate any browser code that sends it to the cart-claim flow above before this date.
HTTP/1.1 200 OK
Deprecation: true
Sunset: Fri, 11 Dec 2026 00:00:00 GMT
Link: <https://usethrottle.dev/docs/developers/storefront-auth#cart-claim>; rel="deprecation" X-Customer-Id keeps working on secret
(sk_*) keys forever. A server-side caller acting on a
customer's behalf is a legitimate, already-authenticated integration — the defect was
only ever the browser asserting its own identity through a key it is allowed to expose.
Error reference
| Code | HTTP | Meaning |
|---|---|---|
auth_not_enabled | 404 | Customer accounts are not enabled for this application. |
unauthorized | 401 | Missing X-API-Key header. |
invalid_api_key | 401 | The publishable key is invalid, revoked, or expired. |
origin_not_allowed | 403 | The Origin header is not in allowedOrigins for this environment. |
invalid_credentials | 401 | Login or step-up failure — any cause. |
session_revoked | 401 | The access token is well-formed but the session is revoked or its epoch is stale. |
token_expired | 401 or 400 | A missing/expired access token (401), or an expired email-verification/reset token (400). |
token_already_used | 400 | A single-use verification or reset token was already consumed. |
token_invalid | 400 | A verification or reset token does not exist or is malformed. |
weak_password | 400 | Fails the fixed password policy (12–256 chars, not in the breached-password list). |
verification_required | 403 | The route needs a verified session. |
step_up_required | 403 | The route needs a valid X-Step-Up token. |
environment_mismatch | 403 | The session's environment does not match the API key's environment (sandbox token on a production key, or vice versa). |
too_many_attempts | 429 | Backoff active on this email and/or IP; response carries Retry-After. |
auth_unavailable | 503 | Redis is unreachable on a credential route (login, register, refresh is unaffected — see below). |
not_found | 404 | An address, saved card, session, order, invoice, or subscription id does not belong to this buyer, or a cart id does not exist. |
cart_already_claimed | 409 | POST me/carts/:cartId/claim targeted a cart already bound to a different customer. |
header_not_permitted | 403 | Reserved for the X-Customer-Id sunset (2026-12-11) — not yet returned. See Cart claim above. |
auth/login, auth/register, auth/forgot-password, auth/reset-password, auth/step-up, and auth/verify-email/resend returns 503 auth_unavailable rather than letting the request
through unmetered. This is the opposite of Throttle's global rate limiter, which fails
open — right for a quote lookup, wrong for a password prompt. Every other buyer route,
including all reads, continues to serve normally during the outage.
Webhook events
Four events cover the buyer-auth lifecycle, gated behind the same customers:read scope as the existing customer.* family. Login is deliberately excluded — a
webhook firing on every sign-in would dominate delivery volume without being an event a
merchant needs to react to.
| Event | Fires when | data |
|---|---|---|
customer.registered | A buyer creates a storefront account. | { customerId } |
customer.email_verified | A buyer verifies their account's email — also the moment their pre-registration history becomes visible. | { customerId } |
customer.password_changed | A buyer changes their password, via account settings or an emailed reset. | { customerId } |
customer.sessions_revoked | All of a buyer’s sessions are revoked at once (logout-all, a password reset, or a merchant-initiated revoke). | { customerId, reason } |
Each uses the standard delivery envelope — see
the webhooks payload reference
for the full envelope shape, signature verification, and retry behaviour. Types are in @usethrottle/webhook-types like every other event.
SDK: @usethrottle/auth
A headless client wraps every route above — silent refresh, single-flight and cross-tab
safe refresh, retry-once-on-401 — plus a React provider, six account hooks, and five
drop-in forms, matching the pattern @usethrottle/payment-methods
already follows for saved cards. This section covers only the quick start; see the
package README
for the full API (all six hooks, step-up, errors, storage adapters) and the endpoint reference above for the raw
HTTP contract it wraps.
npm install @usethrottle/auth
React is a peer dependency and optional — the core client (
createThrottleAuth, createAccount) works with no React installed.
import { ThrottleAuthProvider } from '@usethrottle/auth/react';
export function App({ publishableKey }: { publishableKey: string }) {
return (
<ThrottleAuthProvider publishableKey={publishableKey}>
<StorefrontAccount />
</ThrottleAuthProvider>
);
} import { SignInForm } from '@usethrottle/auth/forms';
import { useAuth } from '@usethrottle/auth/react';
function SignInPage() {
const { isAuthenticated } = useAuth();
if (isAuthenticated) return <p>You are signed in.</p>;
return <SignInForm onSuccess={() => { window.location.href = '/account'; }} />;
} import { useAuth, useOrders } from '@usethrottle/auth/react';
import { VerifyEmailPanel } from '@usethrottle/auth/forms';
function OrderHistory() {
const { isAuthenticated, emailVerified } = useAuth();
const { data: orders, isLoading } = useOrders();
if (!isAuthenticated) return null;
return (
<>
{!emailVerified && <VerifyEmailPanel />}
{isLoading ? <p>Loading…</p> : orders.map((o) => <p key={o.id}>{o.orderNumber}</p>)}
</>
);
} useOrders, useInvoices, useSubscriptions,
and useAddresses hooks return empty arrays for anything
that predates registration, and useCustomer().update(...)
throws verification_required — see Email verification and the claim cutoff
above. Render <VerifyEmailPanel /> whenever isAuthenticated && !emailVerified rather than
treating an empty account page as broken.
Drop-in components: @usethrottle/auth/components
The hooks above are headless — you build the UI. The ./components entry point is the other option: prebuilt,
restylable React components that render inside your own storefront, so a buyer never
leaves your site to sign in or read their order history. Same package, same session, same
routes underneath.
npm install @usethrottle/auth
# React and react-dom are peer dependencies of this entry point // app/providers.tsx
'use client';
import { ThrottleProvider } from '@usethrottle/auth/components';
import '@usethrottle/auth/styles.css'; // or let the provider inject it
export function Providers({ children }) {
return (
<ThrottleProvider
publishableKey={process.env.NEXT_PUBLIC_THROTTLE_PUBLISHABLE_KEY}
baseUrl="https://api.usethrottle.dev"
appearance={{
variables: {
colorPrimary: '#9a3412',
colorPrimaryForeground: '#ffffff', // set BOTH — see the note below
},
}}
>
{children}
</ThrottleProvider>
);
} import {
SignedIn, SignedOut, SignInButton, UserButton, AccountDashboard,
} from '@usethrottle/auth/components';
// In your storefront header
<SignedOut><SignInButton /></SignedOut>
<SignedIn><UserButton afterSignOutUrl="/" /></SignedIn>
// On /account
<SignedIn><AccountDashboard routing="hash" /></SignedIn> | Component | What it renders |
|---|---|
<SignIn /> | Sign-in card. Also SignUp, ForgotPassword, ResetPassword, VerifyEmail. |
<AuthFlow /> | The five cards with navigation between them — one component for a whole auth surface. |
<SignInButton /> | A button that opens the auth flow in a modal. Also SignUpButton, SignOutButton. |
<SignInPage /> | Full-viewport centred shell for a dedicated /sign-in route. Also SignUpPage. |
<UserButton /> | Navbar avatar with a menu: identity, account, sign out. |
<AccountDashboard /> | Profile, addresses, saved cards, orders, invoices, subscriptions and security, in a sidebar or tab layout. |
<SignedIn> / <SignedOut> | Render children only in that state. <Protect> additionally gates on email verification. |
Every panel is also exported individually (OrdersPanel, AddressesPanel, …) if you want your own shell around them.
Theming
Three levels, from least to most invasive: CSS custom properties via appearance.variables, your own classNames on any part via appearance.elements, and whole-component replacement via appearance.components. Every shipped rule is a
single-class selector, so a className you pass wins on source order rather than losing to
specificity.
colorPrimaryForeground is the text drawn on top of colorPrimary. Overriding only the first leaves the
theme's own foreground in place, and the pair is not always readable — a terracotta
primary on its own measures 3.65:1 against the dark theme's foreground, below the 4.5:1
WCAG asks for button text.
Development builds measure the pair and warn once in the console when it falls short. They also warn when your brand colour is too close to the destructive colour to tell apart: Delete and Edit sit side by side in the account panels and a buyer distinguishes them by colour alone, so a warm brand landing on the danger red removes the only signal that one of them is irreversible. Nothing is logged in production builds.
appearance.theme accepts 'light', 'dark' or 'auto', and defaults to light. It is deliberately not 'auto': that resolves against prefers-color-scheme, which describes the shopper's
machine and has no relationship to your storefront — so a light site would render a dark
widget for every shopper with dark mode enabled. Opt into 'auto' explicitly if your own site follows the OS too.
Checkout interruption
The case these components exist for is a buyer stopped mid-purchase. Render the auth flow inline where they already are, rather than sending them to a separate page — and pair it with cart claim so the cart they built anonymously follows them into the session.
// The flow the components exist for: a buyer stopped mid-purchase.
// Render the auth card inline — the cart is already full, so navigating
// away to a separate sign-in page is what loses the sale.
import { SignedIn, SignedOut, AuthFlow } from '@usethrottle/auth/components';
<SignedOut>
<p>Sign in to finish your order — your cart stays exactly as it is.</p>
<AuthFlow />
</SignedOut>
<SignedIn>
<button onClick={placeOrder}>Place order</button>
</SignedIn>