Agent Skills: Storefront Next Authentication

Implement authentication in Storefront Next using split-cookie architecture, SLAS tokens, and auth middleware. Use when accessing user identity in loaders, detecting guest vs registered users, using getAuth or useAuth, or understanding session management and token refresh.

UncategorizedID: SalesforceCommerceCloud/b2c-developer-tooling/sfnext-authentication

Install this agent skill to your local

pnpm dlx add-skill https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/tree/HEAD/skills/storefront-next/skills/sfnext-authentication

Skill Files

Browse the full folder contents for sfnext-authentication.

Download Skill

Loading file tree…

skills/storefront-next/skills/sfnext-authentication/SKILL.md

Skill Metadata

Name
sfnext-authentication
Description
>-

Storefront Next Authentication

Authentication is server-only. One middleware (src/middlewares/auth.server.ts, registered in the middleware array of src/root.tsx) runs on every request: it reads cookies, validates or refreshes the SLAS access token, performs a guest login when nothing usable exists, and writes Set-Cookie. Tokens never reach the browser. Components only see a small non-sensitive slice through useAuth().

Read docs/README-AUTH.md in your project for the full reference; this skill is the task-oriented summary. Route modules use server loader/action only, never clientLoader/clientAction.

Request flow

  1. Valid access token (JWT exp in the future): use it.
  2. Expired access token with a refresh token: refresh, rewrite cc-at.
  3. No tokens: guest login.
  4. Registered shopper whose refresh fails: 307 to /login?returnUrl=<path>&error=session_expired (site/locale prefix included) and a fresh guest session is created in parallel. Guests are silently re-issued a guest session.
  5. A SCAPI call returning 401 throws AuthTokenInvalidError; the middleware clears state, re-runs login/refresh and 307-redirects to the same URL. The short-lived cc-auth-recover cookie (30 s) prevents loops.

User type comes from the access-token JWT (registered tokens carry an rcid claim), not from which cookie exists. customerId is not a cookie; it is derived per request from the JWT.

Read auth on the server

import { getAuth } from '@/middlewares/auth.server';
import type { LoaderFunctionArgs } from 'react-router';

export async function loader({ context }: LoaderFunctionArgs) {
    const auth = getAuth(context); // full SessionData, server only
    const isRegistered = auth.userType === 'registered';
    return { isRegistered };       // never return auth.accessToken / refreshToken
}

Read auth in components

import { useAuth } from '@/providers/auth';

function AccountBadge() {
    const auth = useAuth(); // PublicSessionData | undefined
    return auth?.userType === 'registered' ? <span>Welcome back</span> : <SignInLink />;
}

PublicSessionData is exactly customerId, userType, usid, encUserId, trackingConsent. The root loader derives it with getPublicSessionData (from @/middlewares/auth.utils) and passes it to AuthProvider; there is no client getAuth.

Protect a route

Branch inside the server loader. Prefer the existing account layout (src/routes/_app.account.tsx) for anything under /account instead of adding per-route checks.

import { redirect } from 'react-router';
import { getAuth } from '@/middlewares/auth.server';
import { buildUrlFromContext } from '@/lib/url.server';
import { routes } from '@/route-paths';

export async function loader({ context }: LoaderFunctionArgs) {
    if (getAuth(context).userType !== 'registered') {
        throw redirect(buildUrlFromContext(routes.login, context)); // keeps site/locale prefix
    }
    // ...
}

Log in and out

Never write auth cookies yourself. Use the helpers exported from @/middlewares/auth.server:

import { loginRegisteredUser, updateAuth } from '@/middlewares/auth.server';

export async function action({ request, context }: ActionFunctionArgs) {
    const form = await request.formData();
    const tokens = await loginRegisteredUser(context, String(form.get('email')), String(form.get('password')));
    updateAuth(context, tokens);            // re-derives userType/customerId/usid, swaps cc-nx-g for cc-nx
    return redirect('/account');
}

