Storage and Sessions
Storefront Next uses a server-only authentication architecture. All Shopper Login and API Access Service (SLAS) token management—guest login, refresh, registered login, social and passwordless flows, and cookie writing—happens in a single server middleware, auth.server.ts, that runs on every request. Tokens never reach the browser. Client components receive only a small, non-sensitive slice of session data through the useAuth() hook.
Storefront Next auth has two layers and a single source of truth:
- Server middleware (
middlewares/auth.server.ts) runs on every request—both full page loads and React Router client navigations, which re-invoke the server loaders. It reads auth cookies from theCookieheader, validates and refreshes tokens, performs guest login when needed, and writes updated cookies throughSet-Cookieheaders. - React context (
providers/auth.tsx) exposes aPublicSessionDataobject (no tokens) to components through theuseAuth()hook. The root loader extracts this slice withgetPublicSessionData(session)and passes it to the provider.
Route modules use only the server loader and action—never clientLoader or clientAction.
Storefront Next stores all auth state across separate cookies, each with its own purpose, expiry, and value source. Every auth cookie is HttpOnly. The browser sends them on each request—so the server middleware and hybrid B2C Commerce can both read them—but client-side JavaScript can’t access them. The client gets its auth state from the serialized loader data, not by reading cookies. This architecture protects every token from cross-site scripting (XSS) attacks.
| Cookie | Purpose | User type | Expiry | Value source |
|---|---|---|---|---|
cc-nx-g | Guest refresh token | Guest | Guest refresh expiry (max 30 days) | SLAS body |
cc-nx | Registered refresh token | Registered | Registered refresh expiry (max 90 days) | SLAS body |
cc-at | Access token | Both | Access-token JWT exp | SLAS body |
usid | User session ID (mirrors JWT sub → usid) | Both | Refresh expiry, else access expiry | JWT sub claim |
enc_user_id | Encoded user ID | Registered | Refresh expiry | SLAS body |
idp_access_token | IDP access token (social login) | Both | Access expiry (proxy) | SLAS body |
id_token | OIDC ID token | Both | Access expiry | SLAS body |
idp_refresh_token | IDP refresh token (social login) | Both | Refresh expiry | SLAS body |
dw_dnt | Tracking consent preference (value = TrackingConsent enum) | Both | Session | Cookie (source of truth) / JWT dnt |
dwsid | Hybrid storefront session ID (B2C Commerce session bridge) | Both | Session | SLAS Set-Cookie header |
cc-cv | OAuth2 PKCE code verifier | Both | 5 minutes | Generated (social flow) |
cc-auth-recover | 401-recovery loop guard | Both | 30 seconds | Middleware |
Value source. Token strings come verbatim from the SLAS TokenResponse body. The session facts—userType, customerId, usid, accessTokenExpiry, and tracking consent—are decoded from the access-token JWT rather than read from the body, so they can never drift from the token. The dwsid cookie is the exception: it comes from the SLAS response’s Set-Cookie header.
customerId is not a cookie. It’s derived per request from the access-token JWT and exposed through getAuth() on the server and useAuth() on the client. The logout and error paths also clear any legacy customer_id cookie left by older versions.
- Mutually exclusive refresh tokens: Only one of
cc-nx-g(guest) orcc-nx(registered) exists at a time. On a user-type transition, the middleware explicitly deletes the other with aSet-Cookieheader that uses an expired date. userTypeis never stored in a cookie: It’s derived from the JWT. The refresh-cookie name is only a write-time decision for where to store the refresh token.- Namespacing: Cookies are suffixed with
siteId(for example,cc-nx_RefArch) to support multi-site deployments, exceptdwsidanddw_dnt, which are excluded so external or B2C Commerce systems can read them directly. - HttpOnly on every auth cookie: Client-side JavaScript can never read auth tokens. The client receives only non-sensitive session data through the loader, so there is no need for JavaScript cookie access.
userType comes from the access-token JWT: registered tokens carry an rcid identity claim that guest tokens lack. It isn’t determined by which refresh cookie exists. The refresh-cookie name is consulted only as a cold-start fallback, before any access token has been issued, and to decide which refresh cookie to write or delete on the response.
- Access token: The expiry is read from the JWT
expclaim, stored as a timestamp, and compared at runtime with a fast numeric check—so there’s no repeated JWT decoding in the hot path. - Refresh token: Configurable through environment variables and capped at the B2C Commerce maximums (guest 30 days, and registered user 90 days).
When a SCAPI call returns a 401 for a non-SLAS endpoint, the SCAPI client throws AuthTokenInvalidError. The middleware catches it in handleAuthTokenInvalidation, clears stale token state, re-runs the refresh or guest flow, and—if recovery succeeds—issues a 307 redirect back to the same URL so the request restarts with fresh cookies. The redirect carries the x-sfnext-auth-recovery: 1 header for observability.
To prevent loops, a short-lived guard cookie, cc-auth-recover (Max-Age=30), is set during recovery. If a 401 recurs while the guard is present, recovery isn’t retried—the error surfaces and the response carries the x-sfnext-auth-recovery-guard: 1 header. The guard is cleared on the follow-up request.
When a registered shopper’s SLAS refresh token fails (SLAS returns a 400), the middleware redirects to /login (with the site and locale prefix) instead of silently downgrading the shopper to a guest session. The redirect carries returnUrl (the original request path, already prefixed) and error=session_expired so the login page can display a contextual message. A fresh guest session is created in parallel so the application has a valid token while the redirect is processed.
The same cc-auth-recover guard cookie prevents redirect loops: if the guard is already set when the redirect would fire, the middleware falls through to the guest session rather than redirecting again. The guard expires after 30 seconds and is cleared on the follow-up request.
Guest shoppers whose refresh token fails are unaffected by this change. They continue to receive a new guest session silently.
SLAS guarantees gcid or rcid in the isb claim and usid in the sub claim. The middleware validates this on the incoming cookie token, but only when that token survived validation unchanged. After a refresh or guest login, re-validation is skipped because the freshly issued token was already validated inside updateAuthStorageDataByTokenResponse when it was stored.
If a structurally invalid token is detected (decodable but missing required claims, or undecodable), AuthTokenInvalidError is thrown and routed through the same recovery flow as a 401: clear cookies, fresh login, 307 redirect. Because this indicates a critical token-issuance failure, it logs at the error level for production visibility.
To share cookies across subdomains—or to keep session continuity in a hybrid Storefront Next and SFRA deployment—set a cookie domain by using the global app.cookies.domain or a per-site commerce.sites[].cookies.domain override. It applies to every cookie the storefront writes and is opt-in (host-only by default).
Cookie attribute defaults (path: '/', sameSite: 'lax', and secure) are applied automatically. The resolved cookie domain takes precedence over these defaults. secure is gated on isRemote() (the BUNDLE_ID signal): true on deployed (HTTPS) environments, false on local pnpm dev and pnpm preview, which serve plain HTTP over localhost. Without gating secure on isRemote(), Safari and WebKit silently refuse to persist Secure cookies on loopback and login never sticks. Chrome and Firefox mask it with a localhost exception. SameSite=None cookies, which are used in Page Designer design mode, always stay Secure, as the spec requires.
The per-site override takes precedence over the global setting. A leading-dot value scopes the cookie to the apex domain and all subdomains:
Cookie attribute defaults (path: '/', sameSite: 'lax') are applied automatically. The resolved cookie domain takes precedence over them.
The secure attribute is set to true on deployed (HTTPS) environments and false on local development (pnpm dev or pnpm preview), which serve plain HTTP over localhost. Without this, Safari and WebKit silently refuse to persist Secure cookies on loopback and login never sticks. Chrome and Firefox mask this with a localhost exception. SameSite=None cookies—used in Page Designer design mode—always stay Secure, as the spec requires.
For the full guide, including the matching Business Manager setting and rollout guidance, see the Cookie Domain Configuration guide.
The auth middleware reads cookies from the request and builds the full SessionData, including tokens. You can access this data only on the server—in loaders, actions, and other middlewares. Use getAuth(context):
There’s no client-side getAuth. Client components read the non-sensitive slice through the useAuth() hook—tokens are never available on the client:
For route-level auth checks, branch inside the server loader—not in a client guard.
updateAuth(context, updater) accepts either a SLAS token response or a function updater:
The token-response form re-derives userType, customerId, usid, and expiry from the new JWT, preserves the tracking-consent cookie, and writes the appropriate cookies. The function form, updateAuth(context, (data) => ({ ...data, codeVerifier })), merges in fields such as the PKCE code verifier without a full token swap.
For social login flows that use OAuth2 PKCE (Proof Key for Code Exchange), the code verifier is stored in an HttpOnly cookie (cc-cv) between the authorization request and the token exchange:
CSP note: When you add a social-login provider beyond the defaults, extend connect-src (and any redirect or popup origins) in your app.security.headers.csp.directives config. For the full guide, see Configure CSP for Social Login.
The following server-side SLAS helpers are exported from auth.server.ts:
For the related shopper features, see Social Login, Passwordless Login, and Password Reset.
The recovery helpers cover error and corruption scenarios:
flashAuth(context, message)— Clears the session and sets an error message. Use this when a shopper account is deleted or becomes invalid mid-session so the storefront can redirect the shopper to login with an appropriate message.clearInvalidSessionAndRestoreGuest(context)— Replaces a corrupted or unrecoverable session with a fresh guest session. Use this when the session state is structurally invalid and a clean guest login is preferable to surfacing an error.
- The user visits with no auth cookies.
- The server middleware finds no usable token and calls SLAS guest login.
- The server writes
cc-nx-g,cc-at, andusid(and anydwsidfrom the response). - The root loader serializes
clientAuth, andAuthProvidermakes it available throughuseAuth().
- The server reads cookies from the
Cookieheader. - If the access-token
expis in the future, the tokens are used as-is. - If the access token is expired but a refresh token exists, the server refreshes and rewrites
cc-at(andusidif it changed).
- The action calls
loginRegisteredUser(), and SLAS returns a registered token whose JWT carriesrcid. updateAuth(context, tokenResponse)derivesuserType = 'registered'from the JWT.- The middleware writes
cc-nxand deletescc-nx-gto maintain mutual exclusivity. - Guest resources are merged into the registered account (see below).
- The action calls
destroyAuth(context). - The middleware deletes all auth cookies through expired
Set-Cookieheaders (including the legacycustomer_id). - The next request finds no cookies and triggers a fresh guest login.
On any guest-to-registered transition (social, passwordless, or standard login), guest-owned resources must be captured before the token swap, because customerId changes from the guest gcid to the registered rcid, and SCAPI rejects the guest customer ID under a registered token:
Tracking consent is preserved across the swap: updateAuthStorageData restores the dw_dnt cookie value (the source of truth) after clearing storage, and login and refresh calls forward it to SLAS as the dnt parameter.
There’s no client-side cookie sync. The bridge is the dwsid cookie:
- The SDK extracts
dwsidfrom the SLAS response’sSet-Cookieheader, and the middleware persists it. dwsid(anddw_dnt) aren’t namespaced, so the B2C Commerce cartridge can read and write them directly.- The browser sends these cookies on every request. On the next full request, the React storefront’s server middleware reads them from the
Cookieheader. There’s no real-time iframe or single-page-app sync in middleware scope.
For more about hybrid implementations, see Use Storefront Next in a Hybrid Implementation.
Auth is available immediately during server-side rendering (SSR) and hydration without serializing any tokens:
- Server: The root loader builds the full
SessionDatathroughgetAuth(context), then returns onlyclientAuth = getPublicSessionData(session)—aPickofuserType,customerId,usid,encUserId, andtrackingConsent. - Client: The root renders
<AuthProvider value={clientAuth}>directly. Components that readuseAuth()see exactly the same data that produced the SSR markup, so there’s no hydration gap and no bootstrap snapshot. - Subsequent navigations: React Router re-invokes the root loader, which re-derives
clientAuth.AuthProviderstays mounted, and the root memoizes the provider tree onclientAuth, so only itsvaluechanges anduseAuth()consumers re-render.
The server-side auth during hydration keeps a single source of truth, exposes no tokens to the client, and maintains a stable provider tree.
SessionData and PublicSessionData are custom types defined in the template, not part of React Router’s sessions and cookies API. Storefront Next doesn’t use React Router’s createCookieSessionStorage because auth requires individual cookies with separate expiry times—access tokens expire in minutes, while refresh tokens persist for days. React Router’s session API packs all data into a single cookie, which doesn’t support these requirements.
- Server versus client: Use
getAuth(context)in loaders and actions, anduseAuth()in components. Tokens are server-only. - No direct cookie writes: Always go through
updateAuth()ordestroyAuth()—the middleware owns cookie serialization. - User-type checks: Use
auth.userType, which is JWT-derived. - Token refresh: Handled automatically by the middleware. Don’t refresh manually in routes.
- Capture before swap: On guest-to-registered login, snapshot guest resources before the token swap and merge after.
- Security: Never log or expose
accessTokenorrefreshToken. - No
clientLoaderorclientAction: Route modules use the serverloaderandactiononly.
For a comparison with the PWA Kit approach, see Storage and Sessions Differences with PWA Kit.