BearLog

Blog ·

Where vibe coding breaks

Memories got Google One Tap sign-in this week: the corner prompt, the avatar, no redirect. Under the prompt is a route that takes Google’s credential, verifies it, and provisions a Supabase user for it. I let the AI write the provisioning step. It looked like this:

let user = null;
if (email) {
  const { data } = await admin.auth.admin.listUsers({
    page: 1,
    perPage: 500,
  });
  user = data.users.find((u) => u.email?.toLowerCase() === email) ?? null;
}

Fetch a page of users, find the one with a matching email, fall through to createUser if nobody matched. It reads cleanly. It has a null check. It handles both branches. To anyone not looking for it, this is fine code.

Why it’s fine right up until it isn’t

listUsers pages at 500. Page 1 is the first 500 accounts, full stop. Account 501 does not appear on it, and there is no error to say so, no hasMore the code checked. The lookup for that user just returns “not found”, the route falls into createUser, and Supabase rejects it on the email unique constraint it already holds. Sign-in breaks for exactly the users who’ve been around long enough to age off the first page, which is the last place you’d think to look, because those are your oldest, most established accounts.

And the cost isn’t only correctness. This isn’t a cache warmed once at startup. It runs on every single sign-in attempt, existing user or new. A 500-row admin dump, once per credential exchange, forever, so the bill for finding one row is paying for all of them every time.

The fix, and why it’s also shorter

auth.users.email already carries a unique index. The right lookup was never “list some users and scan them”, it was “ask Postgres for the row”, which is one indexed query instead of a paginated table dump:

create or replace function public.get_user_id_by_email(lookup_email text)
returns uuid
language sql
security definer
set search_path = auth
as $$
  select id from auth.users where lower(email) = lower(lookup_email) limit 1;
$$;

revoke all on function public.get_user_id_by_email(text)
  from public, anon, authenticated;
grant execute on function public.get_user_id_by_email(text) to service_role;

Locked to service_role on purpose. This function reaches into auth.users, so nothing client-facing gets to call it, only the admin client the provisioning route already uses. The route’s call site shrinks to match:

const { data: existingId } = await admin.rpc("get_user_id_by_email", {
  lookup_email: email,
});
const userExists = existingId !== null;

No page size to pick, no cap to hit, flat cost whether there are five accounts or five hundred thousand.

What I took from it

The AI’s version wasn’t lazy in the way that’s usually fine to leave alone, it was lazy in the way that hides a ceiling inside a magic number. perPage: 500 reads as an implementation detail. It’s actually a claim: this list will never be longer than a page. Nothing in the code says that claim out loud, and nothing tests for the day it stops being true, which is exactly the profile of bug that survives review by someone not already looking for it, because the code is correct for every account you’d think to test it against while you’re building it.

The general shape, once you notice it once: vibe-coded code tends to be right about behavior and silent about growth curve. It optimizes for “compiles, passes the happy path, handles the null case” and has no opinion on what happens as the input gets big, because nothing in the prompt or the review asked it to. So the review question worth adding isn’t just “does this work”, it’s “what does this cost at ten times the data, and does it fail loud or quiet when it stops working.” A lookup with a page size in it is usually answering the second question for you, if you think to ask it.