---
schema_version: "1"
id: fb957d868de229fc04028537218e1d236b757a41623d4dac4b0812b66a76cbcc
title: Storage and Sessions
collection: developer
version: current
locale: en-us
portal_url: https://docs.salesforce.com/developer/current/en-us/commerce-cloud/sfnext/guides/sfnext-storage-sessions.md
canonical_url: https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/sfnext-storage-sessions.html
source_site: developer.salesforce.com
content_type: documentation
content_hash: e569f7cf90294c6a46832090d209c023a417b0f0db0ed4925d11d2f4729b5f88
products:
  - Composable Storefront
  - Salesforce APIs
---
# 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.

## Architecture Overview

Storefront Next auth has two layers and a single source of truth:

1. **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 the `Cookie` header, validates and refreshes tokens, performs guest login when needed, and writes updated cookies through `Set-Cookie` headers.
2. **React context** (`providers/auth.tsx`) exposes a `PublicSessionData` object (no tokens) to components through the `useAuth()` hook. The root loader extracts this slice with `getPublicSessionData(session)` and passes it to the provider.

Route modules use only the server `loader` and `action`—never `clientLoader` or `clientAction`.

### Request Flow

```
Browser request (full load or client navigation)
        │
        ▼
auth.server.ts middleware
  • parse cookies from the Cookie header
  • decode access-token JWT (userType, customerId, usid, expiry, tracking consent)
  • if the access token is valid → use it
    else if a refresh token is present → refresh
      if refresh fails AND user was registered → 307 redirect to /login?returnUrl=...&error=session_expired
      if refresh fails AND user was guest → guest login
    else → guest login
  • write updated tokens and metadata through Set-Cookie
        │
        ▼
root loader: getAuth(context) → full SessionData
             getPublicSessionData(session) → clientAuth (no tokens)
        │
        ▼
<AuthProvider value={clientAuth}> → useAuth() in components
```

## Cookie Architecture

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                           |

**note:** **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.

### Key Design Decisions

- **Mutually exclusive refresh tokens:** Only one of `cc-nx-g` (guest) or `cc-nx` (registered) exists at a time. On a user-type transition, the middleware explicitly deletes the other with a `Set-Cookie` header that uses an expired date.
- **`userType` is 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, _except_ `dwsid` and `dw_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.

### User Type Detection

`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.

### Token Expiry Management

- **Access token:** The expiry is read from the JWT `exp` claim, 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).

### 401 Recovery Redirect

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.

### Registered Session Expiry Redirect

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.

### JWT Integrity Validation

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.

## Configuration

### Environment Variables (Optional)

```bash
# Override guest refresh token expiry (capped at 30 days)
PUBLIC_COMMERCE_API_GUEST_REFRESH_TOKEN_EXPIRY_SECONDS=2592000

# Override registered refresh token expiry (capped at 90 days)
PUBLIC_COMMERCE_API_REGISTERED_REFRESH_TOKEN_EXPIRY_SECONDS=7776000
```

### Cookie Domain

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:

```typescript
// config.server.ts — global cookie domain
export default {
  app: {
    cookies: {
      domain: ".example.com", // applies to all sites unless overridden
    },
  },
  commerce: {
    sites: [
      {
        id: "RefArch",
        cookies: { domain: ".us.example.com" }, // per-site override
        defaultLocale: "en-US",
        // …
      },
      {
        id: "RefArchGlobal",
        // inherits global .example.com
        defaultLocale: "en-GB",
        // …
      },
    ],
  },
};
```

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](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/sfnext-domain-cookie.md) guide.

## Accessing Session Data

### On the Server

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)`:

```typescript
import { getAuth } from "@/middlewares/auth.server";
import type { LoaderFunctionArgs } from "react-router";

export async function loader({ context }: LoaderFunctionArgs) {
  const auth = getAuth(context);

  const accessToken = auth.accessToken; // server only
  const customerId = auth.customerId;
  const userType = auth.userType; // 'guest' | 'registered'
  const usid = auth.usid;

  return { customerId, isRegistered: userType === "registered" };
}
```

### In Client Components

There's no client-side `getAuth`. Client components read the non-sensitive slice through the `useAuth()` hook—tokens are never available on the client:

```typescript
import { useAuth } from "@/providers/auth";

function AccountBadge() {
  const auth = useAuth(); // PublicSessionData | undefined

  if (auth?.userType === "registered") {
    return <span>Welcome back</span>;
  }
  return <SignInLink />;
}
```

For route-level auth checks, branch inside the server `loader`—not in a client guard.

## Updating and Destroying Auth

### Updating Auth (Login)

`updateAuth(context, updater)` accepts either a SLAS token response or a function updater:

```typescript
import { updateAuth, loginRegisteredUser } from "@/middlewares/auth.server";
import type { ActionFunctionArgs } from "react-router";

