spartacus⚿site⚿cart and the “Cart is not anonymous” error after session expiry
Summary
After a session expires, the previous user’s active cart id can stay in localStorage (spartacus⚿site⚿cart). When another user logs in, Spartacus tries to merge that cart into the new user’s cart as if it were anonymous, and the backend rejects it with “Cart is not anonymous”.
Fixes made:
- A new always-loaded service clears the cart key on every logout.
- The auth interceptor now logs the user out when the refresh token is rejected with 400
invalid_grant. - The auth interceptor no longer crashes when the cart key is missing.
1. How the key works
Where it comes from
The key is written by Spartacus, not by project code.
MultiCartStatePersistenceService.initSync()(@spartacus/cartbase core) callsStatePersistenceService.syncWithStoragewith:key: 'cart'- context: base site (
environment.OCC_BASE_SITE=site, seesrc/app/spartacus/spartacus-configuration.module.ts) - storage:
localStorage
- Key format:
spartacus⚿<baseSite>⚿cart, so here it isspartacus⚿site⚿cart. - Value:
{"active":"<cartId>"}. initSync()runs once, when the cart base feature is initialised.
When it is written
It is rewritten whenever cart.index.Active changes in the NgRx store (distinctUntilKeyChanged('Active')).
| Trigger | New active value |
|---|---|
CreateCart / CreateCartSuccess with extraData.active | new cart id (guid for anonymous users, code for logged-in users) |
LoadCart with cartId === 'current' (login, reloadCart('current'), ASM bind, reorder) | '' (temporarily) |
LoadCartSuccess with extraData.active | loaded cart id, or '' for a saved cart |
SetActiveCartId(id) (on startup, from the stored value) | id |
When it is reset to {"active":""}
RemoveCartorDeleteCartSuccessfor the active cart (clear cart dialog, temp cart after merge, cart not found, order placed).ClearCartState.- Logout: the
clearMultiCartStatemeta-reducer wipes cart state onAuthActions.LOGOUT.
Spartacus never removes the key. persistToStorage only calls setItem and skips undefined.
Startup read
On init (and on base-site change), syncWithStorage reads the key first and calls onRead. That dispatches ClearCartState, then SetActiveCartId(stored.active || '').
Project code that touches the key
| File | Usage |
|---|---|
src/app/core/services/select-branch/select-branch-component.service.ts (onSuccess) | removeItem after a branch switch |
src/app/core/services/auth-interceptor.service.ts | reads .active to send currentCartId |
src/app/features/checkout/checkout-delivery-address/checkout-delivery-address.component.ts | reads the key |
src/app/core/services/mini-cart/mini-cart-component.service.ts | reads via readStateFromStorage |
src/app/core/services/cart-storage-reset.service.ts (new) | removes on logout |
2. Session expiry handling
Handled in CustomAuthInterceptorService (src/app/core/services/auth-interceptor.service.ts) and CustomAuthHttpHeaderService (src/app/core/services/auth-http-header.service.ts).
| Response | Action |
|---|---|
401 with InvalidTokenError | handleExpiredAccessToken: refresh the token and retry. If it works, the user stays logged in and the cart key does not change. |
500 from authorizationserver/oauth/token | handleExpiredRefreshToken |
401 invalid_token from the token endpoint | handleExpiredRefreshToken |
400 invalid_grant on a refresh-token request | handleExpiredRefreshToken (added in this fix) |
| No refresh token available | handleExpiredRefreshToken |
handleExpiredRefreshToken() calls authService.coreLogout(), which dispatches AuthActions.Logout, and then redirects to /login.
3. The bug
Steps to reproduce
- Log in as Account A.
- Add a product to the cart.
- Close the browser tab.
- The next day, open the site directly by URL. You are redirected to the login page.
- In DevTools,
spartacus⚿site⚿cartstill holds A’s cart id. - Log in as Account B.
- You are redirected to the homepage with the error “Cart is not anonymous”.
Root cause
- The cart library is lazy-loaded (
CART_BASE_FEATUREinsrc/app/spartacus/features/cart/cart-base-feature.module.ts).MultiCartStatePersistenceService, the only code that writes the key, is inside that lazy chunk. - In step 4, the first API call fails and
handleExpiredRefreshToken()logs the user out. If the cart chunk has not loaded yet, nothing reacts to the logout, so A’s cart id stays in localStorage. - In step 6, the cart chunk loads, reads the key and sets A’s cart id as the active cart. The previous user id is now anonymous, so
ActiveCartService.loadOrMergecallsmergeToCurrentCart. That sendsPOST /users/B/carts?oldCartId=<A's cart>, and the backend rejects it because A’s cart is not anonymous.
Related issues found
- The interceptor did not handle 400
invalid_grantfrom the token endpoint (errorIsInvalidGrant()existed but was never called). Stock Spartacus logs out in this case. If the backend returns 400 for an expired refresh token, no logout happened at all. JSON.parse(localStorage.getItem('spartacus⚿site⚿cart')).activein the interceptor throws when the key is missing.
4. Changes
New: src/app/core/services/cart-storage-reset.service.ts
Listens for Spartacus’s LogoutEvent, which is part of core and always loaded, and removes spartacus⚿<baseSite>⚿cart for the current base site. It covers session expiry, normal logout and ASM logout. Removing the key is safe: Spartacus treats a missing key the same as {"active":""}.
The key is built in the same format as StatePersistenceService.generateKeyWithContext, which is protected and cannot be called directly.
this.eventService
.get(LogoutEvent)
.pipe(
switchMap(() =>
this.siteContextParamsService
.getValues([BASE_SITE_CONTEXT_ID])
.pipe(take(1))
)
)
.subscribe((context) =>
this.winRef.localStorage?.removeItem(
`spartacus⚿${([] as string[]).concat(context).join('⚿')}⚿cart`
)
);
Changed: src/app/app.module.ts
Registers the service at startup:
provideAppInitializer(() => inject(CartStorageResetService).initSync()),
Changed: src/app/core/services/auth-interceptor.service.ts
New case 400 in the error handler. The grant_type check stops a wrong password at login (also 400 invalid_grant) from triggering a logout.
case 400:
if (
this.errorIsInvalidGrant(errResponse) &&
request.body?.get?.('grant_type') === 'refresh_token'
) {
this.customAuthHttpHeaderService.handleExpiredRefreshToken();
}
break;
The currentCartId param now handles a missing key and sends '', the same value sent when the key held {"active":""}:
const cartState = JSON.parse(localStorage.getItem('spartacus⚿site⚿cart') ?? 'null');
params = params.append('currentCartId', cartState?.active ?? '');
5. Verification
Status: the changed files type-check cleanly. The fix has not been tested in a browser yet.
To test without waiting overnight:
- Log in as Account A and add a product to the cart.
- In DevTools > Application > Local Storage, edit
spartacus⚿⚿auth: makeaccess_tokeninvalid and setrefresh_tokento a value the server will reject. - Reload the page. You should be redirected to the login page, and
spartacus⚿site⚿cartshould be gone. - Log in as Account B. B’s own cart should load with no “Cart is not anonymous” error.
Also check which status the token endpoint returns for a rejected refresh token (400 invalid_grant or 500). Both now trigger a logout.
6. Known leftovers
auth-interceptor.service.tsstill has a strict-tscerror wherelocalStorage.getItem('spartacus⚿⚿auth')(string | null) is passed to a function that needs astring. It was there before these changes and was left as is.- The site id
siteis hardcoded in several reads of the cart key.
