Login says "invalid email or password" but credentials are correct

Your account exists, your password is right, but every login attempt is rejected.

Symptom: you can sign in at /login with the password you set, but the server returns 401 {"error":"invalid email or password"}. Signup at /register may return new row violates row-level security policy for table "users".

Root cause

Row-Level Security got enabled on auth tables (users, orgs, org_members, api_tokens, audit_log). The Vercel serverless functions connect through Supabase's connection pooler as a role that is subject to RLS, so the login endpoint's SELECT password_hash FROM users WHERE email = $1 returned zero rows even when the row existed — bcrypt.compare never ran, response is 401.

The Supabase SQL Editor uses postgres superuser (RLS-bypassing) by default, which hides the bug — running the query manually still shows your hash, fooling you into thinking the data is fine.

Fix

Run in Supabase SQL Editor:

ALTER TABLE users        DISABLE ROW LEVEL SECURITY;
ALTER TABLE orgs         DISABLE ROW LEVEL SECURITY;
ALTER TABLE org_members  DISABLE ROW LEVEL SECURITY;
ALTER TABLE api_tokens   DISABLE ROW LEVEL SECURITY;
ALTER TABLE audit_log    DISABLE ROW LEVEL SECURITY;

(Codified in migration 007_fix_rls_auth_tables.sql.)

Why this is safe

The five auth tables are never exposed directly to clients. Every read/write goes through the Next.js API routes, which control what gets returned (password hashes never leave the server, emails are returned only to the owning user via /api/auth/me, etc.). The app's session-cookie / bearer-token authorization layer is the actual gate.

Tenant-data tables (memories, namespaces, doc_pages, webhook_endpoints, webhook_deliveries) still have RLS enabled — those need it because SDK clients hit them with their own bearer tokens and we don't want one org reading another org's rows.

Diagnosis if it's not RLS

If RLS is already off and login still 401s:

  1. Confirm your account row exists:

    SELECT id, email, length(password_hash), substring(password_hash, 1, 4)
    FROM users WHERE email = 'you@example.com';
    

    Hash should be 60 chars, prefix $2a$ or $2b$.

  2. Check that the password isn't being autofilled with a stale value — open DevTools → Network → submit login → click the request → "Payload" tab → look at what password is actually being sent.

  3. If all else fails, reset the hash via SQL using a known bcryptjs hash matching a new password.

Last updated 2026-05-17edit