Supabase Auth Error Codes Explained: same_password, weak_password, invalid_credentials (Fix Guide + TypeScript Cheat Sheet 2026)
Supabase Auth returns precise error codes — `invalid_credentials`, `weak_password`, `same_password`, `email_not_confirmed` — but most apps collapse them all into "Something went wrong." Here's the full TypeScript enum, a typed handler, and the UX pattern that doubles signup completion.
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)
Frequently Asked Questions
Continue Reading
Fix Supabase Auth Session Not Persisting After Refresh
Supabase auth sessions mysteriously disappearing after page refresh? Learn the exact cause and fix it in 5 minutes with this tested solution.
Supabase Auth Redirect Not Working in Next.js App Router: Exact Fix
Auth redirects failing in Next.js App Router? Learn the exact cause and fix it with this complete guide including OAuth and magic link redirects.
Handle Supabase Auth Errors in Next.js Middleware
Auth errors crashing your Next.js middleware? Learn how to handle Supabase auth errors gracefully with proper error handling patterns.
Browse by Topic
Find stories that matter to you.