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 (3)
There's a table that would have shown this on the first failed sign-up instead of after the ads:
auth.flow_state.With PKCE, when Google sends the user back, Supabase Auth creates the user, sets
user_idandauth_code_issued_aton the flow state row, and redirects to your callback with the code. The row is only deleted whenexchangeCodeForSessionsucceeds. So a row that has auser_idand is older than a few minutes is exactly your bug: the person got through Google and never got a session.Rows with
user_idnull are people who gave up on the Google screen, which is normal. Two catches: the auth cleanup deletes flow state rows after 24 hours, so it only works as a recent check, and the code itself expires after 5 minutes by default. I'd run it on a schedule and alert on any row, since in a healthy setup it should be empty almost all the time. I read this from the supabase/auth source (token.go and cleanup.go) and checked the query against a mock of the table, not on a live project.This is a great catch, thank you.
I ran it on my production project right after reading this. Every row with a user_id was from before the fix. Zero since it went live. It also flagged a few older sign-ups I’d never looked at, so it’s already paying for itself.
One thing that differed from your read: the 24-hour cleanup didn’t clear anything on my (hosted) project. The oldest row is from May. Not sure if cleanup is off on hosted or just never got triggered, but it meant the check reached a lot further back than I expected.
I also checked token.go after reading this. The flow state really is only destroyed on a successful exchange, so a stuck row means exactly what you said: they got through Google and never got in.
This is going in as a scheduled check that alerts on any stuck row. Much better than finding out from an ad bill.
That one's on me, I read the 24h interval in cleanup.go and assumed it just runs. Went back to the source and it's not that simple.
Cleanup is behind a flag that defaults to false (
CleanupEnabledin internal/conf/configuration.go, envGOTRUE_DB_CLEANUP_ENABLED), and api.go only adds the middleware when it's on. I can't see what hosted projects set, so I don't know if yours has it off.Even when it's on, it isn't a cron. After each successful POST/PUT/PATCH/DELETE to auth it runs one statement from a list of about nine (refresh tokens, sessions, flow_state, MFA challenges...) in rotation, 100 rows max. So flow_state only gets a turn every ninth or so successful auth write. On a project with modest traffic that can easily lose to the inserts, and a May row surviving fits either explanation.
For your scheduled check I'd just add
and auth_code_issued_at > now() - interval '1 day'so it alerts on new stuck rows and not the old backlog.