export async function action({ request, context }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  try {
    const tokenResponse = await loginRegisteredUser(context, email, password);
    updateAuth(context, tokenResponse); // token-response form
    return redirect("/account");
  } catch {
    return { error: "Login failed" };
  }
}
```

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.

### Destroying Auth (Logout)

```typescript
import { destroyAuth } from "@/middlewares/auth.server";

export async function action({ context }: ActionFunctionArgs) {
  destroyAuth(context); // clears storage; middleware deletes all auth cookies on the response
  return redirect("/");
}
```

### Social Login (OAuth2 PKCE)

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:

```typescript
import { getAuth, updateAuth } from "@/middlewares/auth.server";

// Step 1 — generate PKCE challenge, store verifier, redirect to IDP
export async function loader({ context }: LoaderFunctionArgs) {
  const { url, codeVerifier } = await clients.auth.social
    .getAuthorizationUrl
    /* ... */
    ();
  updateAuth(context, (current) => ({ ...current, codeVerifier })); // stored in HttpOnly cc-cv
  return redirect(url);
}

// Step 2 — exchange code for tokens (verifier read from cc-cv); cc-cv auto-deleted on the next write
export async function action({ request, context }: ActionFunctionArgs) {
  const session = getAuth(context);
  const tokenResponse = await exchangeCodeForTokens(code, session.codeVerifier); // illustrative
  updateAuth(context, tokenResponse);
  return redirect("/account");
}
```

**important:** **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](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/sfnext-security-headers.md#configure-csp-for-social-login).

### Custom Auth Operations

The following server-side SLAS helpers are exported from `auth.server.ts`:

```typescript
import {
  loginGuestUser,
  loginRegisteredUser,
  refreshAccessToken,
  authorizePasswordless,
  getPasswordLessAccessToken,
  getPasswordResetToken,
  resetPasswordWithToken,
  requestOtp,
  verifyOtp,
  flashAuth,
  clearInvalidSessionAndRestoreGuest,
} from "@/middlewares/auth.server";
```

```typescript
const guestTokens = await loginGuestUser(context, { usid: "optional-usid" });
const newTokens = await refreshAccessToken(context, refreshToken);

// Passwordless (magic link)
await authorizePasswordless(context, { userid: "user@example.com", redirectPath: "/account" });
const tokens = await getPasswordLessAccessToken(context, magicLinkToken);

// OTP email verification (validates a code without creating a new session)
await requestOtp(context, { email: "user@example.com" });
await verifyOtp(context, { pwdActionToken, email: "user@example.com" });

// Password reset
await getPasswordResetToken(context, { email: "user@example.com" });
await resetPasswordWithToken(context, { email, token, newPassword });

// Recovery helpers
flashAuth(context, "Your session expired"); // clear session + set an error message
await clearInvalidSessionAndRestoreGuest(context); // e.g. deleted customer / corrupted session
```

For the related shopper features, see [Social Login](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/sfnext-social-login.md), [Passwordless Login](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/sfnext-passwordless-login.md), and [Password Reset](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/sfnext-password-reset.md).

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.

## Authentication Flows

### New Guest User

1. The user visits with no auth cookies.
2. The server middleware finds no usable token and calls SLAS guest login.
3. The server writes `cc-nx-g`, `cc-at`, and `usid` (and any `dwsid` from the response).
4. The root loader serializes `clientAuth`, and `AuthProvider` makes it available through `useAuth()`.

### Returning User (Token Valid)

1. The server reads cookies from the `Cookie` header.
2. If the access-token `exp` is in the future, the tokens are used as-is.
3. If the access token is expired but a refresh token exists, the server refreshes and rewrites `cc-at` (and `usid` if it changed).

### Guest to Registered (Login)

1. The action calls `loginRegisteredUser()`, and SLAS returns a registered token whose JWT carries `rcid`.
2. `updateAuth(context, tokenResponse)` derives `userType = 'registered'` from the JWT.
3. The middleware writes `cc-nx` and deletes `cc-nx-g` to maintain mutual exclusivity.
4. Guest resources are merged into the registered account (see below).

### Logout

1. The action calls `destroyAuth(context)`.
2. The middleware deletes all auth cookies through expired `Set-Cookie` headers (including the legacy `customer_id`).
3. The next request finds no cookies and triggers a fresh guest login.

### Guest to Registered Resource Merge

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:

```typescript
// lib/api/auth/social-login.server.ts (shape)
const guestWishlistSnapshot = await captureGuestWishlistSnapshot(context); // BEFORE swap
const result = await loginIDPUser(context /* ... */); // token swap (updateAuth)
await mergeBasket(context); // AFTER swap
await mergeWishlist(context, guestWishlistSnapshot); // AFTER swap
```

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.

### Hybrid Storefronts (B2C Commerce Session Bridge)

There's no client-side cookie sync. The bridge is the `dwsid` cookie:

1. The SDK extracts `dwsid` from the SLAS response's `Set-Cookie` header, and the middleware persists it.
2. `dwsid` (and `dw_dnt`) aren't namespaced, so the B2C Commerce cartridge can read and write them directly.
3. The browser sends these cookies on every request. On the next full request, the React storefront's server middleware reads them from the `Cookie` header. 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](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/sfnext-hybrid.md).

## Hydration

Auth is available immediately during server-side rendering (SSR) and hydration without serializing any tokens:

- **Server:** The root loader builds the full `SessionData` through `getAuth(context)`, then returns only `clientAuth = getPublicSessionData(session)`—a `Pick` of `userType`, `customerId`, `usid`, `encUserId`, and `trackingConsent`.
- **Client:** The root renders `<AuthProvider value={clientAuth}>` directly. Components that read `useAuth()` 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`. `AuthProvider` stays mounted, and the root memoizes the provider tree on `clientAuth`, so only its `value` changes and `useAuth()` consumers re-render.

