Core EngineeringApril 3, 2026

Split Brain Avoidance For Subdomain Cookie Auth In Edge-Rendered Next.Js

M

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 to
  • Path: which URL paths the cookie applies to
  • Secure: only send over HTTPS
  • HttpOnly: 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:

  1. Frontend login sets a cookie that is valid for both app.example.com and api.example.com.
  2. API endpoints read that cookie and verify the session using a signed token (no extra user-info fetch).
  3. 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.com won’t automatically be included in requests to api.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 with Domain=.example.com
  • Browser then calls api.example.com/me
  • Cookie session is 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/login returns 200
  • Cookie appears in the browser for app.example.com
  • /me on api.example.com returns 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 sameSite doesn’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.