DEV Community

Cover image for My Google sign-up worked every time I tested it. My first two real users got nothing.
Roy Levy
Roy Levy

Posted on AI-assisted

My Google sign-up worked every time I tested it. My first two real users got nothing.

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:

  1. The "Get started" button on the marketing page linked to a relative /login. So the login page opened on example.com. Not on the app host.
  2. 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.
  3. Google sends them back to the callback, which lives on app.example.com/auth/callback.
  4. 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.
  5. 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}`;
}
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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-host first. Behind a proxy or CDN, the host header 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)

Collapse
 
sunnatulla_dev profile image
Sunnatulla •

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_id and auth_code_issued_at on the flow state row, and redirects to your callback with the code. The row is only deleted when exchangeCodeForSession succeeds. So a row that has a user_id and is older than a few minutes is exactly your bug: the person got through Google and never got a session.

select user_id, provider_type, auth_code_issued_at
from auth.flow_state
where user_id is not null
  and auth_code_issued_at < now() - interval '5 minutes';
Enter fullscreen mode Exit fullscreen mode

Rows with user_id null 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.

Collapse
 
roylevy profile image
Roy Levy •

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.

Collapse
 
sunnatulla_dev profile image
Sunnatulla •

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 (CleanupEnabled in internal/conf/configuration.go, env GOTRUE_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.

⌂ 换网址