Skip to content

Authentication and sessions

A service never sees a cookie. Every request reaches it with a few headers, and the service turns those into two objects on the GraphQL context: the store context (which store, locale, and currency) and the client context (who is asking, and with which session). Resolvers read only those two.

The cookies stop at the GraphQL gateway, which turns them into those headers and back. The storefront holds the cookies, renews them, and reads the readable one to decide what to show. This page starts inside the service and works outward: the gateway and the storefront come last.

sequenceDiagram
  participant B as Browser
  participant S as Storefront (Next.js)
  participant G as Gateway (federated_token)
  participant SG as Subgraph
  B->>S: Request with the session cookies
  S->>G: GraphQL request, cookies forwarded
  G->>G: Decrypt the cookies into tokens
  G->>SG: X-Access-Token + store context headers
  SG-->>G: Answer, with a changed token when the session changed
  G-->>S: Answer + Set-Cookie with new session cookies
  S-->>B: Page + cookies
X-Access-Token the federated token
X-Refresh-Token the refresh tokens that go with it
X-StoreContext-StoreKey the store
X-StoreContext-Locale the language, such as en-US
X-StoreContext-Currency the currency, such as EUR
X-Client-IP the shopper's IP address

The federated token is one session that can hold a session for several engines at once, each under its own name (commercetools, commercecloud, medusa), plus values such as the active business unit. Because the storefront and gateway always carry this one format, they work the same whichever engine is behind them.

Inside the stack the token is plain encoded JSON. A service reads it without checking a signature, so it trusts whoever calls it. Keep services reachable only from the gateway.

request ──► context factory ──► useFederatedToken ──► useClientContext ──► loaders ──► resolver
(your service) reads the token loads the client │
context │
response ◄── useFederatedToken writes the token back if a resolver changed it ◄──────────┘
  1. Your context factory builds the per-request context. It reads the store context with readStoreContextFromRequest, which fails the request when one of the three store headers is missing, and the IP with createRequestContext. It creates the engine’s StoreContext and ClientContext, and puts the client context’s token on the context as federatedToken.
  2. useFederatedToken(), from @labdigital/federated-token-yoga, loads X-Access-Token and X-Refresh-Token into that token.
  3. useClientContext() hands X-Access-Token to the client context and waits for its load(). If loading fails, the request fails with “Error loading client context”.
  4. Application adds context.loaders. Each loader is built on first read, so it sees the loaded session.
  5. The resolver runs. It reads context.storeContext and context.clientContext, and guards itself with assertAuthenticated, assertNotAuthenticated, or assertActiveSession.
  6. On the response, useFederatedToken() sends the token back in X-Access-Token and X-Refresh-Token if a resolver changed it: a sign-in, a refresh, a sign-out, a new guest session, a business-unit switch.

In code, with the plugins in that order:

import { createApplication } from "@evolve-framework/core";
import { useClientContext } from "@evolve-framework/core/service/graphql";
import { useFederatedToken } from "@labdigital/federated-token-yoga";
const app = await createApplication({
name: "purchase",
module,
graphql: {
plugins: [useFederatedToken(), useClientContext()],
context: createContextFactory(clientFactory),
},
http: { address: { host: config.HTTP_HOST, port: config.HTTP_PORT } },
});
import {
ClientContext,
type ClientFactory,
CommercetoolsClientLoader,
StoreContext,
} from "@evolve-framework/commercetools";
import {
createRequestContext,
readStoreContextFromRequest,
type ServerContext,
} from "@evolve-framework/core/service/graphql";
import type { YogaInitialContext } from "graphql-yoga";
import { tokenManager } from "./commercetools.ts";
export const createContextFactory =
(clientFactory: ClientFactory) =>
async (serverContext: ServerContext & YogaInitialContext) => {
const { request } = serverContext;
const systemClient = clientFactory.getSystemRequestBuilder();
const storeContext = new StoreContext(
readStoreContextFromRequest(request),
systemClient,
);
const clientContext = new ClientContext(
storeContext,
new CommercetoolsClientLoader(clientFactory, tokenManager),
);
return {
...serverContext,
request: createRequestContext(request),
storeContext,
clientContext,
federatedToken: clientContext.token,
globalScopedClient: () => systemClient,
config: {},
};
};

