Skip to main content
← Back to blog

The day the login worked but nobody could log in

Five hours of debugging, a misconfigured cross-subdomain cookie, and the lesson on why E2E tests aren't optional in multi-tenant architecture.

It was eleven on a Tuesday morning when the first message came through Slack. "Hi, I can't log in to my blog. The login page says OK but it sends me back to login again."

Five minutes later, a second message. Different user, same symptom.

Here's what happened, how long it took to find the cause, and what we changed so it doesn't happen again.

Symptom: 200 OK, no session

The login flow at Agentikas is standard: user goes to write.agentikas.ai/login, types email and password, the API validates with Supabase, and on close the user lands on write.agentikas.ai/dashboard already authenticated.

The bug was subtle. The /api/auth/login request returned 200 OK. The sb-access-token cookie appeared in the response's Set-Cookie headers. The client processed it without error. And on the next request — the one loading /dashboard — the server didn't see the cookie, returned 401, and the middleware redirected to login.

Infinite loop. But infinite loops without errors. Browser console, clean. Server logs, perfect. Nothing said something was wrong — except the user, clearly, couldn't get in.

The dead end of the first instinct

First instinct was to check Supabase config. Keys correct. Validation working — returning a correct JWT. The JWT had the right claims. If you copied the access token from the headers and pasted it manually into the browser's cookie, the dashboard loaded.

One hour spent there. Conclusion: the JWT is fine, the problem is the cookie itself.

Second instinct: check Set-Cookie. The headers looked correct:

Set-Cookie: sb-access-token=eyJhbG...; Path=/; HttpOnly; Secure; SameSite=Lax

Path correct. HttpOnly correct. Secure correct (we're on HTTPS). SameSite Lax — the modern default that should work for normal navigation.

Another hour. Conclusion: the cookie arrives, the flags look right.

The "ah" moment

The detail we'd been missing: the API and the dashboard don't live on the same subdomain.

The dashboard serves from write.agentikas.ai. The auth API, during a recent refactor nobody reviewed properly, moved to api.agentikas.ai/auth/login. The cookie is set in response from api.agentikas.ai, so its implicit Domain is that. When the browser makes the next request to write.agentikas.ai, it doesn't send the cookie — because it's not on the same domain.

But here's the trick: write.agentikas.ai and api.agentikas.ai are subdomains of the same apex (agentikas.ai). The fix is simple: set the cookie with Domain=.agentikas.ai so it's valid on any subdomain.

The original refactor had dropped the domain option in cookies.set(). Supabase's server-side SDK default is to leave it unset, assuming same-origin. In local dev it worked because everything ran on localhost. In production, api. and write. are different origins.

The fix

Five lines of code. The right option in the server-side Supabase client:

cookies.set({
  name: "sb-access-token",
  value: token,
  options: {
    domain: ".agentikas.ai",   // ← the missing line
    path: "/",
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7,
  },
});

Deploy. Manual test. Works. Five hours to find a missing line.

What we changed so it doesn't happen again

The five-line fix isn't what matters. What matters is what we added so a bug like this doesn't reach production again.

1. End-to-end test for the full flow. A Playwright test that hits write.agentikas.ai/login, types credentials, navigates to /dashboard, and verifies the user is authenticated. Before, there was a test verifying the API returned 200. That test passed with the bug. Now the test asserts behavior, not implementation.

test("login flow ends with authenticated dashboard", async ({ page }) => {
  await page.goto("https://write.agentikas.ai/login");
  await page.fill('input[name="email"]', "test@example.com");
  await page.fill('input[name="password"]', process.env.TEST_PWD!);
  await page.click('button[type="submit"]');

  await page.waitForURL("**/dashboard");
  await expect(page.getByRole("heading", { name: /your blog/i })).toBeVisible();
});

2. Custom lint rule for cookies. A lint that fails if cookies.set() on server-side gets called without a domain option in files under apps/dashboard/src/app/api/. Sounds overkill — and it is for a small project — but the cost of adding it is low and it kills the entire bug class.

3. Document cookie domain in CLAUDE.md. When Claude (or any new dev) adds a new auth route, they'll read the project's CLAUDE.md. The note says: "Auth cookies must always set domain: .agentikas.ai for cross-subdomain to work. Local dev uses SDK default; prod requires the explicit override."

The lesson, no epiphany

There's a temptation when closing a post-mortem: extract some grand lesson. "We learned observability is critical." That's always true and almost always useless.

The real lesson is more prosaic: tests that verify end-to-end behavior catch bugs that implementation tests don't. When your test says "the API returns 200," you're not testing that the user can log in. You're testing that the handler runs. They're different things.

And when your architecture has subdomains — almost every modern one does — the cross-domain cookie is exactly the kind of detail that breaks in production and works locally. Not by magic, by physics.


Agentikas is open source. The test we added lives in github.com/agentikas/agentikas-blog under tests/e2e/auth.spec.ts. The fix commit has an honest message — worth a read if you ever touch cross-subdomain cookie auth.

Comments

Loading comments…