I tested from the app. They started from the homepage. One cookie, two hosts.
In my last post, I deleted 12,852 lines of AI fact-checking because it caught nothing real.
This one is smaller. One cookie.
But it hurt more.
I finally turned on paid ads for my product. Two people clicked and signed up with Google.
Neither of them got in.
So let's talk about it.
The setup everybody has
Nothing fancy here. You've probably built this exact thing:
Marketing pages on example.com.
The app on app.example.com.
Supabase auth. Google OAuth. The PKCE flow.
I tested sign-up many times. It always worked.
So why did it fail for the only two people who mattered?
What actually happened
Step by step:
- The "Get started" button on the marketing page linked to a relative
/login. So the login page opened onexample.com. Not on the app host. - The user clicks "Continue with Google". The Supabase client creates a code verifier and saves it in a cookie. A host-only cookie, on
example.com. - Google sends them back to the callback, which lives on
app.example.com/auth/callback. - The callback tries to exchange the code for a session. The verifier cookie isn't there. It belongs to the other host. Error:
pkce_code_verifier_not_found. - My error handling turned that into
/login?error=other_browser.
So the user was told they had switched browsers.
They hadn't.
They did everything right. My app blamed them for my bug.
The sneaky part
Here's the thing that kept me from seeing it.
Supabase had already created the user before the exchange failed.
So my auth.users table had two shiny new rows. No session. No profile. No workspace.
From the outside, it looked like two people signed up and left. A marketing problem. "Maybe the onboarding is too long." "Maybe the pricing scared them."
It wasn't a marketing problem.
It was a cookie problem wearing a marketing problem's clothes.
Why I never caught it
I always started from the app host.
And my browser was already signed in, so I never ran the real path: logged out, from the marketing page, click the big button.
I tested the path I take every day.
My users took a different one.
How I found it
Two checks, both boring, both worth it:
- Compare users against sessions and profiles. Two users with zero sessions. That's not "they left". That's "they never got in".
- Split the request logs by host. The login page was served on the marketing host. The callback was on the app host.
Once you see the two hosts next to each other, it's obvious.
Before that, it's invisible.
The fix: one host owns login
The fix is one rule.
Login starts and ends on the same host.
The proxy (Next.js middleware, now proxy.ts) checks every request before anything else. If it's a login or auth path on the wrong host, it sends the user to the app host:
// lib/site/origins.ts
export function authOnAppHostRedirect(
requestHost: string | null,
pathname: string,
search: string,
configuredAppOrigin: string | null = appOrigin(),
): string | null {
if (!configuredAppOrigin || !requestHost) return null;
if (pathname !== "/login" && !pathname.startsWith("/auth/")) return null;
const app = new URL(configuredAppOrigin);
if (LOCAL_HOSTNAMES.has(app.hostname)) return null; // leave localhost alone
if (requestHost.toLowerCase() === app.host) return null;
return `${app.origin}${pathname}${search}`;
}
// proxy.ts
export async function proxy(request: NextRequest) {
const appHostUrl = authOnAppHostRedirect(
request.headers.get("x-forwarded-host") ?? request.headers.get("host"),
request.nextUrl.pathname,
request.nextUrl.search,
);
if (appHostUrl) {
// 308 keeps POST (the login server action) as POST.
return NextResponse.redirect(appHostUrl, 308);
}
// ...the usual auth gate
}
Two small details I'd copy:
- 308, not 301 or 302. The login form posts a server action. A 301 or 302 can turn that POST into a GET. A 308 keeps it a POST.
-
x-forwarded-hostfirst. Behind a proxy or CDN, thehostheader your app sees isn't always the one the user typed.
Now the login page only ever renders on app.example.com. The cookie and the callback always match.
The other fix
You can also set the cookie on the parent domain, .example.com, so both hosts can read it.
That works too.
But then your auth cookies are shared with every subdomain you have now, and every one you add later. Docs site, status page, that preview deploy you forgot about.
Having one host own login keeps the cookie where it is.
What I'd check in your app today
If you have a marketing site and an app on different hosts, five minutes:
- [ ] Does every "Sign up" and "Log in" link on your marketing pages point to the app host? Absolute URLs, or a redirect.
- [ ] Does OAuth start on the same host as the callback?
- [ ] Do you map auth errors to one friendly message? "Use the same browser" hid the real cause for me.
- [ ] Can you see half-made sign-ups? Query users with no session and no profile. A sign-up count won't show them.
- [ ] Have you tested sign-up logged out, from the marketing page, in a clean browser profile? Not from the app. From where real users start.
Here's my honest answer
Was this a hard bug? No.
Was it an expensive one? Yes. Those were my first sign-ups from paid ads. I emailed both, apologized, and offered a free month.
The code wasn't the problem. My testing was.
The path you test vs the path they take.
Your users don't start where you start.
This happened while building NichePressa, an agent that writes and publishes posts into Git-based blogs.
Got bitten by cookies across subdomains too? I'd love to hear what else quietly breaks in that setup. Drop it in the comments.
Top comments (0)