federatedToken and clientContext.token must be the same object: the first plugin loads the token, and the client context reads it.

Core reads the three headers into a plain { storeKey, locale, currency }. A service that carries more, such as a sales channel, passes a reader per extra header to readStoreContextFromRequest.

The engine package wraps those values in its own StoreContext class. The commercetools one knows the price and locale parameters for product queries, looks up the store, and gives the cache key prefix that keeps one store’s cached answers from serving another. The Medusa one is created with StoreContext.resolve(), which rejects an unknown store or an unserved locale before any resolver runs.

Core asks little of a client context: isAuthenticated() and load(), plus setAccessToken() for the plugin and hasToken() for assertActiveSession. Each engine package implements its own, because each engine models sessions differently.

  • commercetools (ClientContext): a guest has a commercetools token whose subject is an anonymous_id, a signed-in customer one whose subject is a customer_id. For a signed-in customer, load() fetches the name, email, customer group, default payment method, and business context. That lookup is cached for 5 minutes per store, customer, and token. getClient() returns a commercetools client that acts as the shopper.
  • Salesforce Commerce Cloud (ClientContext): the session lives under commercecloud. load() fetches nothing, and getClient() throws SessionExpiredError once the session has expired.
  • Medusa (ClientContext): the session lives under medusa. load() fetches nothing, because the token already names the customer. A session is tied to the store it was issued for and refused in any other. The active cart and business unit ride on the token as values.

The commercetools client context asks a ClientContextLoader for tokens and customer details. Two loaders exist:

  • CommercetoolsClientLoader talks to commercetools directly through a TokenManager. When a resolver needs a client and the token holds no commercetools session yet, it requests an anonymous token first, so a guest session can start on the first request that needs one.
  • RemoteClientContextLoader asks another service that runs the AuthModule, at its /token/anonymous and /api/client-context routes. Use it in a service that should not hold the credentials for customer sessions. It never starts a guest session itself.

The TokenManager creates anonymous and customer sessions, refreshes them, and revokes them. It needs the user API client from CommercetoolsConfig (CTP_USER_CLIENT_ID, CTP_USER_CLIENT_SECRET, and optionally CTP_USER_SCOPES). Without CTP_USER_SCOPES it requests DEFAULT_USER_SCOPES. commercetools rejects a request for a scope the API client does not hold, so a client with fewer scopes, for example one without the B2B scopes, needs its own list.

The AuthModule owns the session mutations:

  • createGuestSession starts an anonymous session, or does nothing when one exists. The same work is available as POST /api/create-session.
  • customerLogin refuses a shopper who is already signed in. A shopper without a session gets a new customer session. A guest signs in through their own guest session, then gets a customer session, and the cart from the guest session is assigned to the customer.
  • refreshToken trades the commercetools refresh token for a new access token. When the refresh token has expired, it clears the session and returns success: false. Any other failure is an error, so the caller can tell “sign in again” from “try again”.
  • customerLogout revokes the access and refresh token at commercetools and clears the session.

Each of these changes the federated token, so the response carries the new token back to the gateway.

A business user buys on behalf of a business unit, and the unit decides the store, and through it the prices and assortment. The commercetools package keeps the active unit on the token as the businessContext value:

  1. At sign-in, the first of the customer’s business units in which they are not deactivated becomes the business context, with the unit’s first store.
  2. businessUnitSwitch sets another unit and store, and refuses a unit in which the caller has been deactivated.
  3. On every request, load() reads the unit and store key from the token instead of asking commercetools again, and looks up only the store, for its id and distribution channel.

