Supabase Auth Error Codes Explained: same_password, weak_password, invalid_credentials (Fix Guide + TypeScript Cheat Sheet 2026)
Technology

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.

2026-05-19
9 min read
Supabase Auth Error Codes Explained: same_password, weak_password, invalid_credentials (Fix Guide + TypeScript Cheat Sheet 2026)

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#

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

  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