If your Supabase Auth UI shows "Login failed" for every error, you're leaving signup completion on the table. The Auth client returns precise codes — use them.
The problem
Most Next.js + Supabase apps do this:
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
setError('Something went wrong. Please try again.');
}
What the user actually needs to know:
- "Your email isn't confirmed yet" → show resend button
- "Password is too weak" → show the rule that failed
- "Too many requests" → show a countdown, not an error
- "New password must differ from old" → keep them in the form, don't redirect
Collapsing all of these into one generic message produces three bad outcomes: users churn, support tickets pile up, and real bugs hide inside the noise.
Cheat sheet — every code, cause, and fix
| Code | What it means | User-facing fix |
|---|---|---|
invalid_credentials |
Email/password pair did not match (or account does not exist) | "The email or password is incorrect." Never reveal which. |
email_not_confirmed |
Credentials valid, but email confirmation pending | "Please confirm your email." Show Resend button. |
user_not_found |
Account does not exist | Same message as invalid_credentials — don't leak existence. |
weak_password |
Password fails complexity rules | "Min 8 chars, 1 number, 1 symbol." Show inline. |
same_password |
New password identical to current | "Your new password must be different." Keep user in form. |
email_address_invalid |
Email format rejected by Supabase | "That email looks invalid." Highlight email field. |
over_email_send_rate_limit |
Too many emails sent to this address | Show countdown, not error — ~60s wait. |
over_request_rate_limit |
Too many auth requests from this IP | Show countdown — ~30s wait. |
signup_disabled |
Project has signups disabled in dashboard | "New signups disabled. Contact support." |
email_exists / user_already_exists
|
Email already registered | "Account exists. Sign in" or "Reset password." |
phone_exists |
Phone number already registered | "Phone number already in use." |
session_expired |
Access token expired, refresh failed | "Session expired. Please sign in again." |
refresh_token_not_found |
Refresh token missing or revoked | Same as session_expired. |
otp_expired |
One-time code expired | "Code expired. Request new code." |
captcha_failed |
Captcha challenge rejected | "Captcha failed. Try again." |
Symptoms
You're shipping this pattern if:
- Your error handler reads
error.message. - The same toast string shows up for unrelated failures.
- Your support inbox has "I'm getting an error" tickets with no detail.
- You don't have a TypeScript type for the codes you actually handle.
The fix — typed error code mapping
Supabase exposes a documented set of codes on AuthError.code. Type them once, branch on them everywhere.
// lib/auth-errors.ts
import type { AuthError } from '@supabase/supabase-js';
export const AUTH_ERROR_CODES = [
'invalid_credentials',
'email_not_confirmed',
'user_not_found',
'weak_password',
'same_password',
'email_address_invalid',
'over_email_send_rate_limit',
'over_request_rate_limit',
'signup_disabled',
'email_exists',
'phone_exists',
'user_already_exists',
'session_expired',
'refresh_token_not_found',
'otp_expired',
'captcha_failed',
] as const;
export type AuthErrorCode = typeof AUTH_ERROR_CODES[number];
export type AuthUserError = {
/** Message safe to show end-users */
message: string;
/** Which form field to highlight, if any */
field?: 'email' | 'password' | 'newPassword' | 'otp';
/** Optional CTA hint for the UI */
action?: 'resend_confirmation' | 'reset_password' | 'wait' | 'contact_support';
/** Seconds to wait before retry, when known */
retryAfter?: number;
};
const ERRORS: Record<AuthErrorCode, AuthUserError> = {
invalid_credentials: {
// SECURITY: never disclose whether the email exists.
message: 'The email or password is incorrect.',
field: 'password',
},
email_not_confirmed: {
message: 'Please confirm your email before signing in.',
field: 'email',
action: 'resend_confirmation',
},
user_not_found: {
// Same message as invalid_credentials — do not leak account existence.
message: 'The email or password is incorrect.',
field: 'email',
},
weak_password: {
message:
'Password must be at least 8 characters and include a number and a symbol.',
field: 'password',
},
same_password: {
message: 'Your new password must be different from your current password.',
field: 'newPassword',
},
email_address_invalid: {
message: 'That email address looks invalid. Double-check the format.',
field: 'email',
},
over_email_send_rate_limit: {
message: 'Too many requests. Try again in a minute.',
action: 'wait',
retryAfter: 60,
},
over_request_rate_limit: {
message: 'You’re going too fast. Please wait a moment.',
action: 'wait',
retryAfter: 30,
},
signup_disabled: {
message: 'New signups are currently disabled.',
action: 'contact_support',
},
email_exists: {
message: 'An account with this email already exists. Try signing in.',
field: 'email',
action: 'reset_password',
},
phone_exists: {
message: 'An account with this phone number already exists.',
field: 'email',
},
user_already_exists: {
message: 'An account with this email already exists. Try signing in.',
field: 'email',
action: 'reset_password',
},
session_expired: {
message: 'Your session expired. Please sign in again.',
},
refresh_token_not_found: {
message: 'Your session expired. Please sign in again.',
},
otp_expired: {
message: 'The code has expired. Request a new one.',
field: 'otp',
},
captcha_failed: {
message: 'Captcha verification failed. Please try again.',
},
};
const FALLBACK: AuthUserError = {
message: 'Something went wrong. Please try again.',
};
/**
* Translate any Supabase AuthError (or unknown error) into a user-facing
* AuthUserError. Logs the raw error for observability.
*/
export function toUserError(error: unknown): AuthUserError {
if (!error) return FALLBACK;
const authError = error as Partial<AuthError>;
const code = authError.code as AuthErrorCode | undefined;
if (code && code in ERRORS) {
return ERRORS[code];
}
// Unknown code: log for triage, show fallback
console.error('[auth] unhandled error', {
code: authError.code,
status: authError.status,
message: authError.message,
});
return FALLBACK;
}
The as const + typeof trick gives you compile-time safety: if you add a new code to AUTH_ERROR_CODES but forget the ERRORS entry, TypeScript fails the build.
Using it in a Next.js form
// app/sign-in/SignInForm.tsx
'use client';
import { useState } from 'react';
import { createBrowserClient } from '@supabase/ssr';
import { toUserError, type AuthUserError } from '@/lib/auth-errors';
export function SignInForm() {
const [authError, setAuthError] = useState<AuthUserError | null>(null);
const [isPending, setPending] = useState(false);
async function onSubmit(formData: FormData) {
setPending(true);
setAuthError(null);
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const { error } = await supabase.auth.signInWithPassword({
email: String(formData.get('email')),
password: String(formData.get('password')),
});
if (error) {
setAuthError(toUserError(error));
}
setPending(false);
}
return (
<form action={onSubmit}>
<label>
Email
<input
name="email"
type="email"
aria-invalid={authError?.field === 'email'}
required
/>
</label>
<label>
Password
<input
name="password"
type="password"
aria-invalid={authError?.field === 'password'}
required
/>
</label>
{authError && (
<div role="alert" className="text-red-600">
{authError.message}
{authError.action === 'resend_confirmation' && (
<button type="button" onClick={resendConfirmation}>
Resend confirmation email
</button>
)}
{authError.action === 'reset_password' && (
<a href="/reset-password">Reset password</a>
)}
</div>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Signing in…' : 'Sign in'}
</button>
</form>
);
}
async function resendConfirmation() {
// call your resend route
}
Notice three UX wins from typed errors:
-
aria-invalidhighlights the right field for screen-reader and visual users. - Inline CTA replaces the dead-end error with a next action.
-
role="alert"announces the error without forcing focus.
Handling rate limits properly
over_email_send_rate_limit is the easiest UX win in the entire auth flow. Instead of an error:
{authError?.action === 'wait' && (
<Countdown seconds={authError.retryAfter ?? 60} />
)}
A countdown tells the user the system is healthy and the wait is temporary. Generic errors make them assume the site is broken and leave.
Server-side / Route Handler usage
Same helper works in route handlers and server actions:
// app/api/auth/sign-in/route.ts
import { NextResponse } from 'next/server';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { toUserError } from '@/lib/auth-errors';
export async function POST(req: Request) {
const { email, password } = await req.json();
const cookieStore = await cookies();
const supabase = createServerClient(/* ... */);
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
const userError = toUserError(error);
return NextResponse.json({ error: userError }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
The server sends a structured error; the client renders it without re-mapping. Single source of truth.
Debug checklist
- Are you reading
error.code(noterror.message)? Ifcodeisundefined, you're on an old@supabase/supabase-jsversion — upgrade. - Is your fallback path logging the unknown code? Without that, you'll never know which codes to add.
- Does
email_not_confirmedhave a resend CTA wired up? It's the #1 lost-conversion path. - Are
invalid_credentialsanduser_not_foundreturning the same user-facing message? If not, you're leaking account existence. - Are rate-limit codes treated as countdowns, not errors?
- Does TypeScript fail the build if you add a new code without a mapping? If not, the
as constis missing.
Prevention
-
Code review rule: any
error.messageaccess in an auth path is a red flag. -
One mapping file:
lib/auth-errors.tsis the only place that knows English strings. i18n later by swapping the messages, not the call sites. -
Log unknown codes: Sentry/Datadog alert on
[auth] unhandled errorso new codes are caught the day Supabase adds them. -
Treat security-sensitive codes carefully: never let
invalid_credentialsanduser_not_founddiverge in user-facing text.
This is the lowest-effort, highest-impact change you can make to a Supabase Auth UI. Two hours of work, a measurable lift in signup completion.
Related reading
- Hub: Next.js + Supabase: The Complete Resource Hub
- Hub: Supabase Debugging Hub
- Supabase Auth Redirect Not Working in Next.js App Router: Exact Fix
- Handle Supabase Auth Errors in Next.js Middleware: Redirects, Sessions, Codes
- Fix Supabase Auth Session Persistence
- Supabase Email Confirmation Fix
- Supabase Authentication & Authorization (Guide)
Originally published at https://www.iloveblogs.blog
Top comments (0)