From then on the client context answers for the unit. clientContext.storeKey returns the unit’s store instead of the one in the headers, and product queries add the unit’s store and channel to the customer’s group. That is why products for a signed-in shopper skip the shared cache (see Caching and performance). clientContext.getAssociateClient() returns a client for the associate endpoints of the active unit.

With a business context, the commercetools package reads and writes carts, orders, quotes, and shopping lists through the associate endpoints of the active unit. Without one, it uses the shopper’s own /me endpoints.

Without a business context With a business context
Client the shopper’s own token, through getClient() the service’s API client acting as the associate, through getAssociateClient()
Carts, orders, quotes, shopping lists the shopper’s own, in the store the unit’s, as far as the associate’s roles allow
Who decides access ownership the associate’s commercetools permissions

A request uses one of the two for carts and for the orders placed from them, so a cart a buyer can read is also a cart they can check out. Inside a unit, commercetools decides from the associate’s roles whether a buyer may act on a colleague’s cart. A refusal comes back as CART_NOT_PERMITTED, and CART_NOT_FOUND means only that the cart is gone.

A storefront should not show a button that the platform then refuses. The engine package translates each role’s commercetools permissions into BusinessUnitCapabilityType values, such as MANAGE_MY_ORDERS, MANAGE_OTHERS_ORDERS, MANAGE_USERS, DECIDE_APPROVALS, and VIEW_INVOICES. Read them from BusinessUnit.currentAssociate, the signed-in caller within that unit, and gate each control on them. The full list is in the business unit API.

BusinessUnitModule takes roleCapabilities to set capabilities for a role whose permissions map to none. It cannot overrule a role’s mapped permissions: change those in commercetools.

The gateway is a Hive Router image with the federated_token plugin compiled in. The plugin is the only part of the stack that can decrypt the session cookies. On each request it:

  1. Reads the access token from the userToken cookie, then guestToken, then an X-Access-Token header, and the refresh and data tokens the same way.
  2. Decrypts the access and refresh tokens and checks the signature, issuer, and audience of the data token. A token that is expired or invalid fails the request, unless a refresh token came with it: then the request goes through, so the refresh mutation can run.
  3. Sends the token to every subgraph as plain encoded JSON in X-Access-Token and X-Refresh-Token.
  4. Collects the token each subgraph sends back, and when a resolver changed it, writes new cookies on the response.

The plugin writes the user cookies for a signed-in session and the guest cookies for an anonymous one:

Cookie Holds Script can read it
userToken / guestToken The access token, encrypted. No
userData / guestData The token’s values, such as the business context, signed but not encrypted. Yes
refreshToken The refresh tokens, encrypted. Sent only to /graphql/auth. No
userRefreshTokenExists / guestRefreshTokenExists 1, so the storefront can tell that a refresh token exists. Yes

The gateway is configured through environment variables, which its router.config.yaml reads:

Variable What it does
JWT_ISSUER, JWT_AUDIENCE The issuer and audience on every token. Required.
COOKIE_DOMAIN The domain on the cookies. Required.
FEDERATED_TOKEN_ENCRYPT_KEY_1, FEDERATED_TOKEN_SIGN_KEY_1 The keys that encrypt and sign the tokens, exactly 32 bytes each. Required: the image ships no defaults.
FEDERATED_TOKEN_USER_MAX_AGE How long the access and data cookies live. 2 days by default.
FEDERATED_TOKEN_REFRESH_MAX_AGE How long the refresh cookie lives. 200 days by default.
FEDERATED_TOKEN_REFRESH_PATH The path of the refresh cookie. /graphql/auth by default.
FEDERATED_TOKEN_COOKIE_SECURE, FEDERATED_TOKEN_SAME_SITE true and lax by default.

Changing a key signs everyone out. To rotate one without that, list the new key first and keep the old one below it in encrypt_keys or sign_keys: the first key signs new tokens, and the others still verify old ones. That takes your own copy of router.config.yaml, as the reference sites have.