```typescript
// providers/auth.tsx
export const AuthContext = createContext<PublicSessionData | undefined>(undefined);
export const useAuth = (): PublicSessionData | undefined => useContext(AuthContext);

// root.tsx (shape)
const session = getAuth(context); // full SessionData (server only)
const clientAuth = getPublicSessionData(session); // non-sensitive slice → loader data
// ...
<AuthProvider value={clientAuth}>{/* app */}</AuthProvider>;
```

The server-side auth during hydration keeps a single source of truth, exposes no tokens to the client, and maintains a stable provider tree.

## Type Safety

`SessionData` and `PublicSessionData` are custom types defined in the template, not part of React Router's [sessions and cookies API](https://reactrouter.com/explanation/sessions-and-cookies). 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.

```typescript
import type { SessionData, PublicSessionData } from "@/lib/api/types";

// Full server-side session (tokens included — never serialized to the client)
type SessionData = {
  accessToken?: string;
  accessTokenExpiry?: number;
  refreshToken?: string;
  refreshTokenExpiry?: number;
  customerId?: string;
  userType?: "guest" | "registered";
  usid?: string;
  encUserId?: string;
  codeVerifier?: string; // OAuth2 PKCE (server-only, ephemeral)
  idpAccessToken?: string;
  idpAccessTokenExpiry?: number;
  idToken?: string; // OIDC id_token (server-only)
  idpRefreshToken?: string; // social login (server-only)
  dwsid?: string; // hybrid storefront session bridge
  trackingConsent?: TrackingConsent;
};

// The only data exposed to the client (Pick — stays in sync with SessionData)
type PublicSessionData = Pick<
  SessionData,
  "customerId" | "userType" | "usid" | "encUserId" | "trackingConsent"
>;
```

## Best Practices

- **Server versus client:** Use `getAuth(context)` in loaders and actions, and `useAuth()` in components. Tokens are server-only.
- **No direct cookie writes:** Always go through `updateAuth()` or `destroyAuth()`—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 `accessToken` or `refreshToken`.
- **No `clientLoader` or `clientAction`:** Route modules use the server `loader` and `action` only.

## File Structure

```
src/
├── middlewares/
│   ├── auth.server.ts        # Server auth middleware, SLAS operations, cookie I/O, getAuth/updateAuth/destroyAuth
│   └── auth.utils.ts         # Cookie-name constants, JWT decoding/claims, userType derivation, getPublicSessionData
├── providers/
│   └── auth.tsx              # AuthContext + AuthProvider + useAuth() (PublicSessionData only)
└── lib/
    ├── cookie-utils.server.ts  # getCookieConfig, createCookie, namespacing, parseAllCookies
    ├── auth/                   # passwordless-login.server.ts, error-handler.ts
    └── api/auth/               # register, standard-login, social-login, reset-password (.server.ts)
```

For a comparison with the PWA Kit approach, see [Storage and Sessions Differences with PWA Kit](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/sfnext-pwa-storage-sessions.md).
