| name | convex-better-auth-dual-database |
| description | Understanding Convex + Better Auth dual-database architecture. Use when: (1) "User not found"
errors during login/password reset but user appears to exist, (2) users exist in app's users
table but can't authenticate, (3) need to create admin users in production, (4) debugging
auth flows in Convex + Better Auth setup. Better Auth stores users in component tables
separate from app tables.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-01-21T00:00:00.000Z" |
Convex + Better Auth Dual-Database Architecture
Problem
When using Better Auth with Convex, users exist in TWO separate locations:
- Better Auth component tables:
betterAuth.user, betterAuth.account, betterAuth.session
- App's users table: Your custom
users table in the main schema
This causes confusing errors like "User not found" when the user exists in one location but not the other.
Context / Trigger Conditions
- "User not found" during login or password reset, but you see the user in your app's database
- User can't authenticate despite having a record in the
users table
- Need to manually create an admin user in production
- Debugging auth flows where users should exist but operations fail
- Export shows users in
_components/betterAuth/user/documents.jsonl separate from users/documents.jsonl
Architecture
┌─────────────────────────────────────────┐
│ Better Auth Component │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ user table │ │ account table │ │
│ │ - email │ │ - password hash │ │
│ │ - name │ │ - providerId │ │
│ │ - verified │ │ - userId │ │
│ └─────────────┘ └──────────────────┘ │
└─────────────────────────────────────────┘
│
│ Login/Auth happens here
▼
┌─────────────────────────────────────────┐
│ App's users table │
│ - email │
│ - name │
│ - isAdmin │
│ - subscriptionStatus │
│ - (business-specific fields) │
└─────────────────────────────────────────┘
Synced via syncFromAuth mutation
Key Points
-
Authentication uses Better Auth tables ONLY
- Login validates against
betterAuth.user and betterAuth.account
- Password hashes are stored in
betterAuth.account
- Sessions are in
betterAuth.session
-
App's users table is for business logic
- Stores app-specific fields (isAdmin, subscriptionStatus, etc.)
- Must be synced AFTER Better Auth user is created
- Sync happens via a mutation like
syncFromAuth
-
Component tables can't be directly accessed
- Can't write mutations that query
betterAuth.user
- Can't import directly to component tables via
convex import --table
- Must use export/modify/import workflow for manual changes
Solution: Creating Users Manually
Step 1: Create Better Auth user (via API)
curl -X POST "https://yourapp.com/api/auth/sign-up/email" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@example.com",
"password": "your-password",
"name": "Admin User"
}'
Step 2: Create app user (via Convex mutation)
export const createAdminUser = mutation({
args: { email: v.string(), name: v.string(), setupSecret: v.string() },
handler: async (ctx, args) => {
if (args.setupSecret !== process.env.ADMIN_SECRET) {
throw new Error("Unauthorized");
}
const userId = await ctx.db.insert("users", {
email: args.email,
name: args.name,
isAdmin: true,
subscriptionStatus: "ACTIVE",
});
return { userId };
},
});
Step 3: Run the mutation
npx convex run --prod adminSetup:createAdminUser \
'{"email": "admin@example.com", "name": "Admin", "setupSecret": "your-secret"}'
Solution: Deleting/Modifying Better Auth Users
Since you can't access component tables directly:
-
Export production data
npx convex export --prod --path /tmp/convex-export
-
Extract and modify
unzip /tmp/convex-export -d /tmp/convex-data
-
Reimport
cd /tmp/convex-data && zip -r ../convex-modified.zip .
npx convex import --prod --replace-all -y /tmp/convex-modified.zip
Verification
- Check both tables when debugging:
users/documents.jsonl AND _components/betterAuth/user/documents.jsonl
- User must exist in BOTH locations for full functionality
- Better Auth user allows authentication
- App user allows business logic (admin access, subscriptions, etc.)
Notes
- The
syncFromAuth pattern is common: on first login, copy Better Auth user to app table
- ADMIN_EMAIL env var should be set in Convex (via
npx convex env set) not just .env.local
- Password hashes in
betterAuth.account use format salt:hash (scrypt-based)
- Turnstile CAPTCHA may be client-side only - API calls can bypass it
Related Issues
- INTERNAL_API_SECRET must be set in BOTH Convex AND your hosting platform (Railway/Vercel)
- Magic link and password reset both require user to exist in Better Auth first