Supabase Auth Error Codes: Fixes + TS Cheat Sheet (2026)
Supabase

Supabase Auth Error Codes: Fixes + TS 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.

Updated
9 min read
Supabase Auth Error Codes: Fixes + TS Cheat Sheet (2026)

Photo by FlyD on Unsplash

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:

ts
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#

CodeWhat it meansUser-facing fix
invalid_credentialsEmail/password pair did not match (or account does not exist)"The email or password is incorrect." Never reveal which.
email_not_confirmedCredentials valid, but email confirmation pending"Please confirm your email." Show Resend button.
user_not_foundAccount does not existSame message as invalid_credentials — don't leak existence.
weak_passwordPassword fails complexity rules"Min 8 chars, 1 number, 1 symbol." Show inline.
same_passwordNew password identical to current"Your new password must be different." Keep user in form.
email_address_invalidEmail format rejected by Supabase"That email looks invalid." Highlight email field.
over_email_send_rate_limitToo many emails sent to this addressShow countdown, not error — ~60s wait.
over_request_rate_limitToo many auth requests from this IPShow countdown — ~30s wait.
signup_disabledProject has signups disabled in dashboard"New signups disabled. Contact support."
email_exists / user_already_existsEmail already registered"Account exists. Sign in" or "Reset password."
phone_existsPhone number already registered"Phone number already in use."
session_expiredAccess token expired, refresh failed"Session expired. Please sign in again."
refresh_token_not_foundRefresh token missing or revokedSame as session_expired.
otp_expiredOne-time code expired"Code expired. Request new code."
captcha_failedCaptcha challenge rejected"Captcha failed. Try again."

Symptoms#

You're shipping this pattern if:

  1. Your error handler reads error.message.
  2. The same toast string shows up for unrelated failures.
  3. Your support inbox has "I'm getting an error" tickets with no detail.
  4. 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.

ts
// 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#

tsx
// 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:

  1. aria-invalid highlights the right field for screen-reader and visual users.
  2. Inline CTA replaces the dead-end error with a next action.
  3. 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:

tsx
{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:

ts
// 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#

  1. Are you reading error.code (not error.message)? If code is undefined, you're on an old @supabase/supabase-js version — upgrade.
  2. Is your fallback path logging the unknown code? Without that, you'll never know which codes to add.
  3. Does email_not_confirmed have a resend CTA wired up? It's the #1 lost-conversion path.
  4. Are invalid_credentials and user_not_found returning the same user-facing message? If not, you're leaking account existence.
  5. Are rate-limit codes treated as countdowns, not errors?
  6. Does TypeScript fail the build if you add a new code without a mapping? If not, the as const is missing.

Prevention#

  • Code review rule: any error.message access in an auth path is a red flag.
  • One mapping file: lib/auth-errors.ts is 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 error so new codes are caught the day Supabase adds them.
  • Treat security-sensitive codes carefully: never let invalid_credentials and user_not_found diverge 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.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.