A sign-in that only looked like it worked
Sign in to gamekal.com with Google and everything looks right. You pick your account and land back on the site. Then the header still says Log in. The address bar ends in ?code= and a long id. Try again and the same thing happens, every time. Sign in with Discord instead and it works on the first try.
It’s fixed. Both providers now sign in on the web, including from a fresh private window. The sign-in screens on iPhone also show gamekal.supabase.co instead of a random-looking project address, and nobody who was already signed in got signed out by that change.
This post covers why a sign-in can fail after every visible step succeeds, the one response that explained it, and the two-part fix. It also covers the domain change that came with it, and the one line that kept every existing session alive.
How sign-in works here
GameKal uses Supabase Auth with the PKCE flow, which is the standard choice for browser and mobile apps. It’s worth knowing the shape, because the bug lives in it.
- Before leaving the site, the app makes a random secret, the code verifier, saves it in the browser, and sends only a hash of it, the challenge, to Supabase.
- The provider (Google or Discord) confirms who you are and sends you back to Supabase, which sends you back to the site with a one-time
code. - Back on the site, the app trades that code plus the saved verifier for a session. Supabase checks that the verifier matches the challenge from step 1.
If step 3 fails, nothing visible tells you. You’ve already seen Google’s screen and you’re back on the site. You’re just not signed in.
One detail matters later. On the web, GameKal prepares both sign-in links as soon as the page loads, Google’s and Discord’s, so a tap can leave the page at once. That came from an earlier fix: DuckDuckGo on a phone dropped the redirect when it started only after the tap had waited for the challenge to be made. Keep that in mind.
The response that explained it
The address bar already said step 2 worked: there was a code. So I opened the Network tab with Preserve log on, signed in with Google once more, and clicked the one red request, POST /auth/v1/token?grant_type=pkce:
{
"code": "bad_code_verifier",
"message": "code challenge does not match previously saved code verifier"
}
The browser had sent a verifier. It was the wrong one.
The request’s origin was https://www.gamekal.com on both ends, so this wasn’t a www versus bare-domain mix-up, which was my first guess.
One slot, two secrets
Each prepared link makes its own secret. Preparing Google’s link saves Google’s verifier. Preparing Discord’s link a moment later saves Discord’s. Supabase’s client has one storage key for the verifier, so the second one overwrites the first.
Discord was always prepared last. So the slot always held Discord’s verifier. Tap Discord and the secrets match. Tap Google, and the app comes back and sends Discord’s secret for Google’s challenge. Supabase is right to refuse it.
That explains “Discord works every time and Google never does” exactly. The iPhone app wasn’t affected, because it creates the verifier at the moment you tap.
Why Google failed and Discord didn't
What was in the browser's single verifier slot at each step. Not to scale.
400 bad_code_verifier
The setting that should have prevented it
Supabase’s JavaScript client has an option for exactly this case. With it on, each sign-in gets its own stored verifier, and the client adds a flow id, sb_flow_id, to the return address so it can find the right one. GameKal had it turned on.
So why didn’t it help? The return address the app asked for was https://www.gamekal.com/?sso=google&sb_flow_id=…. Supabase only sends you back to addresses on its allowed list, and the list had https://gamekal.com/** but not the www version. When the requested address isn’t allowed, Supabase quietly falls back to the project’s Site URL, https://gamekal.com, and drops the query. The flow id was gone. Without it, the client falls back to the one shared slot, which held Discord’s secret.
Two reasonable defaults, a quiet fallback in each, and a bug that only one of two providers could see.
The fix, in two parts
The allowed list. In Supabase, under Authentication, URL Configuration, I added https://www.gamekal.com/** to the Redirect URLs. A Google return now looks like /?sso=google&sb_flow_id=…&code=…, and the client finds the right verifier by its flow id. This is the real fix, and it’s a dashboard setting.
A belt to go with the braces. In code, when each link is prepared, its verifier is also copied to a provider-specific key. When you tap a button, that provider’s verifier is written back into the shared slot in the same tap, before the page leaves. It’s synchronous, so phones still don’t drop the redirect, and it doesn’t depend on the allowed list being right. Modifier and middle clicks now sign in in the same tab instead of opening a new one that would skip this step.
The address in the sign-in sheet
While testing the iPhone app, the first thing the sign-in flow shows is an iOS system sheet: “GameKal” wants to use <project-ref>.supabase.co to sign in. That’s a 20-character random project id. Discord’s consent screen, a step later, showed https://gamekal.supabase.co, the vanity subdomain the project already had.
It’s a small thing that matters at the exact moment you’re asking someone to trust you with an account. So the app moved to the vanity address everywhere: the local environment file, the website’s production settings, and the app build service’s production settings.
Changing the address could have signed everyone out. Supabase’s client names the saved session after the first part of the URL’s hostname. A new hostname means a new name, and the app wouldn’t find anyone’s existing session. The fix is one line: pin storageKey to the old name, sb-<project-ref>-auth-token. Same string as before, so existing sessions on the web, iOS and Android are found where they’ve always been.
Nothing about the data changes. The vanity address is another name for the same project: same database, same users, same shelves. Still, before shipping I exported the two tables that hold every user’s shelf and its history, confirmed the daily backups, and had the sign-in and sync code audited for any path where a re-login could write an empty shelf over a real one. That audit found one, unrelated to the address: a “Reset Device” pressed while signed out stayed armed, and a later sign-in whose account read failed could carry it over the account. A reset now only counts for the signed-in user who pressed it.
After the deploy, I checked the live site’s JavaScript: the vanity host is in, the project-ref host is gone, and the pinned session name is there. Then the real checks: a browser that was already signed in stayed signed in with the same shelf, Google and Discord both signed in from a private window, and the iPhone app stayed signed in across the update.
What I learned
Looking back, three lessons stand out.
A redirect allowlist is part of your auth code. It decides what comes back in the URL, including the parts your client depends on. When your site has two hostnames, list both.
Quiet fallbacks hide loud bugs. Supabase fell back to the Site URL, the client fell back to the shared slot, and the user fell back to being signed out, with no error on screen. The one honest signal was a 400 in the Network tab.
The name in the sign-in sheet is product design. People decide whether to continue in that half-second. Pay for the clean domain, and then make sure changing it doesn’t cost anyone their session.
What’s next?
The token exchange failing should never look like success. If the exchange fails, the page should say so and offer to try again, instead of leaving a ?code= in the address bar and a Log in button on screen.
The fix was a line in a dashboard and a few lines in a click handler. Finding it took one red request that I’d been scrolling past all along.