Buyer identity

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.

Not enabled by default
Every application has 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)
PrincipalMerchant staff and their serversThe merchant's own end shoppers
Credentialssk_* / pk_*accessToken (Bearer) + refreshToken
Namespace/api/v1/*/v1/storefront/*
Request propertyrequest.authrequest.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
A buyer session and a merchant API key never cross planes.

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).

An empty allowed-origins list denies in production, allows in sandbox
This is deliberate, not a bug you can work around by leaving the list empty: it lets local development work with zero setup, but it means going live requires configuring allowed origins first. In a production environment, an empty list rejects every request with 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.

TokenTransportLifetimeNotes
accessTokenAuthorization: Bearer600 secondsA JWT. Reads trust it on signature alone — no database round trip.
refreshTokenRequest body30 days sliding, 90 days absoluteOpaque. Rotates on every use; a spent one that is replayed revokes the whole session family.
stepUpTokenX-Step-Up300 secondsProves the current password was just re-entered. Bound to one session — cannot be replayed on another device.
Single-flight your refresh calls

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.

Register a buyer
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 } }
Response — 201 Created
{
  "data": {
    "accessToken": "eyJhbGciOi...",
    "expiresAt": "2026-09-12T18:10:00.000Z",
    "refreshToken": "rt_bGl2ZS1zZXNzaW9uLWlk.9vQ2f...",
    "customer": {
      "id": "cus_51...",
      "email": "buyer@example.com",
      "emailVerified": false
    }
  }
}
Refresh a session
// 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

EventEffect
LoginNew session, new family, generation 0.
RefreshRotates the session in place; family unchanged.
Refresh reuse detectedRevokes 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.
LogoutRevokes the current session only.
Logout-allRevokes 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.

Step-up before a destructive action
// 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,
  },
});
ActionStep-up required?
Change account emailYes
Change passwordYes
Remove a saved payment methodYes
Cancel / pause / resume a subscriptionYes
Set a saved card as defaultNo — not destructive
Address create / update / deleteNo

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.

A returning guest sees an empty account until they verify

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

MethodPathNotes
POSTauth/registerCreates 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.
POSTauth/login401 invalid_credentials for every failure mode — wrong password, unknown email, or a guest with no credentials.
POSTauth/refreshBody: { refreshToken }. Rotates the token; detects reuse. See single-flight warning above.
POSTauth/verify-emailBody: { token }. Consumes a single-use emailed token; clears the claim cutoff.
POSTauth/forgot-passwordBody: { email }. Always 202, whether or not the address has an account.
POSTauth/reset-passwordBody: { token, password }. Consumes the emailed token; revokes every session for the account.

Session required

MethodPathAuthNotes
POSTauth/logoutsessionRevokes the current session.
POSTauth/logout-allsessionRevokes every session; emits customer.sessions_revoked.
POSTauth/verify-email/resendsessionRate limited; 204 even when already verified.
POSTauth/step-upsessionBody: { password }. Returns a 300s stepUpToken.
GETmesessionThe buyer's own profile.
PATCHmesessionfirstName / lastName / phone / company / acceptsMarketing. Email is not editable here.
POSTme/change-passwordsession + step-upBody: { password }. Revokes every other session.
GETme/sessionssessionThe buyer's own device list.
DELETEme/sessions/:idsession404 if the session does not belong to this customer.
GETme/addressessessionCutoff-filtered.
POSTme/addressessessionCreates an address; not step-up gated.
PATCHme/addresses/:idsession404 (never 403) if the address isn't this buyer's.
DELETEme/addresses/:idsession404 (never 403) on a foreign address id.
GETme/payment-methodssession + verifiedVerified-only even to list — gated outright, not by cutoff.
PATCHme/payment-methods/:idsession + verified{ isDefault: true }. Not step-up gated — not destructive.
DELETEme/payment-methods/:idsession + step-up404 (never 403) on a foreign card id.
GETme/orderssessionCursor-paginated (cursor, limit up to 100); cutoff-filtered.
GETme/orders/:idsessionCutoff-filtered; 404 if before the cutoff or foreign.
GETme/invoicessessionCursor-paginated; cutoff-filtered.
GETme/invoices/:idsessionCutoff-filtered.
GETme/subscriptionssessionCursor-paginated; cutoff-filtered.
GETme/subscriptions/:idsessionCutoff-filtered.
POSTme/subscriptions/:id/pausesession + step-up
POSTme/subscriptions/:id/resumesession + step-up
POSTme/subscriptions/:id/cancelsession + step-upBody: { atPeriodEnd?: boolean }.
POSTme/carts/:cartId/claimsessionSee cart claim below. Not verified-gated — a buyer must be able to check out before verifying.
There is no PATCH /me/subscriptions/:id/payment-method
Changing which card renews a subscription is not implemented, on purpose — there is no per-subscription payment-method column anywhere in this platform to change. A buyer changes their renewal card by setting a different saved card as their default: 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.

Claim a cart after login
// 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-Id still works on both key types. On a publishable key, a request that sends it gets Deprecation and Sunset response 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-Id is rejected on publishable keys with 403 header_not_permitted. Migrate any browser code that sends it to the cart-claim flow above before this date.
Response headers, today
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"
Secret keys are unaffected, indefinitely
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

CodeHTTPMeaning
auth_not_enabled404Customer accounts are not enabled for this application.
unauthorized401Missing X-API-Key header.
invalid_api_key401The publishable key is invalid, revoked, or expired.
origin_not_allowed403The Origin header is not in allowedOrigins for this environment.
invalid_credentials401Login or step-up failure — any cause.
session_revoked401The access token is well-formed but the session is revoked or its epoch is stale.
token_expired401 or 400A missing/expired access token (401), or an expired email-verification/reset token (400).
token_already_used400A single-use verification or reset token was already consumed.
token_invalid400A verification or reset token does not exist or is malformed.
weak_password400Fails the fixed password policy (12–256 chars, not in the breached-password list).
verification_required403The route needs a verified session.
step_up_required403The route needs a valid X-Step-Up token.
environment_mismatch403The session's environment does not match the API key's environment (sandbox token on a production key, or vice versa).
too_many_attempts429Backoff active on this email and/or IP; response carries Retry-After.
auth_unavailable503Redis is unreachable on a credential route (login, register, refresh is unaffected — see below).
not_found404An address, saved card, session, order, invoice, or subscription id does not belong to this buyer, or a cart id does not exist.
cart_already_claimed409POST me/carts/:cartId/claim targeted a cart already bound to a different customer.
header_not_permitted403Reserved for the X-Customer-Id sunset (2026-12-11) — not yet returned. See Cart claim above.
Credential routes fail closed, not open
If the rate-limit store (Redis) is unreachable, every one of 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.

EventFires whendata
customer.registeredA buyer creates a storefront account.{ customerId }
customer.email_verifiedA buyer verifies their account's email — also the moment their pre-registration history becomes visible.{ customerId }
customer.password_changedA buyer changes their password, via account settings or an emailed reset.{ customerId }
customer.sessions_revokedAll 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.

Install
npm install @usethrottle/auth

React is a peer dependency and optional — the core client ( createThrottleAuth, createAccount) works with no React installed.

Provider, once near the app root
import { ThrottleAuthProvider } from '@usethrottle/auth/react';

export function App({ publishableKey }: { publishableKey: string }) {
  return (
    <ThrottleAuthProvider publishableKey={publishableKey}>
      <StorefrontAccount />
    </ThrottleAuthProvider>
  );
}
Sign-in page
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'; }} />;
}
Account page
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>)}
    </>
  );
}
Build the verification banner from day one
An unverified buyer's 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.

Install
npm install @usethrottle/auth
# React and react-dom are peer dependencies of this entry point
Provider, once near the app root
// 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>
  );
}
Header and account page
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>
ComponentWhat 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.

Set colorPrimary and colorPrimaryForeground together

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.

The theme defaults to light, not the shopper's OS
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.

Inline sign-in at checkout
// 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>
Unverified buyers see gates, not errors
An unverified buyer's orders, invoices, subscriptions and saved cards are withheld by the server (see the claim cutoff above). The panels render that as an actionable gate — "Verify your email to see your order history", with a resend action — never as a failure. You do not need to special-case it.