Menu

Sign-in was broken for two days and nothing logged it

2026-09-24MCP Checkup

On 2026-09-14 the founder tried to sign in and got “Google sign-in could not start”. The email link did not work either, with the same sentence. We reproduced it in a browser within minutes. Nobody had reported it before, and nothing on our side had flagged it, although it had been broken since a deployment two days earlier.

What the browser was running

The sign-in page loads a small JavaScript chunk that creates the Supabase client used for both sign-in paths. The chunk on the live site was 429 bytes long. Its entire content was, once unminified:

function t(){throw Error("PUBLIC_SUPABASE_URL / PUBLIC_SUPABASE_ANON_KEY are not set …")}

No project URL, no key, just a function that throws. At build time, import.meta.env.PUBLIC_SUPABASE_URL and its sibling had been empty, so the guard at the top of our client factory was always true, and the minifier had correctly folded the whole module into a single throw. Every browser-side call that needed the client, Google OAuth and the email one-time link alike, failed at the same first step, which is why two unrelated sign-in methods produced one error message.

Why the variables were empty

Since 2026-09-10 the site had been deployed from a clean checkout, a fresh git worktree of the exact commit being shipped, precisely so that nothing outside the repository could leak into a build. That is the right rule. It also means the checkout does not contain apps/web/.env.local, which is where those two variables had lived on the machine that used to run the deploys. The deploy command was astro build && wrangler deploy, and astro build in a clean tree found no variables.

Two things hid it. Our CI builds the site with zero environment variables on purpose, because no prerendered route may touch the database at build time, so CI was green: it was building exactly the same broken bundle and never deploying it. And the Worker’s runtime configuration does have a PUBLIC_SUPABASE_URL, which reads as if it should cover this, but a value baked into a static bundle at build time cannot be supplied at runtime. The comment next to the client factory already said so.

Two deployments, on 12 and 13 September, went out this way. Both were reported as successful, because they were.

Why nothing logged it

The failure happened entirely inside the visitor’s browser, before any request left it. The server never saw a sign-in attempt, so the server logs had nothing to say. The site did not yet have browser-side error reporting. The pages themselves returned 200, so no uptime check would have noticed. It was a total outage of one feature with a perfect set of metrics.

The fix, in two parts

The hot fix on 14 September rebuilt the same commit that was already live, from a clean checkout again, but with the two variables exported into the build shell. Before deploying, we checked that the built chunk contained the project’s Supabase URL and did not contain the “are not set” throw. The new chunk was 208,403 bytes. Sign-in worked.

The durable fix went into the deploy script. Between astro build and wrangler deploy there is now a gate that opens the built Supabase chunk and refuses to continue unless it contains the project URL and does not contain the single-throw shape above. A build with empty variables cannot reach wrangler deploy at all. The variables themselves are the public URL and the public anonymous key; neither is a secret, so keeping them out of the build was never a security measure, just an accident of where a file lived.

We also wrote the rule down where deployments are described: a site build that is going to be deployed must have those two variables injected, and the gate is the proof.

What we did not do

We did not weaken the clean-checkout rule. The checkout is still built from the commit and nothing else; the two values are passed in from the environment of the deploying shell, and the gate checks the result rather than trusting the process.

We also did not add a synthetic sign-in check that logs in as a real account on a schedule. The gate catches the class of failure we actually had, a bundle that cannot even construct the client, at the moment it is cheapest to catch, before the deploy. A login that breaks for a reason the bundle cannot show would still need someone to try it.

Where the facts come from

The 429-byte chunk and its content were fetched from the live site on 2026-09-14 before the hot fix; the 208,403-byte replacement was fetched after it. The two affected deployments are identified in our deployment log by their Cloudflare version identifiers and the commits they were built from. The gate is scripts-level code in the repository and is exercised on every site deployment since 2026-09-14.