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.
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.
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 auth cookies across subdomains, set a cookies.domain on the relevant site in config.server.ts (under app.commerce.sites). A leading-dot value scopes the cookie to the apex domain and all subdomains:
Cookie attribute defaults (path: '/', sameSite: 'lax', secure: true) are applied automatically. The site cookies.domain takes precedence over them.
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.
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 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.