Skip to Content
DevelopersBuildingShopper login options

8 min read

Shopper login options

Shoppers sign in per store. Every row involved carries store_id and sits under RLS, so a Google account that shops at two stores is two customers with two identities, and nothing minted on one store means anything on another. This page extends Customer privacy and accounts, which covers the email, SMS and WhatsApp codes.

What exists

MethodWhere it runsHow the customer is found
Email, SMS, WhatsApp codeStore domainEmail (upsert) or phone blind index, as before
Magic linkStore domainEmail (upsert), exactly like the email code
Google, Apple, FacebookPlatform login domain, then a hand-offLinked identity, else a verified email
Google One TapStore domainSame as Google
TruecallerStore domain + server callback on the login domainLinked identity, else the verified mobile
PasskeyStore domainThe credential’s customer

Merchants switch each one on in Settings → Customer accounts (store.preferences.customerAccounts, schema in @commerce/core/customer-accounts). The storefront shows only what is switched on and working: signInOptions(settings, codes, readiness) decides, and quickSignInButtons (storefront, tested) decides per device.

Files

PieceFile
Settings, signInOptions, sessionLifetime (pure)packages/core/src/customer-accounts/client.ts
Signed tokens, hand-off checks, return URL allow-list (pure)packages/core/src/shopper-login/tokens.ts
Hand-off and magic-link ticketspackages/core/src/shopper-login/tickets.ts
Linking rule and identitiespackages/core/src/shopper-login/identities.ts
Provider apps (platform env, merchant’s own, encrypted)packages/core/src/shopper-login/credentials.ts
OAuth and One Tap (Better Auth provider modules)packages/core/src/shopper-login/providers.ts
Passkeys (@simplewebauthn/server)packages/core/src/shopper-login/passkeys.ts
Truecallerpackages/core/src/shopper-login/truecaller.ts
Tables customer_identity, customer_passkey, customer_login_ticket; store.login_providerspackages/db/src/shopper-login-schema.ts, migration 0076_shopper_login
Store-domain routesapps/storefront/src/app/-s/[storeId]/account/login/{social/[provider],complete,link}
Login-domain routesapps/storefront/src/app/-a/oauth/[provider]/{start,callback}, -a/truecaller/callback
Actionsapps/storefront/src/lib/login-actions.ts (+ account-actions.ts, privacy-actions.ts for codes)
Adminpackages/api/src/routers/customer-accounts.ts, apps/admin/src/components/settings/{sign-in-providers-card,sign-in-extras-card,login-app-sheet}.tsx

Why not betterAuth() for shoppers

Better Auth (already our merchant auth) keeps users and sessions in global tables without store_id. Shoppers are per-store customer rows under RLS with their own sessions (customer_session). So we reuse Better Auth’s official provider modules (better-auth/social-providers: google, apple, facebook, verifyGoogleIdToken, the same code its oneTap plugin calls) for the protocol work (PKCE, code exchange, ID token checks), and @simplewebauthn/server, the library its @better-auth/passkey plugin is built on, for passkeys. The magic-link and one-tap plugins create Better Auth users, so their flows are rebuilt on our tables with the same behaviour.

Docs: Google , Apple , Facebook , One Tap , Magic link , Passkey . The docs host was blocked from the machine that built this; it was written against the 1.7.6 package source in node_modules (pinned: better-auth 1.7.6, @simplewebauthn/server 13.3.3, @simplewebauthn/browser 13.3.0, jose 6.2.12).

One platform login domain

