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
What a service receives
Section titled “What a service receives”X-Access-Token the federated tokenX-Refresh-Token the refresh tokens that go with itX-StoreContext-StoreKey the storeX-StoreContext-Locale the language, such as en-USX-StoreContext-Currency the currency, such as EURX-Client-IP the shopper's IP addressThe 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.
From headers to resolver
Section titled “From headers to resolver”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 ◄──────────┘- 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 withcreateRequestContext. It creates the engine’sStoreContextandClientContext, and puts the client context’s token on the context asfederatedToken. useFederatedToken(), from@labdigital/federated-token-yoga, loadsX-Access-TokenandX-Refresh-Tokeninto that token.useClientContext()handsX-Access-Tokento the client context and waits for itsload(). If loading fails, the request fails with “Error loading client context”.Applicationaddscontext.loaders. Each loader is built on first read, so it sees the loaded session.- The resolver runs. It reads
context.storeContextandcontext.clientContext, and guards itself withassertAuthenticated,assertNotAuthenticated, orassertActiveSession. - On the response,
useFederatedToken()sends the token back inX-Access-TokenandX-Refresh-Tokenif 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.
Store context
Section titled “Store context”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.
Client context
Section titled “Client context”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 ananonymous_id, a signed-in customer one whose subject is acustomer_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 undercommercecloud.load()fetches nothing, andgetClient()throwsSessionExpiredErroronce the session has expired. - Medusa
(
ClientContext): the session lives undermedusa.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.
How the commercetools package gets tokens
Section titled “How the commercetools package gets tokens”The commercetools client context asks a
ClientContextLoader
for tokens and customer details. Two loaders exist:
CommercetoolsClientLoadertalks to commercetools directly through aTokenManager. 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.RemoteClientContextLoaderasks another service that runs theAuthModule, at its/token/anonymousand/api/client-contextroutes. 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.
Sessions
Section titled “Sessions”The AuthModule owns the session mutations:
createGuestSessionstarts an anonymous session, or does nothing when one exists. The same work is available asPOST /api/create-session.customerLoginrefuses 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.refreshTokentrades the commercetools refresh token for a new access token. When the refresh token has expired, it clears the session and returnssuccess: false. Any other failure is an error, so the caller can tell “sign in again” from “try again”.customerLogoutrevokes 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.
Business context
Section titled “Business context”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:
- 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.
businessUnitSwitchsets another unit and store, and refuses a unit in which the caller has been deactivated.- 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.
Which endpoint answers a business user
Section titled “Which endpoint answers a business user”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.
What an associate may do
Section titled “What an associate may do”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.
In the gateway
Section titled “In the gateway”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:
- Reads the access token from the
userTokencookie, thenguestToken, then anX-Access-Tokenheader, and the refresh and data tokens the same way. - 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.
- Sends the token to every subgraph as plain encoded JSON in
X-Access-TokenandX-Refresh-Token. - 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.
In the storefront
Section titled “In the storefront”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.
Reading the session on the server
Section titled “Reading the session on the server”src/lib/auth.ts in the reference sites reads the cookies for server
components:
isAuthenticated()is true when auserTokencookie is present. The storefront cannot decrypt it, so it checks only that it has the shape of an encrypted token.getSessionHeaders()turnsuserTokenanduserDatainto theX-Access-TokenandX-Data-Tokenheaders 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 theuserDatacookie and checks its expiry, issuer, and audience againstJWT_ISSUERandJWT_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.
Renewing an expired session
Section titled “Renewing an expired session”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:
- On every page request, it checks the
userTokencookie. A token that expires within 5 minutes counts as expired. - When there is no live token and a
userRefreshTokenExistscookie is present, it redirects to/graphql/auth?next=<the page>. - That route calls the
refreshTokenmutation on the gateway, passing the browser’s cookies on, and copies the gateway’sSet-Cookieheaders onto its own response. - It redirects back to
nextwith a 303. - 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)
Signing in and out
Section titled “Signing in and out”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.
In a B2B storefront
Section titled “In a B2B storefront”The B2B reference site reads the business context from userData on the
server:
/accountsends 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.

