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
/apibecauselumie-frontend/src/shared/config/env.tshardcodesENV.API_URLto/api. lumie-frontend/app/api/[...path]/route.tsforwards those requests toNEXT_PUBLIC_API_BASE, rewrites cookies for localhost, strips browser-only headers, and injects tenant context for custom domains.lumie-frontend/src/shared/api/base.tsperforms the client-side single-flight refresh flow.lumie-frontend/proxy.tshandles 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/apirewrite innext.config.ts. - Keep the proxy cookie rewrites together: remove
Domain, removeSecure, and downgradeSameSite=NonetoSameSite=Laxfor localhost proxy mode. - Keep
originstripped 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.tsdecides whether navigation can continue, andsrc/shared/api/base.tsperforms the actual token refresh.
Prevention And Verification
- Verify
lumie-frontend/src/shared/config/env.tsstill points browser traffic to/api. - Verify
lumie-frontend/next.config.tsonly rewrites/ws/:path*, not/api/:path*. - Verify
lumie-frontend/app/api/[...path]/route.tsstill removesorigin,content-encoding, andcontent-length, and still rewritesSet-Cookiefor localhost. - Verify
lumie-frontend/src/shared/api/base.tsstill reads the refresh response body before interpreting the result. - Verify
lumie-frontend/proxy.tsstill falls back fromlumie_access_tokentolumie_refresh_tokenwhen 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.
| Concern | Browser-facing rule | Upstream-facing rule |
|---|---|---|
| Cookies on localhost | Must be acceptable to Chromium and Safari. | Backend may emit production-oriented cookie attributes. |
| Custom domains | Browser origin reflects the school domain. | Backend needs tenant context, not the raw browser origin. |
| Compressed responses | Browser receives already-decoded body from the proxy. | Upstream may still report original compression headers. |
| Expired access token | Page 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
- Reproduce in the browser that shows the failure. Chromium and Safari expose different cookie timing failures.
- Inspect the proxied response headers, especially
Set-Cookie,Content-Encoding, andContent-Length. - Confirm browser calls use
/apiand do not bypass the frontend proxy. - For custom-domain login, confirm
X-Tenant-Slugis injected andoriginis removed before forwarding. - For first-navigation expiry, confirm the refresh token is still present and
proxy.tsallows the page to stay mounted. - 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:
| Scenario | Expected result |
|---|---|
| Chromium localhost login | Session cookies persist after refresh. |
| Safari refresh | First expired access token triggers refresh, then request retry succeeds. |
| Custom-domain login | Login/register request carries the resolved tenant slug and returns non-403. |
| Gzip or Brotli API response | Browser does not report a double-decode error. |
| Access token expired, refresh token valid | Protected page does not immediately redirect to login. |
Anti-Patterns
- Restoring a broad
/apirewrite innext.config.tswhile the app route proxy still owns auth mutation. - Forwarding browser
Originupstream to solve custom-domain CORS symptoms. - Returning upstream
Content-Encodingafter the proxy has already received a decoded body. - Treating page-routing auth and API token refresh as the same responsibility.