Stores live on <slug>.<PLATFORM_STORE_DOMAIN> and on custom domains. Registering every domain with Google, Apple and Facebook is impossible, so provider sign-in runs on one domain, ACCOUNTS_URL (default https://accounts.<PLATFORM_STORE_DOMAIN>), served by the storefront app: the proxy sends that hostname to the internal /-a routes and serves nothing else there (routeForAccounts). Store hosts can never reach /-a (routeForStore forbids it), and the slug accounts is reserved. Each provider needs one redirect URI: <ACCOUNTS_URL>/oauth/<google|apple|facebook>/callback.

Hand-off back to the store

What each check stops:

AttackStopped by
Tampered token (other customer, store, URL)HMAC over the payload, key derived per purpose from SETTINGS_SECRET
Expired tokene in the payload (2 minutes) and the ticket’s expires_at
Replayed tokenThe ticket is deleted in the statement that reads it (used)
Token for store A used on store BPayload store must equal the store the proxy resolved; tickets are under store B’s RLS anyway
Open redirect / foreign return URLStart and callback both require https://<one of the store's active domains>/account/login/complete (no port, query, fragment or credentials; http and ports only in development)
Token planted in a victim’s browser (login CSRF)The ticket stores the hash of a random cookie set on the store domain by the browser that started; another browser burns the ticket
OAuth CSRF on the login domainstate in a signed cookie on the login domain; PKCE on every provider

Tests: packages/core/src/shopper-login/tokens.test.ts (pure) and shopper-login.test.ts (database: replay, expiry, wrong browser, wrong store, foreign return URL, tampering).

Linking accounts

decideLink (pure) then resolveShopperIdentity:

  1. An identity seen before (customer_identity (store_id, provider, subject)) signs in as its customer. The subject is the provider’s id, never the email, so a changed Google email keeps the account.
  2. A signed-in shopper pressing Connect on their profile links to themselves (a conflict if that identity already belongs to someone else). The platform login domain only checks for a conflict (planConnect) and mints a connect hand-off; the link is written by /account/login/complete on the store’s domain, after the browser check, for the customer signed in there. A start link forwarded to someone else therefore links nothing.
  3. A mobile the provider verified (Truecaller) links to the customer with that phone (blind index), else creates a phone-only customer. Only a phone that was itself proven (customer.phone_verified_at, set by a phone sign-in code or Truecaller) or a customer with no email matches: a phone typed at checkout or on a profile proves nothing (accounts/phone-proof.ts). Phone code sign-in follows the same rule.
  4. A verified email links to the customer with that email, else creates one.
  5. An unverified email never links and never creates a customer with that email (the real owner would later sign into it). The shopper confirms it with an email code first: the identity waits in a signed wc_link cookie, and verifyCodeAction links it once the code proves that same email. Facebook reports no verification flag, so it always takes this path the first time.
  6. No email at all: refused (no_email).

Erasing a customer (anonymiseCustomer) deletes their identities, passkeys and tickets.

Return paths (safeNextPath) refuse control characters and backslashes anywhere and must resolve to the same origin, since browsers drop tab and CR (/\t/evil.com is //evil.com). Origins for emailed links come from a Host header that is a plain host[:port] (parseHostHeader); the proxy refuses anything else. Third-party tags never load under /account/login, whose URLs carry tokens. Sign-in codes: the code row is locked while it is checked, wrong guesses carry over resends for an hour, and limiter keys are the normalised address per store (otpLimiterId).

Passkeys and domains

Decision: passkeys are registered on the store’s own hostname (rpID = the host the shopper is on), not the platform domain. A WebAuthn ceremony must run on a page whose origin matches the rpID, so a platform-domain passkey would need a hand-off for every sign-in, and a custom domain cannot use an rpID of waimers.store. Consequences:

  • Each credential stores rp_id; sign-in only matches credentials for the current host.
  • When a store moves from <slug>.waimers.store to a custom domain, old passkeys stop working there. The shopper signs in another way once and adds a passkey again; the profile page marks passkeys made on another domain.
  • Later: WebAuthn related origin requests  (/.well-known/webauthn) could let one rpID serve a store’s domains; browser support is still partial.

Challenges live on the server: each ceremony writes a customer_login_ticket row (passkey_register or passkey_login) with the challenge, the rpID and, when adding a passkey, the signed-in customer. The wc_pk cookie holds only a random handle whose hash finds the row. Verifying deletes the row first, so a challenge works once; it lives 5 minutes and only on the host and store it was made for. No attestation and resident key required (usernameless sign-in), like Better Auth’s passkey plugin, but user verification is required (fingerprint, face or screen lock), because a passkey is the whole sign-in. Security audit D-04.

Google One Tap

One Tap runs on the store’s own page, so Google must list the store’s origin under the client’s authorised JavaScript origins. That scales only with the merchant’s own Google app, so One Tap is offered only then (signInOptions). The page loads Google Identity Services, asks our server for a nonce (sealed in wc_1tap), and posts the ID token to oneTapSignInAction, which checks audience, issuer, signature and nonce with verifyGoogleIdToken, then links like any Google sign-in.

Truecaller

Truecaller’s web SDK for mobile websites  works only in Android browsers with the Truecaller app installed (the button shows on Android only). Flow:

  1. startTruecallerAction writes a pending ticket and a 59-character request nonce that carries the store id and an HMAC (Truecaller’s callback has nothing else to go on). The nonce also sits in the wc_tc cookie.
  2. The page opens truecallersdk://truecallersdk?type=btmsheet&requestNonce=…&partnerKey=…. If the page keeps focus, the app is not there and the shopper picks another way.
  3. Truecaller’s server POSTs { requestId, accessToken, endpoint } to <ACCOUNTS_URL>/truecaller/callback. We fetch the profile only from https://*.truecaller.com (SSRF guard) and take the verified number. A number seen with Truecaller before signs in; a new number becomes a new customer. A number that belongs to an existing customer who never used Truecaller here waits on a phone code (ticket status verify_phone). A profile email is never used (Truecaller does not verify it).
  4. The page polls pollTruecallerAction; the ready ticket is spent by the browser holding the cookie. For verify_phone, the first poll sends a sign-in code to that number through the store’s WhatsApp or SMS sign-in channel (status phone_code_sent) and the page asks for it; the polling browser never sees the number. confirmTruecallerCodeAction checks the code (same attempt limits as phone sign-in), links Truecaller to the customer and signs in. A store without a phone channel cannot finish this step, and the shopper signs in another way.

Why the code (security audit D-03): the nonce belongs to whoever started the request, not to the phone that consents, so a page could show someone the deep link for its own request and walk into their account with one tap. The button is also offered only on the store’s own page: never inside a frame (quickSignInButtons, topLevel), and startTruecallerAction refuses a request whose Origin is not exactly the store origin (truecallerStartAllowed).

Terms: a Truecaller developer account and partner key are required, the consent screen is Truecaller’s, and profile data may only be used to sign the shopper in. The docs host was blocked when this was built; verify the parameter names against the portal before switching it on in production.

Remember this device

Off (default): sessions last 30 days as before. On: the sign-in card shows a tick box; ticked = 90-day session and persistent cookie, unticked = 12-hour session in a session cookie that the browser drops on close (sessionLifetime).

Setup

Environment (packages/config/src/env.ts, .env.example):

ACCOUNTS_URL=https://accounts.waimers.store # DNS to the storefront, TLS included SHOPPER_GOOGLE_CLIENT_ID=… SHOPPER_GOOGLE_CLIENT_SECRET=… SHOPPER_APPLE_SERVICES_ID=… SHOPPER_APPLE_TEAM_ID=… SHOPPER_APPLE_KEY_ID=… SHOPPER_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n…" SHOPPER_FACEBOOK_APP_ID=… SHOPPER_FACEBOOK_APP_SECRET=… SHOPPER_TRUECALLER_PARTNER_KEY=… SHOPPER_TRUECALLER_PARTNER_NAME=…

A provider without its env keys is simply “needs your own app” in the admin. Merchants’ own apps live in store.login_providers (secret fields AES-256-GCM under SETTINGS_SECRET, masked in the API, included in bun run rotate-secrets).

On localhost

The seeded store answers on localhost and waimers.localhost, so use another host for the login domain:

  1. .env: ACCOUNTS_URL=http://127.0.0.1:3100 and your test Google client’s id and secret.
  2. In Google Cloud, add http://127.0.0.1:3100/oauth/google/callback as a redirect URI.
  3. bun run dev, turn Google on in Settings → Customer accounts, open http://localhost:3100/account/login and press Continue with Google: you leave for 127.0.0.1, then Google, and come back to localhost signed in.
  4. Magic links print to the storefront console in development. Passkeys work on http://localhost (browsers treat it as secure). Apple needs https end to end; Truecaller needs a public https callback (a tunnel) and an Android phone.
Was this helpful?