OTP for Email Confirmation Always Expired – Email Shows Confirmed but User Still Blocked
Hey Supabase team! I'm encountering a bug I can’t figure out.
Issue:
When a user clicks the email verification link, they are redirected to our app but see a message saying the OTP is expired. However, in the Supabase dashboard, the user shows as email confirmed with a valid confirmed_at timestamp.
Despite this, when the user tries to log in, they get an error saying “Email not confirmed” and are redirected back to the registration/login page due to our route protection middleware.
Expected behavior:
Once the email is confirmed via link, the user should be allowed to log in.
Current behavior:
User receives the confirmation email
Clicks the link → OTP expired error
Supabase dashboard shows email as confirmed
User can't log in because Supabase returns “Email not confirmed”
Middleware redirects them back to /registration
Here’s a snippet from our middleware (updateSession):
And our login function:
Question:
Why is the OTP considered expired even though the user gets marked as confirmed?
How can I ensure users don't get stuck in this loop?
Would appreciate any help or advice
Issue:
When a user clicks the email verification link, they are redirected to our app but see a message saying the OTP is expired. However, in the Supabase dashboard, the user shows as email confirmed with a valid confirmed_at timestamp.
Despite this, when the user tries to log in, they get an error saying “Email not confirmed” and are redirected back to the registration/login page due to our route protection middleware.
Expected behavior:
Once the email is confirmed via link, the user should be allowed to log in.
Current behavior:
User receives the confirmation email
Clicks the link → OTP expired error
Supabase dashboard shows email as confirmed
User can't log in because Supabase returns “Email not confirmed”
Middleware redirects them back to /registration
Here’s a snippet from our middleware (updateSession):
const {
data: { user },
} = await supabase.auth.getUser();
if (!user && !isPublicRoute) {
return NextResponse.redirect(new URL('/registration', request.url));
}And our login function:
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error?.message === 'Email not confirmed') {
router.push(/confirm-email?email=${encodeURIComponent(email)});
}
Question:
Why is the OTP considered expired even though the user gets marked as confirmed?
How can I ensure users don't get stuck in this loop?
Would appreciate any help or advice