From 8735351c7a407a928fc518effd6b950f6d69727d Mon Sep 17 00:00:00 2001 From: Ryan Rader Date: Sat, 23 May 2026 14:40:09 -0400 Subject: [PATCH] fix(portal): skip admin Bearer JWT on customer-portal routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The customer portal authenticates via the customer-portal-token header, not the admin Bearer JWT. But authLink was attaching the Bearer JWT to every request if one existed in localStorage — including portal requests. For users who have ever logged into Lago admin in the same browser, the admin JWT (12h TTL) sits in localStorage. Once it expires, every customer-portal GraphQL request returns expired_jwt_token (because GraphqlController validates the Bearer JWT before resolving), even though the customer-portal session itself is fresh and valid. This broke the recently-shipped stale-tab-recovery flow into an infinite loop: portal hits expired_jwt → bounces to host app → host mints fresh portal URL → portal still has expired admin JWT in localStorage → expired_jwt → bounces again. Fix: only attach the Bearer header when NOT on a /customer-portal/* route. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/apolloClient/init.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/core/apolloClient/init.ts b/src/core/apolloClient/init.ts index cbc0b4ed74..6310d31d4e 100644 --- a/src/core/apolloClient/init.ts +++ b/src/core/apolloClient/init.ts @@ -69,10 +69,22 @@ export const initializeApolloClient = async () => { const customerPortalToken = getItemFromLS(CUSTOMER_PORTAL_TOKEN_LS_KEY) const currentOrganizationId = getCurrentOrganizationId() + // The customer portal authenticates via the `customer-portal-token` + // header, NOT the admin Bearer JWT. If a user has logged into Lago + // admin in this browser, the admin JWT sits in localStorage; sending + // it on portal requests means an expired admin JWT (12h TTL) causes + // GraphQL to return `expired_jwt_token` on the portal — even though + // the portal session itself is fine. Skip the Bearer for portal routes. + const isCustomerPortalRoute = !!matchPath( + `${CUSTOMER_PORTAL_ROUTE}/*`, + window.location.pathname, + ) + const shouldSendBearer = !!token && !isCustomerPortalRoute + operation.setContext({ headers: { ...headers, - ...(!token ? {} : { authorization: `Bearer ${token}` }), + ...(shouldSendBearer ? { authorization: `Bearer ${token}` } : {}), ...(!customerPortalToken ? {} : { 'customer-portal-token': customerPortalToken }), 'x-lago-organization': currentOrganizationId, },