Split Brain Avoidance For Subdomain Cookie Auth In Edge-Rendered Next.Js
Written by
Maximus Arc
The bug I chased: “login works on one subdomain, breaks on the other”
I ran into a weird authentication failure while building a setup like this:
- I had an edge-rendered Next.js app serving
app.example.com - A separate API lived at
api.example.com - Users logged in through the frontend, but after login the API would act like the user was still logged out
- Worse: refreshing sometimes “fixed” it, then broke again
The root cause ended up being subdomain cookie scope and edge caching interacting in an unexpected way. This post documents the exact architecture I ended up with and the working code I used to make it deterministic.
What was actually happening
A cookie is a small piece of data the browser stores and sends back automatically with HTTP requests. Cookie delivery is controlled by attributes like:
Domain: which hostnames the cookie should be sent toPath: which URL paths the cookie applies toSecure: only send over HTTPSHttpOnly: JS can’t read it (prevents XSS cookie theft)SameSite: controls whether cookies are sent on cross-site requests
In my case, the login endpoint set a cookie on app.example.com without a Domain that covered api.example.com, so the API requests never included the cookie.
Then edge-rendering added a second layer: the server sometimes served a cached page that didn’t trigger a fresh session check the way I expected.
The niche architecture: “Edge frontend session cookie + API token verification without re-calling user info”
The approach I used:
- Frontend login sets a cookie that is valid for both
app.example.comandapi.example.com. - API endpoints read that cookie and verify the session using a signed token (no extra user-info fetch).
- Edge responses use cache headers that ensure auth-sensitive content isn’t incorrectly cached.
To keep it concrete, I used:
- Next.js App Router for the frontend
- Express for the API
- Cookie-based sessions with a signed JWT stored in an HttpOnly cookie
Working implementation
1) Frontend: login route sets the cookie with Domain=.example.com
This is the critical part: using Domain=.example.com so the cookie is sent to both subdomains.
// app/api/login/route.ts (Next.js App Router) import { cookies } from "next/headers"; import jwt from "jsonwebtoken"; export async function POST() { // Demo auth: in a real app you would validate user credentials here. const userId = "user_123"; const secret = process.env.SESSION_JWT_SECRET!; const token = jwt.sign({ sub: userId }, secret, { expiresIn: "15m" }); // Domain=.example.com makes the cookie available to: // - app.example.com // - api.example.com // - (and other subdomains too, if you use a wider Domain) cookies().set("session", token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", domain: ".example.com", maxAge: 15 * 60, // seconds }); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" }, }); }
Why this fixes the “split brain” cookie problem:
- Without
domain: ".example.com", the browser will default the cookie to the host that set it. - That means a cookie set by
app.example.comwon’t automatically be included in requests toapi.example.com.
2) API: read cookie and verify the token
Here’s an Express API server that validates the session cookie on every request.
// server.ts (Express API) import express from "express"; import cookieParser from "cookie-parser"; import jwt from "jsonwebtoken"; const app = express(); app.use(cookieParser()); const secret = process.env.SESSION_JWT_SECRET!; function requireAuth( req: express.Request, res: express.Response, next: express.NextFunction ) { const token = req.cookies.session; if (!token) return res.status(401).json({ error: "missing session" }); try { const payload = jwt.verify(token, secret) as { sub: string }; (req as any).userId = payload.sub; return next(); } catch { return res.status(401).json({ error: "invalid session" }); } } app.get("/me", requireAuth, (req, res) => { return res.json({ userId: (req as any).userId, }); }); app.listen(4000, () => { console.log("API listening on http://localhost:4000"); });
What happens when you run this:
- Your browser hits
app.example.com/api/login→ cookie gets set withDomain=.example.com - Browser then calls
api.example.com/me - Cookie
sessionis automatically attached to the request - API verifies JWT and returns the user identity
3) Prevent edge caching from serving auth-wrong content
I also added explicit cache headers on the frontend route that checks auth. The key is to avoid caching responses that depend on cookies.
In Next.js App Router, for dynamic auth pages I forced “no store”.
// app/me/page.tsx (Next.js) import "server-only"; import { cookies } from "next/headers"; import jwt from "jsonwebtoken"; export const dynamic = "force-dynamic"; // disable static optimization export default function MePage() { const token = cookies().get("session")?.value; if (!token) { return ( <main> <h1>Not logged in</h1> </main> ); } const secret = process.env.SESSION_JWT_SECRET!; try { const payload = jwt.verify(token, secret) as { sub: string }; return ( <main> <h1>Logged in</h1> <p>User id: {payload.sub}</p> </main> ); } catch { return ( <main> <h1>Session expired</h1> </main> ); } }
Why this matters:
- Edge-rendered frameworks can try to cache content.
- Auth-dependent pages must not be cached in a way that ignores per-user cookies.
Local testing with hostnames that resemble production
Testing with localhost hides the Domain behavior because cookie scoping rules differ when you’re not using real subdomains.
A practical way to simulate subdomains is to use /etc/hosts:
# /etc/hosts 127.0.0.1 app.example.com 127.0.0.1 api.example.com
Then run:
- Frontend on something like
https://app.example.com:3000(with HTTPS) - API on
https://api.example.com:4000(with HTTPS)
Because the cookie uses secure: true, HTTP won’t send it. That’s another easy-to-miss failure mode that looked similar to the cookie scoping bug.
Common failure modes I hit (and how I recognized them)
1) Cookie set without a shared domain
Symptom:
/api/loginreturns 200- Cookie appears in the browser for
app.example.com /meonapi.example.comreturns 401
Fix:
- Add
domain: ".example.com"when setting the cookie.
2) Cookie is present but API still sees “missing session”
Symptom:
- Cookie appears in devtools
- API still returns 401 “missing session”
Fix:
- Ensure requests are actually going to
api.example.com(not a different host) - Ensure HTTPS is used (
secure: true) - Ensure
sameSitedoesn’t block sending cookies for your request type
In my case, sameSite: "lax" worked with navigation-style flows.
3) Edge caching serves stale auth UI
Symptom:
- Clicking login sometimes “works” only after a manual refresh
- UI claims you’re logged in but API says otherwise (or vice versa)
Fix:
- Use
dynamic = "force-dynamic"(or equivalent) for auth-sensitive routes - Ensure cache headers don’t treat auth pages as public static content
Summary
I built an auth architecture for an edge-rendered Next.js frontend paired with an Express API where login sets an HttpOnly JWT cookie scoped to Domain=.example.com, and the API verifies that cookie on every protected route. The deterministic behavior came from addressing the “split brain” caused by cookie scoping across subdomains and preventing edge caching from serving auth-dependent content out of context.