The gateway also passes the three store context headers on unchanged, and turns X-Forwarded-For into X-Client-IP.

The storefront and the gateway share the cookies because they share a parent domain, such as www.example.com and api.example.com with COOKIE_DOMAIN=.example.com. The browser sends the cookies to the gateway itself, on every GraphQL request it makes. The storefront’s server reads them to render pages, and renews them when they run out.

src/lib/auth.ts in the reference sites reads the cookies for server components:

  • isAuthenticated() is true when a userToken cookie is present. The storefront cannot decrypt it, so it checks only that it has the shape of an encrypted token.
  • getSessionHeaders() turns userToken and userData into the X-Access-Token and X-Data-Token headers for a server-side fetch. It forwards only a signed-in session, so a server-rendered page sees a guest as anonymous. What a guest owns, such as their cart, is fetched from the browser.
  • readUserData() decodes the userData cookie and checks its expiry, issuer, and audience against JWT_ISSUER and JWT_AUDIENCE. It tells the page whether there is a business context and which unit it is.

The storefront does not check the signature of userData. Use it to decide what to show, never to decide what someone may do: the gateway checks every token on every request.

The access cookie lives for 2 days and the refresh cookie for 200, so a returning visitor often has a refresh token and no live access token. The storefront’s proxy, src/proxy.ts (Next.js 16’s name for middleware), renews the session before the page renders:

  1. On every page request, it checks the userToken cookie. A token that expires within 5 minutes counts as expired.
  2. When there is no live token and a userRefreshTokenExists cookie is present, it redirects to /graphql/auth?next=<the page>.
  3. That route calls the refreshToken mutation on the gateway, passing the browser’s cookies on, and copies the gateway’s Set-Cookie headers onto its own response.
  4. It redirects back to next with a 303.
  5. If the gateway did not return a new userToken, the route clears the user cookies. The visitor continues as a guest, and keeps their guest cart.

The route sits at /graphql/auth because that is the path of the refresh cookie. A browser sends that cookie only to that path, so a route anywhere else never receives it. If you change FEDERATED_TOKEN_REFRESH_PATH, move the route with it.

The proxy leaves three kinds of request alone:

  • The sign-in, registration, and password reset pages. Sending a visitor there to the refresh route would loop when the refresh fails.
  • Link prefetches. It answers them with an empty 204, so the router keeps no redirect that would be stale by the time someone clicks.
  • Anything but GET. A redirect would drop the body of a form post.

In the browser, the reference sites wrap the app in the AuthProvider from @labdigital/federated-token-react, and call its checkToken() before every GraphQL request, which renews a token that is about to expire. When a request still fails with a 401, the fetcher refreshes once and retries, and signs the visitor out if that fails.

sequenceDiagram
  participant B as Browser
  participant P as proxy.ts
  participant R as /graphql/auth route
  participant G as Gateway
  B->>P: GET /account (userToken expired, refresh cookie present)
  P-->>B: Redirect to /graphql/auth?next=/account
  B->>R: GET /graphql/auth?next=/account
  R->>G: refreshToken mutation
  G-->>R: New session cookies
  R-->>B: 303 to /account, with Set-Cookie
  B->>P: GET /account (fresh session)

feature-auth ships the sign-in, registration, password reset, and sign-out forms. It runs the mutations and never reads or sets a cookie: the gateway sets them on the response. Your app passes onAuthenticated and onLoggedOut to its AuthProvider to refresh what it shows and to navigate.

The B2B reference site reads the business context from userData on the server:

  • /account sends a signed-in user to the account of their active business unit, at /account/<business unit id>, or lets them choose a unit when they have none.
  • The checkout is shown only with a business context. A visitor who has only added something to their cart already has a session, so the session alone is not enough.
  • Switching units runs businessUnitSwitch. The gateway writes a new token, and from the next request on, prices, assortment, and carts follow the new unit.