Skip to main content

Next.js API Proxy Auth Gotchas

This page covers the auth-sensitive parts of lumie-frontend/app/api/[...path]/route.ts, src/shared/api/base.ts, and proxy.ts.

Symptoms

  • Login appears to succeed on localhost, but Chromium never keeps the session cookie.
  • Safari refreshes the token but still loops through 401 responses.
  • Custom-domain login returns 403 even though the same credentials work on the canonical host.
  • Authenticated API calls fail only through the localhost proxy with browser decoding errors.
  • Navigation jumps back to login as soon as the 1-hour access token expires, even though the refresh token is still valid.

Diagnosis

The intended runtime contract is:

  • Browser code calls relative /api because lumie-frontend/src/shared/config/env.ts hardcodes ENV.API_URL to /api.
  • lumie-frontend/app/api/[...path]/route.ts forwards those requests to NEXT_PUBLIC_API_BASE, rewrites cookies for localhost, strips browser-only headers, and injects tenant context for custom domains.
  • lumie-frontend/src/shared/api/base.ts performs the client-side single-flight refresh flow.
  • lumie-frontend/proxy.ts handles page-routing auth and now accepts either a valid access token or a valid refresh token when deciding whether a navigation is authenticated.

If any one of those layers is bypassed or partially reverted, auth failures show up quickly.

Root Causes

Localhost cookies disappearing in Chromium

The proxy strips Domain and Secure before replaying Set-Cookie on localhost. That part is required, but it is not sufficient. If SameSite=None survives after Secure is removed, Chromium silently rejects the cookie.

The current fix in app/api/[...path]/route.ts rewrites SameSite=None to SameSite=Lax after removing Secure, which works because proxy-mode auth is first-party on localhost.

Safari refresh loops

src/shared/api/base.ts now consumes the refresh response body inside tryRefreshToken(). Without that read, Safari can return 200 from /api/v1/refresh but delay applying Set-Cookie, so the retried request still sends the expired access token.

Custom-domain login 403

The proxy must not forward the browser Origin header on the server-to-server hop. app/api/[...path]/route.ts now deletes origin, and for /api/v1/login or /api/v1/register on custom domains it resolves the tenant and sets X-Tenant-Slug before forwarding.

Decoding failures on authenticated JSON responses

Node's fetch implementation transparently decompresses upstream gzip or Brotli bodies, but the proxy still sees the original Content-Encoding and Content-Length headers. Forwarding those headers makes the browser attempt a second decode. app/api/[...path]/route.ts now deletes both headers before returning the proxied response.

Session loss on the first expired access token navigation

proxy.ts used to gate route access on the short-lived access token only. The current readRoutingRole() implementation falls back to lumie_refresh_token, which keeps the page mounted long enough for the client refresh flow in src/shared/api/base.ts to renew the access token on the first API 401.

Fix

  • Keep browser API calls relative to /api; do not restore the removed /api rewrite in next.config.ts.
  • Keep the proxy cookie rewrites together: remove Domain, remove Secure, and downgrade SameSite=None to SameSite=Lax for localhost proxy mode.
  • Keep origin stripped on proxied auth writes and preserve tenant resolution for custom-domain login and register requests.
  • Keep the refresh body consumption in tryRefreshToken().
  • Keep route auth and API refresh responsibilities separate: proxy.ts decides whether navigation can continue, and src/shared/api/base.ts performs the actual token refresh.

Prevention And Verification

  • Verify lumie-frontend/src/shared/config/env.ts still points browser traffic to /api.
  • Verify lumie-frontend/next.config.ts only rewrites /ws/:path*, not /api/:path*.
  • Verify lumie-frontend/app/api/[...path]/route.ts still removes origin, content-encoding, and content-length, and still rewrites Set-Cookie for localhost.
  • Verify lumie-frontend/src/shared/api/base.ts still reads the refresh response body before interpreting the result.
  • Verify lumie-frontend/proxy.ts still falls back from lumie_access_token to lumie_refresh_token when computing the routing role.

Operational Detail

The proxy is the compatibility boundary between browser rules and backend auth rules. Most regressions come from treating it as a simple pass-through.

ConcernBrowser-facing ruleUpstream-facing rule
Cookies on localhostMust be acceptable to Chromium and Safari.Backend may emit production-oriented cookie attributes.
Custom domainsBrowser origin reflects the school domain.Backend needs tenant context, not the raw browser origin.
Compressed responsesBrowser receives already-decoded body from the proxy.Upstream may still report original compression headers.
Expired access tokenPage should remain mounted when refresh token is valid.API client performs the refresh and retries the request.

Debugging should follow that boundary. A request can be valid from the browser's point of view and still need header changes before the server-to-server hop.

Recovery Playbook

  1. Reproduce in the browser that shows the failure. Chromium and Safari expose different cookie timing failures.
  2. Inspect the proxied response headers, especially Set-Cookie, Content-Encoding, and Content-Length.
  3. Confirm browser calls use /api and do not bypass the frontend proxy.
  4. For custom-domain login, confirm X-Tenant-Slug is injected and origin is removed before forwarding.
  5. For first-navigation expiry, confirm the refresh token is still present and proxy.ts allows the page to stay mounted.
  6. Only then inspect backend auth logs. A backend 401 can be caused by a frontend proxy mutation regression.

Verification Matrix

Run at least one check from each row when touching this area:

ScenarioExpected result
Chromium localhost loginSession cookies persist after refresh.
Safari refreshFirst expired access token triggers refresh, then request retry succeeds.
Custom-domain loginLogin/register request carries the resolved tenant slug and returns non-403.
Gzip or Brotli API responseBrowser does not report a double-decode error.
Access token expired, refresh token validProtected page does not immediately redirect to login.

Anti-Patterns

  • Restoring a broad /api rewrite in next.config.ts while the app route proxy still owns auth mutation.
  • Forwarding browser Origin upstream to solve custom-domain CORS symptoms.
  • Returning upstream Content-Encoding after the proxy has already received a decoded body.
  • Treating page-routing auth and API token refresh as the same responsibility.