Migrating from 0.x to 1.0
This guide walks through every breaking change accumulated between 0.10 and 1.0, organized so you can skim for the bits that affect you.
If you're on 0.10 or 0.11, you can adopt 0.12 today — same migration applies. The 1.0 tag will be the version with no further breaking changes planned, not a code rewrite.
Skip ahead
- Schema changes (Prisma users)
- Schema changes (Drizzle users)
- Login return-type change
- DatabaseAdapter new methods (custom adapters only)
Userinterface changesauth.forgotPasswordrequiresresetUrl(direct-core callers)- Coming from Lucia
- Coming from NextAuth / Auth.js
Schema changes (Prisma)
Three schema migrations between 0.10 and 1.0 — bundle them into a single db:push after upgrading:
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
emailVerified Boolean @default(false)
role String @default("user")
+ twoFactorEnabled Boolean @default(false)
+ twoFactorSecret String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tokens Token[]
+ oauthAccounts OAuthAccount[]
}
+model OAuthAccount {
+ id String @id @default(uuid())
+ userId String
+ provider String
+ providerAccountId String
+ accessToken String
+ refreshToken String?
+ expiresAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([provider, providerAccountId])
+ @@index([userId])
+}
enum TokenType {
EMAIL_VERIFICATION
PASSWORD_RESET
SESSION
INVITATION
+ REFRESH
+ MAGIC_LINK
+ RECOVERY_CODE
}Apply with:
pnpm --filter @authcore/prisma-adapter db:push
# or generate a migration if you're using `prisma migrate`:
pnpm exec prisma migrate dev --name authcore-1-0All existing data stays — these are additive changes (new columns get defaults, new enum values widen the type).
Schema changes (Drizzle)
If you adopted @authcore/drizzle-adapter after 0.12, you're already on the final schema. The bundled tables (users, tokens, oauth_accounts) include every field. Re-export them from your schema and drizzle-kit generate produces the right SQL:
export { users, tokens, oauthAccounts, tokenTypeEnum } from '@authcore/drizzle-adapter/pg'
// or '/sqlite' — same fields, dialect-specific column typesLogin return-type change (potentially breaking for everyone)
auth.login() previously returned { user, token, refreshToken } unconditionally. As of 0.12 it returns a discriminated union so 2FA-enabled users get a challenge instead of a half-session:
type LoginResult = SessionResult | TwoFactorChallengeResult
interface SessionResult { user: PublicUser; token: string; refreshToken: string }
interface TwoFactorChallengeResult { requires2FA: true; challengeToken: string }If no user in your app has 2FA enabled, behavior is identical at runtime. Type-wise, existing code like:
// Before
const { user, token } = await auth.login(body)…now fails to type-check. Narrow first:
// After
const result = await auth.login(body)
if ('requires2FA' in result) {
return reply.send({ requires2FA: true, challengeToken: result.challengeToken })
}
const { user, token, refreshToken } = resultFramework adapter users (Express/Fastify/NestJS/Next.js): nothing to do. The bundled adapters already handle both branches and pass the challenge through to the client transparently.
Direct core callers (rare): update your login() consumers to narrow on the union. Recipe above.
DatabaseAdapter additions (custom adapters only)
If you wrote your own DB adapter, four new methods are required since 0.10:
interface DatabaseAdapter {
// …existing…
// 0.10:
deleteTokensByUserAndType(userId: string, type: TokenType): Promise<void>
// 0.11:
findOAuthAccount(provider: string, providerAccountId: string): Promise<OAuthAccount | null>
createOAuthAccount(data: CreateOAuthAccountInput): Promise<OAuthAccount>
updateOAuthAccount(
id: string,
data: Partial<Pick<OAuthAccount, 'accessToken' | 'refreshToken' | 'expiresAt'>>,
): Promise<OAuthAccount>
}If your app doesn't use OAuth, the three OAuth methods can throw new Error('OAuth not configured') — AuthCore never calls them unless config.oauth is set.
The User interface also gained twoFactorEnabled: boolean and twoFactorSecret: string | null. Your findUserBy* queries need to return these fields; your updateUser needs to accept them.
The TokenType union gained 'REFRESH' | 'MAGIC_LINK' | 'RECOVERY_CODE'. Any switch/exhaustiveness check on Token.type will fail to compile until you add cases.
User interface
interface User {
// …existing…
twoFactorEnabled: boolean // 0.12+
twoFactorSecret: string | null // 0.12+
}
type PublicUser = Omit<User, 'passwordHash' | 'twoFactorSecret'>PublicUser now exposes twoFactorEnabled (clients need it for "is 2FA on?" UI) but still hides twoFactorSecret. If you're using useAuth<MyUser>() with a custom user shape, extend your type accordingly.
auth.forgotPassword requires resetUrl (direct core callers)
Was breaking in 0.9. Re-listed here because it's the most common gotcha for people upgrading from <0.9:
// Before 0.9 — implicit URL, leaked AUTH_SECRET into emails (CVE)
await auth.forgotPassword({ email })
// 0.9 onwards — explicit URL required
await auth.forgotPassword({ email }, { resetUrl: `${baseUrl}/reset-password` })Framework adapter users aren't affected — every adapter supplies the URL automatically.
If you ran 0.5–0.8 with passwordReset enabled in production: rotate AUTH_SECRET immediately. Old reset emails contained the JWT signing secret in plaintext. See SECURITY.md "Known Past Issues".
Coming from Lucia
Lucia is winding down in 2025. AuthCore covers most of its territory:
| Lucia concept | AuthCore equivalent |
|---|---|
lucia.createSession() | auth.login() — returns a JWT, not a session row |
lucia.validateSession() | auth.verifyToken(jwt) |
lucia.invalidateSession() | auth.revoke(refreshToken) |
lucia.invalidateUserSessions() | auth.revokeAll(userId) |
| Session-based auth (DB session row) | AuthCore uses JWT + refresh tokens by default. Same security properties: refresh rotation invalidates stolen tokens on next use; revokeAll is a global kill switch. |
Lucia instance | createAuth({ … }) |
Provider plugins (@lucia-auth/oauth) | oauth: { google, github, … } config field with bundled providers |
The biggest difference: Lucia stores sessions in the database; AuthCore uses JWT access tokens + a refresh-token table. If you have an existing Lucia DB with a sessions table, you don't have to migrate the data — old sessions will simply expire on their own. New sign-ins go through AuthCore.
The recommended migration path:
- Add AuthCore alongside Lucia (mount on
/api/auth-v2initially). - Have new sign-ins go through AuthCore.
- Old Lucia sessions keep working until they expire.
- After your max-Lucia-session-TTL has passed, remove Lucia.
Coming from NextAuth / Auth.js
NextAuth covers a much bigger surface than AuthCore (80+ providers, role-based adapters, etc.). The migration is more involved — most users will keep NextAuth if they're happy with it.
If you're moving because you want code-not-config: AuthCore lets you build the auth flow yourself with primitives (auth.login, auth.verifyTwoFactor, etc.). You write your own forms, pages, and email templates instead of using NextAuth's pre-built ones.
If you're moving because you want fewer dependencies: AuthCore's core has 3 runtime deps (bcryptjs, jsonwebtoken, zod); NextAuth's has more.
If you're moving because of bundle size on Edge: AuthCore's middleware is presence-only and edge-compatible; route handlers run on Node. NextAuth has been working toward edge compatibility too — check if you actually have a measurable problem first.
Database compatibility: NextAuth uses tables like users, accounts, sessions, verification_tokens. AuthCore uses users, tokens, oauth_accounts. Migrating data is mostly straightforward (one-to-one row-level translation), but the OAuth account → user joining works differently — AuthCore links on email at first sign-in only.
Coming from Clerk / Auth0
You're moving from a hosted service to self-hosted. Things to plan for:
- Email delivery is your problem now. Pick
@authcore/resend-adapter(~$20/mo for 50k emails) or@authcore/nodemailer-adapter(any SMTP, including Mailgun/SendGrid). - No hosted UI. AuthCore is headless — you build the forms. Tradeoff: full control over branding/UX, no $25/MAU bill.
- No social login config UI. You register OAuth apps yourself at Google/GitHub/etc., put the credentials in env vars.
- No prebuilt rate limiting / IP allowlists / impossible-travel detection. Wire your own via
callbacks.onFailedLogin+ your own metrics. - No SAML / SCIM / enterprise SSO built in. Build on top of OAuth or wait for a community plugin.
The break-even is usually around 1,000 MAU. Below that, hosted services are still cheaper than your time.
Version cheat sheet
| Version | Highlights | Breaking? |
|---|---|---|
| 0.9 | AUTH_SECRET leak fix, cookie-name threading, NestJS 401 | Direct core callers of forgotPassword |
| 0.10 | Refresh tokens, CSRF, email templates, callbacks | DatabaseAdapter.deleteTokensByUserAndType required |
| 0.11 | OAuth + PKCE (5 providers), Magic-link, Next.js adapter, Drizzle adapter | OAuthAccount table; three new DB adapter methods |
| 0.12 | 2FA (TOTP) + recovery codes | User.twoFactorEnabled/Secret; login() returns discriminated union |
| 1.0 | Stability declaration. No new breaking changes planned. | None — 1.0 is the same code as 0.12 with the stability commitment. |
Got stuck?
Open an issue with your starting version, the error, and a tiny repro. Migration friction is a real bug — we'd rather fix the path than leave you stuck.