updateAuth accepts a SLAS token response or an updater function (updateAuth(context, (cur) => ({ ...cur, codeVerifier }))). Logout: destroyAuth(context) clears every auth cookie on the response; the shipped src/routes/_empty.logout.ts also calls clients.auth.logout and destroyBasket. Prefer src/lib/api/auth/standard-login.server.ts and social-login.server.ts over hand-rolled flows: they already handle the merge below.

Other exported helpers: loginGuestUser, refreshAccessToken, authorizePasswordless, getPasswordLessAccessToken, getPasswordResetToken, resetPasswordWithToken, requestOtp, verifyOtp, flashAuth(context, message) (clear session plus an error message), clearInvalidSessionAndRestoreGuest(context) (deleted customer or corrupted session).

Guest to registered merge

customerId changes from the guest gcid to the registered rcid at the token swap, and SCAPI rejects the guest id under the registered token. So: snapshot guest-owned data (wishlist) before the swap, then mergeBasket and mergeWishlist after. src/lib/api/auth/social-login.server.ts is the reference implementation. dw_dnt tracking consent is preserved across the swap.

Common SCAPI calls for account data

These are real operations: clients.shopperCustomers.getCustomerOrders, getCustomerProductLists (wishlists), getCustomer. Use the existing helpers (src/lib/api/order.server.ts, wishlist.server.ts, customer.server.ts) rather than calling clients directly. Client setup and the @/lib/api-clients.server import are covered in storefront-next:sfnext-scapi.

Configuration

| Setting | Where | |---|---| | Refresh-token lifetime | PUBLIC_COMMERCE_API_GUEST_REFRESH_TOKEN_EXPIRY_SECONDS (max 30 days), PUBLIC_COMMERCE_API_REGISTERED_REFRESH_TOKEN_EXPIRY_SECONDS (max 90 days) | | Feature flags | features.passwordlessLogin, features.passkey, features.socialLogin, features.otpRequest, auth.otpLength in config.server.ts | | Private SLAS client | commerce.api.privateKeyEnabled (required for passwordless / OTP) | | Cookie domain | app.cookies.domain (see storefront-next:sfnext-security) |

secure on cookies is only set on deployed (HTTPS) environments; local pnpm dev over http://localhost writes non-secure cookies so Safari keeps them.

Pitfalls

  • Reading tokens with document.cookie: impossible by design (httpOnly). Use useAuth() or loader data.
  • Returning getAuth(context) from a loader leaks tokens into the page. Return only derived values.
  • Refreshing tokens manually in routes: the middleware already does it.
  • Logging accessToken or refreshToken: never.
  • Changing the cookie domain on a live site leaves old host-only cookies shadowing new ones; roll out in a quiet window.
  • A loop of redirects to /login?error=session_expired usually means refresh tokens are being rejected (SLAS client config, or cookie domain mismatch between hosts).

References

  • COOKIES.md: full cookie inventory, namespacing, expiry, hints.
  • LOGIN-FLOWS.md: passwordless, social, OTP, passkeys, password reset, guest order lookup, email cartridge prerequisites.
  • In your project: docs/README-AUTH.md, docs/README-EMAIL-VERIFICATION.md, docs/README-GUEST-ORDER-LOOKUP.md, docs/README-EMAIL-CARTRIDGE.md.

Related Skills

  • storefront-next:sfnext-security - Turnstile bot protection, CSP for social providers, cookie domain
  • storefront-next:sfnext-scapi - SCAPI clients and custom APIs
  • storefront-next:sfnext-hybrid-storefronts - shared dwsid session bridge with SFRA
  • storefront-next:sfnext-analytics-consent - dw_dnt tracking consent
  • storefront-next:sfnext-configuration - config.server.ts and PUBLIC__ overrides
  • b2c-cli:b2c-slas - manage SLAS clients and scopes