Skip to content

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)

Three schema migrations between 0.10 and 1.0 — bundle them into a single db:push after upgrading:

diff
 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:

bash
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-0

All 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:

ts
export { users, tokens, oauthAccounts, tokenTypeEnum } from '@authcore/drizzle-adapter/pg'
// or '/sqlite' — same fields, dialect-specific column types

Login 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:

ts
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:

ts
// Before
const { user, token } = await auth.login(body)

…now fails to type-check. Narrow first:

ts
// After
const result = await auth.login(body)
if ('requires2FA' in result) {
  return reply.send({ requires2FA: true, challengeToken: result.challengeToken })
}
const { user, token, refreshToken } = result

Framework 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:

ts
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

ts
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:

ts
// 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 conceptAuthCore 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 instancecreateAuth({ … })
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:

  1. Add AuthCore alongside Lucia (mount on /api/auth-v2 initially).
  2. Have new sign-ins go through AuthCore.
  3. Old Lucia sessions keep working until they expire.
  4. 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

VersionHighlightsBreaking?
0.9AUTH_SECRET leak fix, cookie-name threading, NestJS 401Direct core callers of forgotPassword
0.10Refresh tokens, CSRF, email templates, callbacksDatabaseAdapter.deleteTokensByUserAndType required
0.11OAuth + PKCE (5 providers), Magic-link, Next.js adapter, Drizzle adapterOAuthAccount table; three new DB adapter methods
0.122FA (TOTP) + recovery codesUser.twoFactorEnabled/Secret; login() returns discriminated union
1.0Stability 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.