Every form on PubTrivia's sign-in page leads somewhere I have had to be careful about since: Continue with Google, email and password, and the "Forgot password?" link underneath them. This is about the third one, and about one line that was awaited and then thrown away.
Here is the line, as it shipped:
await supabase.auth.exchangeCodeForSession(passwordData.code)
const { error } = await supabase.auth.updateUser({ password: passwordData.password })
if (error) return { message: error.message }
The second call is checked. The first is not. It is awaited, so it finishes before the next statement runs, which is enough to make the code look sequential and correct. What it does not do is notice failure. exchangeCodeForSession does not throw on a bad code. Like most of the Supabase client, it resolves to { data, error } and leaves the decision to you, and if you never bind error the decision is made: carry on.
Two different wrong answers, one missing check
Carrying on means reaching updateUser, and updateUser does not take a user id. It acts on the session the client currently holds. So the behaviour split by who was holding one.
A signed-out visitor following an expired link had no session. updateUser failed, Supabase returned "Auth session missing", and that raw string was rendered into the form's toast as the explanation. Ugly, confusing, harmless.
A signed-in user following the same expired link had a perfectly good session: their own. The exchange failed, nothing checked it, and updateUser changed the password on the live account using a session that had nothing to do with the reset token. The form then said it worked, because from its point of view it had.
That is the part worth sitting with. The feature is "prove you control this mailbox, then set a new password". The proof step failed and the set step ran anyway. Nobody else's account was reachable, but a password change had just happened on the strength of a cookie rather than a token, which is precisely the thing a reset flow exists not to do.
The fix is three lines and an ordering decision
if (!passwordData.code) {
return { message: 'That reset link is invalid. Please request a new one.' }
}
const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(passwordData.code)
if (exchangeError) {
return { message: 'That reset link has expired or has already been used. Please request a new one.' }
}
const { error } = await supabase.auth.updateUser({ password: passwordData.password })
if (error) return { message: error.message }
The empty-code guard earns its own branch because an empty string is not a failed exchange, it is a visitor who reached the page without a link at all, and the two deserve different sentences.
The ordering above it matters more than it looks. The whole action runs in this sequence:
- refuse if the site is in maintenance mode
- rate limit, 5 attempts per 15 minutes
- check the two password fields match
- check the code is present
- exchange the code
- update the password
The field comparison sits deliberately ahead of the exchange. A reset code is single use: exchange it, then fail on "passwords do not match", and the user has to request a whole new email because of a typo in the confirm box. Cheap local checks before anything consumable. That is a rule I would now write on the wall.
How the code gets into the form at all
The reset link lands on a page that is noIndex and does nothing but render the form. The code arrives in the query string and is carried into the POST by a hidden input:
function GetCodeHiddenInput() {
const searchParams = useSearchParams();
return <Input type="hidden" name="code" value={searchParams.get('code')!} />
}
That non-null assertion is a lie, and it is the lie the server now covers for. searchParams.get('code') returns null for anybody who opens the page directly, the assertion tells TypeScript to stop asking, and the field posts empty. Which is fine, as long as the thing receiving it treats empty as a refusal rather than as a value. Before the fix, empty was a value.
The component is wrapped in Suspense on its own, separately from the rest of the form, because useSearchParams opts a component out of static rendering. Isolating the one component that reads the URL keeps the rest of the page static.
Then I went looking for the same shape elsewhere
The bug is not "I forgot a check". It is a shape: await someSdkCall() with no destructuring, in an SDK that reports failure in the return value. That shape is greppable, and there were two more of it in the same file.
Google sign-in:
const { data } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { ... } })
if (data.url) redirect(data.url)
error is not bound. When the provider call fails there is no URL, no redirect, and no message: you press "Continue with Google" and the page sits there. It is the mildest possible version of the same mistake, and it produces the single worst bug report a developer can receive, which is "the button does nothing".
Sign out:
await supabase.auth.signOut()
redirect('/login')
Here it is arguably correct, and I left it. If sign-out fails we still want the browser on the sign-in page, and the right follow-up is a cookie that no longer validates, not a toast. Worth being explicit that it is a decision rather than an oversight, which is the only reason the line is allowed to look like the one at the top of this post.
Why this flow carries more weight than it used to
A single-account product has one password per customer. Our Ultimate plan is five host accounts on one subscription, each one a person who can start a quiz in a room full of people, and each one a mailbox that can ask for a reset. The reset form is the one place in the product where somebody who cannot sign in gets to change something, and that makes it the one place where an unchecked error is not a cosmetic bug.
The general lesson, with the specifics filed off: when an SDK returns errors instead of throwing them, await on its own is a silent catch. It reads like the careful version of the call. Grep your auth code for calls whose result you never bind, and ask of each one what runs next if it failed.
Top comments (0)