This project uses a split-cookie architecture for managing SLAS authentication tokens. The implementation separates auth concerns between server and client, with automatic token refresh and session management.
We maintain separate authentication contexts for server and client, plus a React context for components:
- Server-side middleware (
auth.server.ts): Manages auth tokens, writes cookies viaSet-Cookieheaders - Client-side middleware (
auth.client.ts): Reads auth cookies, maintains in-memory cache, initializes router context - React Context + root provider (
AuthContext+AuthProvider): Components consume auth via a React context that is always provided by the rootAppcomponent. During hydration, the root uses abootstrapAuthvalue derived from cookies; after hydration, it uses loader-based session data.
- Server middleware detects or creates user session with SLAS tokens
- Server writes auth data to separate cookies via
Set-Cookieheaders - Browser receives and stores cookies automatically
- Cookies are written with the latest tokens and user metadata
- On the client, a bootstrap auth snapshot (
bootstrapAuth) is derived from cookies once at module load time and used by the rootAppas a fallback during hydration - Client middleware initializes router context during execution and maintains in-memory cache for further use of authData
- On subsequent client-side navigations, client middleware reads cookies and validates tokens; server is only involved on full page refreshes
Authentication data is stored in separate cookies, each with specific purpose and expiry:
| Cookie Name | Purpose | User Type | Expiry | HttpOnly |
|---|---|---|---|---|
cc-nx-g |
Guest refresh token | Guest only | 30 days (max) | No |
cc-nx |
Registered refresh token | Registered only | 90 days (max) | No |
cc-at |
Access token | Both | 30 minutes | No |
usid |
User session ID | Both | Matches refresh token | No |
customerId |
Customer ID | Registered only | Matches refresh token | No |
cc-idp-at |
IDP access token (social login) | Both | Matches access token | No |
cc-cv |
OAuth2 PKCE code verifier (Temporary cookie deleted after successful token call via PKCE flow) | Both | 5 minutes | Yes |
Key Design Decisions:
- Mutually Exclusive Refresh Tokens: Only ONE refresh token cookie exists at a time (
cc-nx-gORcc-nx, never both) - User Type Derivation:
userTypeis NEVER stored in cookies. It's derived at runtime from which refresh token cookie exists - Cookie Namespacing: All cookies are automatically namespaced with
siteId(e.g.,cc-nx_RefArch) - HttpOnly Exception: Only
cc-cv(code verifier) useshttpOnly: truefor security; others usehttpOnly: falseto allow client-side JavaScript to read auth data from cookies (required for AuthContext default value and client middleware). - Browser Auto-Cleanup: Cookies include expiry dates, so browser automatically deletes expired cookies. Cookies are also deleted on shopper logout.
User type is determined by which refresh token cookie exists:
// Server-side (auth.server.ts)
if (refreshTokenRegistered) {
userType = 'registered';
refreshToken = refreshTokenRegistered;
} else if (refreshTokenGuest) {
userType = 'guest';
refreshToken = refreshTokenGuest;
} else {
userType = 'guest'; // Fallback - will trigger guest login
refreshToken = null;
}On user type transition (e.g., guest → registered), the old refresh token cookie is explicitly deleted by the server.(Set-Cookie: cc-nx-g="")
Access Token Expiry:
- Extracted directly from JWT
expclaim (source of truth) - Decoded once during middleware initialization
- Fast numeric comparison at runtime:
accessTokenExpiry > Date.now() - No repeated JWT decoding needed
Refresh Token Expiry:
- Configurable via environment variables (with Commerce Cloud maximum limits enforced)
- Guest tokens: 30 days maximum
- Registered tokens: 90 days maximum
Configure refresh token expiry and cookie settings in your .env file:
# Optional: Override guest refresh token expiry (max 30 days)
PUBLIC_COMMERCE_API_GUEST_REFRESH_TOKEN_EXPIRY_SECONDS=2592000
# Optional: Override registered refresh token expiry (max 90 days)
PUBLIC_COMMERCE_API_REGISTERED_REFRESH_TOKEN_EXPIRY_SECONDS=7776000
# Optional: Set cookie domain for cross-subdomain sharing
PUBLIC_COOKIE_DOMAIN=.yourstore.comCookie settings are managed via getCookieConfig() with precedence:
- Environment variables (highest priority)
- Provided options (function arguments)
- Default values (path, sameSite, secure)
import { getCookieConfig } from '@/lib/cookie-utils';
// Uses environment config + defaults
const config = getCookieConfig({ httpOnly: false }, context);Use the getAuth() helper in loaders and actions:
import { getAuth } from '@/middlewares/auth.server';
import type { LoaderFunctionArgs } from 'react-router';
export async function loader({ context }: LoaderFunctionArgs) {
const auth = getAuth(context);
// Access auth properties
const accessToken = auth.access_token;
const customerId = auth.customer_id;
const userType = auth.userType; // 'guest' | 'registered'
const usid = auth.usid;
// Check if user is authenticated
const isGuest = auth.userType === 'guest';
const isRegistered = auth.userType === 'registered';
return { customerId, isRegistered };
}Use the same getAuth() helper in client loaders:
import { getAuth } from '@/middlewares/auth.client';
import type { ClientLoaderFunctionArgs } from 'react-router';
export async function clientLoader({ context }: ClientLoaderFunctionArgs) {
const auth = getAuth(context);
// Same API as server-side
const accessToken = auth.access_token;
const isRegistered = auth.userType === 'registered';
return { isRegistered };
}Use updateAuth() to update auth state after login:
import { updateAuth } from '@/middlewares/auth.server';
import { 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 {
// Call SLAS login endpoint
const tokenResponse = await loginRegisteredUser(context, email, password);
// Update auth storage and cookies
updateAuth(context, tokenResponse);
return redirect('/account');
} catch (error) {
return { error: 'Login failed' };
}
}Use destroyAuth() to clear all auth cookies:
import { destroyAuth } from '@/middlewares/auth.server';
import type { ActionFunctionArgs } from 'react-router';
export async function action({ context }: ActionFunctionArgs) {
// Clear all auth cookies and storage
destroyAuth(context);
return redirect('/');
}The auth system supports OAuth2 PKCE flow for social login providers (Google, Facebook, etc.):
import { generateCodeVerifier, generateCodeChallenge } from '@/utils/pkce';
import { updateAuth } from '@/middlewares/auth.server';
// Step 1: Generate PKCE challenge and redirect to IDP
export async function loader({ context }: LoaderFunctionArgs) {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store code verifier in httpOnly cookie (server-only, 5 min expiry)
const auth = getAuth(context);
updateAuth(context, (data) => ({
...data,
codeVerifier, // Automatically stored in cc-cv cookie
}));
// Redirect to IDP with code challenge
const authUrl = `${idpUrl}?code_challenge=${codeChallenge}`;
return redirect(authUrl);
}
// Step 2: Handle OAuth callback
export async function callbackAction({ request, context }: ActionFunctionArgs) {
const url = new URL(request.url);
const code = url.searchParams.get('code');
// Retrieve code verifier from cookie
const auth = getAuth(context);
const codeVerifier = auth.codeVerifier;
// Exchange code for tokens (using codeVerifier for PKCE verification)
const tokenResponse = await exchangeCodeForTokens(code, codeVerifier);
// Update auth with new tokens (code verifier cookie is auto-deleted)
updateAuth(context, tokenResponse);
return redirect('/account');
}For custom auth workflows, use the provided server-side helpers:
import {
loginGuestUser,
loginRegisteredUser,
refreshAccessToken,
authorizePasswordless,
getPasswordLessAccessToken,
getPasswordResetToken,
resetPasswordWithToken,
} from '@/middlewares/auth.server';
// Guest login
const guestTokens = await loginGuestUser(context, { usid: 'optional-usid' });
// Refresh access token
const newTokens = await refreshAccessToken(context, refreshToken);
// Passwordless login (magic link)
await authorizePasswordless(context, {
userid: 'user@example.com',
redirectPath: '/account',
});
// Get token from magic link
const tokens = await getPasswordLessAccessToken(context, magicLinkToken);
// Password reset flow
await getPasswordResetToken(context, { email: 'user@example.com' });
await resetPasswordWithToken(context, {
email: 'user@example.com',
token: 'reset-token',
newPassword: 'newPassword123',
});- User visits site without cookies
- Server middleware detects no auth cookies
- Server calls SLAS guest login endpoint
- Server writes
cc-nx-g,cc-at,usidcookies viaSet-Cookie - Browser stores cookies and renders page
- On client hydration,
AuthContextdefault value reads cookies at module load time - Client middleware reads cookies into in-memory cache and initializes router context
- User visits site with valid cookies
- Server middleware reads cookies from
Cookieheader - Server validates access token expiry (fast JWT check)
- If valid, server proceeds with existing tokens
- If access token expired but refresh token valid, server refreshes
- Updated tokens written back via
Set-Cookieheaders
- Guest user submits login form
- Server action calls
loginRegisteredUser() - SLAS returns registered user tokens with
customer_id - Server calls
updateAuth()with token response - Server middleware writes
cc-nx,cc-at,usid,customerIdcookies - Server middleware deletes old
cc-nx-gcookie (mutual exclusivity) - On next request, server detects
cc-nxcookie →userType = 'registered'
- User clicks logout button
- Server action calls
destroyAuth(context) - Server middleware deletes all auth cookies via
Set-Cookiewithexpires=Thu, 01 Jan 1970 - Browser receives response and deletes cookies
- On next request, server detects no cookies → new guest login
- External system (e.g., ECOM cartridge) updates auth cookies
- User navigates to new page in React app
- Full page load: Server middleware reads updated cookies from
Cookieheader and validates tokens - Client-side navigation: Client middleware reads updated cookies from
document.cookieand syncs in-memory cache - AuthProvider in
root.tsxupdates React Context with latest auth state - App reflects new auth state automatically
The server and client use the same validation logic:
1. Check if access token exists and not expired (JWT exp claim)
✅ If valid → use it
❌ If expired → proceed to step 2
2. Check if refresh token exists
✅ If exists → call refresh endpoint for new access token
❌ If missing → proceed to step 3
3. Fallback to guest login
→ Get new guest tokens
→ Write cookies
To prevent React hydration mismatches while keeping auth tokens out of serialized loader data, auth is made available immediately during hydration via a combination of:
- A bootstrap snapshot of auth data derived from cookies on the client (
bootstrapAuth) - A root-level
AuthProviderthat always wraps the app and chooses between loader-based session data andbootstrapAuth
// providers/auth.tsx
export const bootstrapAuth: SessionData | undefined =
typeof window === 'undefined'
? undefined
: (getAuthDataFromCookies() as SessionData | undefined);
export const AuthContext = createContext<SessionData | undefined>(undefined);// root.tsx (simplified)
import AuthProvider, { bootstrapAuth } from '@/providers/auth';
export default function App({ loaderData: { auth, /* ... */ } }: { loaderData: LoaderData }) {
const loaderSession = auth?.();
const sessionData = loaderSession ?? bootstrapAuth;
const providers = useMemo(
() =>
[
[AuthProvider, { value: sessionData }],
// other providers...
] as const,
[sessionData]
);
return <ComposeProviders providers={providers}>{/* app */}</ComposeProviders>;
}- On the server:
- Middleware builds a
SessionDataobject. - The root loader returns
auth: () => sessionto avoid serializingSessionDatainto the HTML/data payload. bootstrapAuthis alwaysundefinedon the server.
- Middleware builds a
- On the client during initial hydration:
bootstrapAuthis computed once from cookies at module load time.- Before
clientLoaderruns,auth?.()returnsundefined, sosessionData = bootstrapAuth. - The root always renders
<AuthProvider value={sessionData}>, so components usinguseAuth()see cookie-derived auth that matches the SSR markup.
- After
clientLoaderand on subsequent navigations:- The client loader recomputes auth from the middleware/client context.
auth?.()now returns liveSessionData, sosessionData = loaderSession.AuthProviderstays mounted; only itsvaluechanges, anduseAuth()consumers re-render with the updated auth.
This keeps:
- A single source of truth for live auth state in the middleware/client loader pipeline
- Cookie-based bootstrap only for the hydration gap
- A stable provider tree (no conditional
AuthProvidermounting/unmounting) - No token serialization into loader JSON or HTML
- Server vs Client: Use
getAuth()in loaders/actions; same API works in both environments - Cookie Management: Never write cookies directly; use
updateAuth()ordestroyAuth() - User Type Checks: Always use
auth.userTypeto determine guest vs registered - Token Refresh: Middleware handles automatic refresh; no manual intervention needed
- Security: Never log or expose
access_tokenorrefresh_tokenvalues - PKCE Flow: Always use
httpOnly: trueforcode_verifierin OAuth2 flows - Error Handling: Check for
auth.errorproperty to detect auth failures
The project includes TypeScript types for all auth operations:
import type { AuthData, AuthStorageData } from '@/middlewares/auth.utils';
// AuthData includes:
interface AuthData {
access_token?: string;
refresh_token?: string;
access_token_expiry?: number;
refresh_token_expiry?: number;
usid?: string;
customer_id?: string;
userType?: 'guest' | 'registered';
idp_access_token?: string;
codeVerifier?: string;
}src/
├── middlewares/
│ ├── auth.server.ts # Server auth middleware & SLAS operations
│ ├── auth.client.ts # Client auth middleware, token sync & router context init
│ └── auth.utils.ts # Shared auth utilities & cookie names
├── providers/
│ └── auth.tsx # AuthContext + AuthProvider with bootstrapAuth used at the root for hydration
└── lib/
├── cookies.server.ts # Server cookie utilities (Node.js)
├── cookies.client.ts # Client cookie utilities (browser)
└── cookie-utils.ts # Shared cookie config & namespacing