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
userstable 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
userstable - •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.jsonlseparate fromusers/documents.jsonl
Architecture
code
┌─────────────────────────────────────────┐
│ 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.userandbetterAuth.account - •Password hashes are stored in
betterAuth.account - •Sessions are in
betterAuth.session
- •Login validates against
- •
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
- •Can't write mutations that query
Solution: Creating Users Manually
Step 1: Create Better Auth user (via API)
bash
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)
typescript
// convex/adminSetup.ts
export const createAdminUser = mutation({
args: { email: v.string(), name: v.string(), setupSecret: v.string() },
handler: async (ctx, args) => {
// Verify secret
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
bash
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
bashnpx convex export --prod --path /tmp/convex-export
- •
Extract and modify
bashunzip /tmp/convex-export -d /tmp/convex-data # Edit _components/betterAuth/user/documents.jsonl # Edit _components/betterAuth/account/documents.jsonl
- •
Reimport
bash# Recreate zip with modifications 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.jsonlAND_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
syncFromAuthpattern 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.accountuse formatsalt: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