Migrating from Better Auth
One-time cutover from Better Auth to the native convex-auth runtime.
This is a one-time cutover, not a long-term bridge. The convex-better-auth-adapter and convex-better-auth packages are only used during the migration. Once the data is moved and the client/runtime is cut over, you remove them.
A runnable cutover lives in examples/better-auth-migration.
What the cutover looks like
- Mount the legacy
convex-better-auth-adapter(betterAuth) component and the nativeconvex-auth(convexAuth) component in the same Convex app. - Run the one-time data migration.
- Cut over
convex/convex.config.tsandconvex/http.tsto nativeconvex-auth. - Swap the React client to
@vortex-api/convex-auth/react. - Remove
convex-better-authandconvex-better-auth-adapterfrompackage.json.
What is and is not migrated
Migrated:
- Users (email, name, image, email verified status)
- Credential accounts (password hashes — Better Auth scrypt is supported)
- OAuth accounts (provider/issuer/subject, so the same social sign-in keeps working)
- Sessions are migrated as rows, but the old tokens are not usable. Users must sign in again.
Not migrated:
- TOTP 2FA secrets and backup codes. Migrated users have
twoFactorEnabledset tofalseso they are not locked out. They must re-enroll inconvex-auth. - Passkeys, organizations, members, and other advanced Better Auth tables. These are not part of the one-time migration today.
Prerequisites
convexCLI installed- pnpm and Node
>=22.0.0 - A Better Auth 1.7.x setup on the vendored
convex-better-auth-adapter(0.13.5) - The native
convex-authcomponent available (1.7.6)
Step 1 — Mount both components
convex/auth.config.ts:
import { createConvexAuthProvider } from "@vortex-api/convex-auth/convex";
export default {
providers: [createConvexAuthProvider()],
};
convex/convex.config.ts:
import { defineApp } from "convex/server";
import { v } from "convex/values";
import auth from "@vortex-api/convex-auth/convex.config";
import betterAuth from "convex-better-auth-adapter/convex.config";
const app = defineApp({
env: {
JWT_PRIVATE_KEY: v.string(),
JWKS: v.string(),
},
});
app.use(auth, {
env: {
JWT_PRIVATE_KEY: app.env.JWT_PRIVATE_KEY,
JWKS: app.env.JWKS,
},
});
app.use(betterAuth, { name: "betterAuth" });
export default app;
convex/http.ts:
import { httpRouter } from "convex/server";
import { auth } from "./auth";
import betterAuth from "convex-better-auth-adapter/http";
const http = httpRouter();
auth.addHttpRoutes(http);
betterAuth(http);
export default http;
Set the required environment variables on your Convex deployment:
convex env set JWT_PRIVATE_KEY '<private-key-json>'
convex env set JWKS '<jwks-json>'
convex env set BETTER_AUTH_SECRET '<better-auth-secret>'
convex env set EMAIL_FROM_ADDRESS 'auth@yourdomain.com'
Step 2 — Run the data migration
pnpm dlx @vortex-api/convex-auth migrate better-auth
The CLI migrates users first, then accounts, then sessions. It is idempotent: re-running without --resume resets from the beginning. Use --resume to continue from the stored cursor.
Flags:
--dry-run— preview the plan and legacy table counts without writing anything.--resume— continue a previously started migration from the stored cursor.--cutover— after migration, rewriteconvex/convex.config.tsandconvex/http.tsto remove the legacy adapter and dropconvex-better-auth/convex-better-auth-adapterfrompackage.json.--batch-size <n>— number of records per migration batch (default: 100).
After the CLI returns, the migrate:migrateUsers batch is done. Accounts and sessions continue in the background through the migration scheduler. Watch them with:
pnpm dlx convex run --component betterAuth/migrations lib:getStatus '{"names":["migrate:migrateUsers","migrate:migrateAccounts","migrate:migrateSessions"]}'
When all three are success, the data migration is complete.
Step 3 — Verify migrated data
pnpm dlx convex data users --component convexAuth
pnpm dlx convex data auth_identities --component convexAuth
pnpm dlx convex data authAccounts --component convexAuth
pnpm dlx convex data authSessions --component convexAuth
Password users should be able to sign in through the native convex-auth HTTP routes (/api/auth/sign-in/email) with their existing passwords.
Step 4 — Cut over the runtime
If you ran pnpm dlx @vortex-api/convex-auth migrate better-auth --cutover, the files were already rewritten. Otherwise, do it manually.
convex/convex.config.ts after cutover:
import { defineApp } from "convex/server";
import { v } from "convex/values";
import auth from "@vortex-api/convex-auth/convex.config";
const app = defineApp({
env: {
JWT_PRIVATE_KEY: v.string(),
JWKS: v.string(),
},
});
app.use(auth, {
env: {
JWT_PRIVATE_KEY: app.env.JWT_PRIVATE_KEY,
JWKS: app.env.JWKS,
},
});
export default app;
convex/http.ts after cutover:
import { httpRouter } from "convex/server";
import { auth } from "./auth";
const http = httpRouter();
auth.addHttpRoutes(http);
export default http;
convex/auth.ts remains the same as the native setup:
import { components } from "./_generated/api";
import { convexAuth, type EmailDraft } from "@vortex-api/convex-auth/convex";
const siteUrl = process.env.CONVEX_SITE_URL?.replace(/\/+$/, "");
export const auth = convexAuth({
component: components.convexAuth,
emailAndPassword: {
enabled: true,
email: {
from: process.env.EMAIL_FROM_ADDRESS ?? "auth@example.com",
appOrigin: siteUrl,
sendEmail: async (draft: EmailDraft) => {
console.log("Email draft", draft);
return "email-id";
},
sendOnSignUp: true,
sendOnSignIn: false,
},
},
oauth: {
github: {
clientId: process.env.GITHUB_CLIENT_ID ?? "",
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID ?? "",
clientSecret: process.env.DISCORD_CLIENT_SECRET ?? "",
},
},
});
export const {
signUp,
signIn,
signOut,
updateSession,
sendEmailVerification,
verifyEmail,
sendPasswordReset,
resetPassword,
verifyPassword,
twoFactorEnable,
twoFactorVerifyTOTP,
twoFactorVerifyBackupCode,
} = auth;
Step 5 — Swap the React client
Replace Better Auth’s client with @vortex-api/convex-auth/react:
// src/main.tsx
import { ConvexReactClient, ConvexProvider } from "convex/react";
import { ConvexAuthClientProvider } from "@vortex-api/convex-auth/react";
import { api } from "../convex/_generated/api";
import App from "./App";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
function Root() {
return (
<ConvexProvider client={convex}>
<ConvexAuthClientProvider actions={api.auth}>
<App />
</ConvexAuthClientProvider>
</ConvexProvider>
);
}
Components use useAuthActions from @vortex-api/convex-auth/react:
import { useAuthActions } from "@vortex-api/convex-auth/react";
export function SignIn() {
const { signIn } = useAuthActions();
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
await signIn({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign in</button>
</form>
);
}
Step 6 — Remove the bridge packages
pnpm remove convex-better-auth convex-better-auth-adapter
Then remove any remaining Better Auth client imports and configuration from your app.
Troubleshooting
invalid_email_or_password after migration
The user’s password hash is probably from a format convex-auth does not yet support. The current release supports Better Auth’s default scrypt salt:derivedKey format. If you changed Better Auth’s password hashing, open an issue with the exact hash format.
No migrated user found for legacy user
The migration ran accounts/sessions before users, or a user was added after the migration started. Re-run with the default (reset) behavior so users are migrated first.
Sessions are not preserved
Migrated sessions are rows in the native authSessions table, but the tokens are not migrated. Users must sign in again and obtain new convex-auth tokens.
Better Auth to convex-auth mapping
This document maps Better Auth concepts to the native convex-auth runtime. It is useful if you are migrating from Better Auth or comparing the two systems.
What changed
convex-auth is now a fully native Convex auth runtime. It does not import or depend on better-auth at runtime. The convex-better-auth-adapter and convex-better-auth packages are used only for the one-time data and client migration.
Mapping Better Auth plugins to convex-auth
| Better Auth plugin | convex-auth replacement |
|---|---|
organization |
packages/auth/src/component/organizations.ts — orgs, members, invitations |
admin |
packages/auth/src/component/scopes.ts and servicePrincipals.ts — roles and permissions |
api-key |
packages/auth/src/component/apiKeys.ts — API key issuance, rotation, and verification |
two-factor |
packages/auth/src/component/identity.ts — TOTP and backup codes |
oauth-provider |
packages/auth/src/mcp.ts and agent-auth-protocol/ — MCP and agent auth flows |
webhooks |
packages/auth/src/component/webhooks.ts — webhook fan-out and security |
| Authentication | convex-auth native email/password, OAuth, session minting, JWT/JWKS, and password reset actions |
The data that used to live in Better Auth’s adapter tables is now stored directly in the convexAuth component tables (users, auth_identities, authAccounts, authSessions, etc.).
Migration terminology
| Better Auth term | convex-auth term |
Notes |
|---|---|---|
user |
users |
Stored in the convexAuth component. |
account |
authAccounts + auth_identities |
Password and OAuth accounts are both identities. |
session |
authSessions |
Native JWT sessions, not Better Auth opaque sessions. |
organization |
organizations / members |
Convex-native B2B control plane. |
apiKey |
authApiKeys |
API keys and service sessions. |
twoFactor |
auth_identities.totp |
TOTP secret and backup codes per identity. |
jwt |
JWT_PRIVATE_KEY / JWKS env |
Convex signs and verifies tokens with crypto.subtle and jose. |
oauth |
Provider metadata + HTTP actions | Google, GitHub, and Discord are built in; provider metadata is pure data, not a runtime framework. |
What happens to the bridge packages after migration
Once the one-time migration finishes and the consumer cuts over to the native runtime, convex-better-auth and convex-better-auth-adapter are removed from package.json. They should not be used for new features or kept as a runtime dependency.
Migrating from @convex-dev/better-auth
If you were using @convex-dev/better-auth, the move to this workspace is mostly a package-name change. The runtime semantics are the same; the adapter was vendored to keep it on the Better Auth 1.7 line and in the same repo as the higher-level Convex primitives.
1. Install the new package
pnpm remove @convex-dev/better-auth
pnpm add convex-better-auth-adapter
2. Update imports
Replace any imports from @convex-dev/better-auth with the same path under convex-better-auth-adapter:
// Before
import { convexClient } from "@convex-dev/better-auth/client/plugins";
// After
import { convexClient } from "convex-better-auth-adapter/client/plugins";
React / React Native provider imports also move:
// Before
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
// After
import { ConvexBetterAuthProvider } from "convex-better-auth-adapter/react";
3. Bump Better Auth to 1.7.x
Update your package.json to require better-auth in the >=1.7.1 <1.8.0 range:
{
"dependencies": {
"better-auth": "^1.7.2"
}
}
The adapter is not compatible with Better Auth 1.6.x or earlier.
4. Account issuer migration
Better Auth 1.7 changed how the account table stores the provider issuer. The adapter includes a backfill path for existing data, but you should read the migration notes in the upstream PR that landed this work:
If you are starting a new project, no migration is needed — the 1.7 schema is used from the beginning.
5. React Native / Expo storage
If you use the Expo client, @vortex-api/convex-auth/react-native now wires the expoClient storage with both sync and async SecureStore methods. You should still pass a SecureStore-compatible object as storage, but the package no longer needs a custom sync-only wrapper.
6. Update your convex version
The rest of the workspace currently requires convex >=1.39.0. Make sure your app is on at least that version.
7. Run the full local proof
After the rename and bump, run:
pnpm install
pnpm run typecheck
pnpm run build
pnpm run test
If you were previously on Better Auth 1.6, read the Better Auth 1.7 release notes first — there may be auth-options or plugin changes outside the adapter.
Better Auth feature parity
Use this table to find the convex-auth equivalent for each Better Auth doc page. The implementation is different because auth state lives in Convex, but the feature surface is the same.
| Better Auth page | Convex Auth 2.0 equivalent | convex-auth alternative |
|---|---|---|
| Installation | Setup | Quickstart |
| Basic usage / email & password | Passwords | Email and password |
| Basic usage / social sign-on | OAuth | OAuth |
| Basic usage / session | React client | React client |
| Two-factor | — | Two-factor authentication |
| Organizations | — | Organizations |
| API keys | — | API keys |
| Magic link | Magic links | Magic links |
| Email OTP | OTPs | Email OTP |
| Webhooks | — | Webhooks |
| Configuration / options | Server API reference | Configuration |
If a page is marked “coming next,” it has not been written yet. Open an issue or PR if you need it first.