This error occurs when attempting to sign up, link, or update a phone number that is already associated with another user account in Supabase Auth. This can happen during signup, MFA enrollment, or when updating user phone numbers.
The "phone_exists" error is thrown by Supabase Auth when you attempt to use a phone number that is already linked to an existing user account. Supabase enforces strict phone number uniqueness in the auth.users table to maintain referential integrity and prevent duplicate phone factors. This error commonly occurs in three scenarios: 1. During signup with `auth.signUp({ phone })` when the phone is already registered 2. When updating a user's phone with `auth.updateUser({ phone })` and the new phone belongs to another user 3. When adding a second verified phone factor to MFA enrollment with a phone already claimed by another user The core issue is that Supabase does not currently support automatic phone number recycling, meaning if a user's phone number changes hands (e.g., they cancel service and someone else gets that number), the old account still holds a claim on it.
Before attempting any fixes, confirm whether the phone number is legitimately in use by another account:
import { createClient } from '@supabase/supabase-js';
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: { autoRefreshToken: false, persistSession: false }
}
);
// List all users (search in admin users list)
const { data: { users }, error } = await supabaseAdmin.auth.admin.listUsers();
// Check if your phone number appears in the users list
const phoneInUse = users?.some(u => u.phone === '+1234567890');
console.log('Phone in use:', phoneInUse);This helps determine if you need a workaround or data cleanup.
Add a server-side check before allowing signup with a phone number. Create a Postgres function to check phone availability:
CREATE OR REPLACE FUNCTION public.check_phone_available(phone_to_check TEXT)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN NOT EXISTS (
SELECT 1 FROM auth.users
WHERE phone = phone_to_check AND deleted_at IS NULL
);
END;
$$;Then call it before signup:
const { data: phoneAvailable } = await supabase.rpc('check_phone_available', {
phone_to_check: '+1234567890'
});
if (!phoneAvailable) {
console.error('Phone number already exists. Please use a different number.');
return;
}
const { data, error } = await supabase.auth.signUp({
phone: '+1234567890',
password: 'secure_password'
});For backend operations, use the Admin Auth API with your service role key, which provides more control and clearer error handling:
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
);
// Creating new user with phone
const { data, error } = await supabaseAdmin.auth.admin.createUser({
phone: '+1234567890',
password: 'secure_password',
phone_confirm: true // Pre-confirm phone if desired
});
if (error?.message.includes('phone_exists')) {
console.error('Phone number already registered.');
// Handle error - redirect user to login, offer recovery, etc.
}The Admin API provides consistent error messages that are easier to handle programmatically.
If a user has legitimately reclaimed a phone number (real-world reassignment), an Admin with service role key must manually unlink the old account. This requires server-side code:
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: { autoRefreshToken: false, persistSession: false }
}
);
// This should only be called after verifying the new user owns the phone
const { data: { user }, error } = await supabaseAdmin.auth.admin.updateUserById(
oldUserId,
{ phone: null } // Unlink phone from old account
);
if (error) {
console.error('Failed to unlink phone:', error.message);
return;
}
// Now the phone is available for the new user
const { data: newUser } = await supabaseAdmin.auth.admin.updateUserById(
newUserId,
{ phone: '+1234567890', phone_confirmed_at: new Date() }
);Important: Only do this after verifying OTP on the new phone number to prove ownership.
Implement proper error handling and user messaging:
async function handlePhoneSignup(phone: string, password: string) {
try {
const { data, error } = await supabase.auth.signUp({
phone,
password
});
if (error?.message.includes('phone_exists')) {
// Phone already registered
showError('This phone number is already registered. Please log in or use a different number.');
redirectToLogin();
return;
}
if (error) {
showError(error.message);
return;
}
// Success
showMessage('Account created! Check your phone for verification code.');
} catch (err) {
console.error('Signup failed:', err);
showError('An unexpected error occurred. Please try again.');
}
}Provide clear options: login, password reset, or use a different phone number.
When adding a phone factor to MFA, the recommended flow is to verify OTP before committing the factor:
// Step 1: Request OTP for new phone (this sends SMS)
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'phone',
phone: '+1234567890' // May fail with phone_exists
});
if (error?.message.includes('phone_exists')) {
// Phone already enrolled on another account
// Option 1: Use different phone
// Option 2: Contact support if it's a recycled number
showError('This phone is already enrolled. Use a different phone or contact support.');
return;
}
// Step 2: Verify the OTP that was sent
const { data: verified } = await supabase.auth.mfa.verify({
factorId: data.id,
code: userEnteredCode
});
// Only after successful OTP verification is the factor active
if (verified) {
showMessage('Phone factor successfully added!');
}Phone Number Recycling Problem
One of the biggest challenges is "phone number recycling": real-world phone numbers are reassigned when users cancel service. Supabase does not currently have built-in support for this scenario. The community has requested feature requests (Issue #35950 in supabase/supabase) asking for:
1. Automatic phone unlinking when new OTP verification proves ownership
2. Configuration options to disable strict phone uniqueness
3. Built-in admin methods for phone conflict resolution
Until Supabase implements these features, you must implement custom logic on your backend.
Testing Considerations
When testing signup flows:
- Never hardcode phone numbers in tests - they persist in your database
- Use unique test numbers for each test run (e.g., append timestamp)
- Clean up test accounts in Prisma Studio between test iterations
- In development, reset auth schema if needed: supabase migration reset
Comparing with Email Handling
Unlike email (where obfuscated responses prevent enumeration attacks), Supabase returns a clear "phone_exists" error for phone signups. This is because phone verification happens via SMS, so the constraint is simpler.
Regional Considerations
Phone number formats vary by region. Supabase stores them in E.164 format (+1234567890). Ensure your validation and lookup logic accounts for this. Some systems may have leading zeros that need normalization.
Security: Service Role Key
Never expose your service role key to the client. All the admin operations shown above must run server-side only (backend, Edge Functions, API routes). If exposed, attackers can:
- Create/update/delete user accounts
- Unlink phone numbers from accounts
- Bypass authentication entirely
email_conflict_identity_not_deletable: Cannot delete identity because of email conflict
How to fix "Cannot delete identity because of email conflict" in Supabase
mfa_challenge_expired: MFA challenge has expired
How to fix "mfa_challenge_expired: MFA challenge has expired" in Supabase
conflict: Database conflict, usually related to concurrent requests
How to fix "database conflict usually related to concurrent requests" in Supabase
StorageApiError: resource_already_exists
StorageApiError: Resource already exists
email_address_not_authorized: Email sending to this address is not authorized
Email address not authorized for sending in Supabase Auth