Migration (v1 → v2)
Exhaustive upgrade guide from @ackplus/nest-auth v1.x to v2 — every breaking change, config rename, schema change, SDK change, and behavior change, with before/after code.
This guide takes an existing v1.x app (baseline 1.1.69-beta.x) to v2. Install the current stable, 2.2.0 (all packages on the same version). It is exhaustive — every v1→v2 change a consumer can observe is listed, grouped so you can jump to what you use. A typical app upgrades in 1–2 hours; longer if you have many custom hooks or a hand-managed database schema.
Already on v2 and just bumping a minor (e.g.
2.0.x → 2.2.x)? That's additive/non-breaking — see Upgrading within v2 instead; this page is only for the v1 → v2 jump.
Every item below was verified against the actual v1 and v2 source. If something here doesn't match your code, file an issue — don't guess.
The good news first
These did not change, so you don't have to touch them:
- Module wiring — still
NestAuthModule.forRoot(options)/forRootAsync(...). No plugin system; config is still one flatIAuthModuleOptionsobject. - Tenancy —
TenantModeEnumis unchanged (ISOLATED = 'isolated',SHARED = 'shared');tenant: { enabled, mode }is identical. - Event keys — no event was removed or renamed; every existing
NestAuthEvents.*string is byte-identical. v1 listeners keep working (v2 only adds new events). - Default route paths —
/auth/...is still the default (now configurable, see below). Most routes are unchanged.
Everything else is below.
⚠️ Do this first: stored secrets that v2 re-hashes
v2 stores several secrets hashed at rest that v1 stored in plaintext. Existing rows silently stop validating after upgrade (they fail closed — no crash). Plan these before cutover:
| Secret (table) | v1 | v2 | Action |
|---|---|---|---|
API keys (nest_auth_access_keys.privateKey) | plaintext, compared with === | domain-separated SHA-256 hash, constant-time compare | Regenerate every key. Capture the new plaintext once (it's never recoverable). |
MFA recovery codes (nest_auth_users.mfaRecoveryCode) | plaintext (speakeasy base32) | HMAC-SHA256 of a random code | Have users regenerate their recovery code; old ones won't validate. |
OTP codes (nest_auth_otps.code) | plaintext | HMAC-SHA256, select: false | In-flight OTPs (reset/verify codes) issued before upgrade won't validate — users just request a new code. No action needed. |
Trusted-device tokens (nest_auth_trusted_devices) | plaintext token column (indexed) | tokenHash (text, select: false) | Existing trusted devices simply re-prompt for MFA once; the user re-trusts the device. |
There's no automatic re-hash for any of these — the only value that could be re-hashed is the plaintext, and storing it was the weakness. Rotation is the path.
Pre-upgrade checklist
- Pin v1 so you can roll back (e.g.
"@ackplus/nest-auth": "1.1.69-beta.9"). - Snapshot your database. Schema changes are additive plus a few column drops (below) — a snapshot is your safety net.
- Inventory your blast radius — grep for the renamed symbols:
- Confirm Node ≥ 20 (v2 declares
engines.node >= 20).
Step 1 — Bump versions
There are now five npm packages (the React Native SDK is new) plus a Flutter package. Upgrade the npm ones in lockstep:
The admin dashboard still ships inside
@ackplus/nest-auth(enable withadminConsole) — there's no separate package to install.
Step 2 — Database schema
v2 adds tables/columns and drops a few. With synchronize: true most of this applies automatically; with migrations, generate one and review it.
Added
| Change | Table / column | Notes |
|---|---|---|
| New table | nest_auth_platform_accesses | One row per platform super-admin (the new platform-admin feature). |
| New join table | nest_auth_platform_access_roles | M2M linking platform accesses → roles. |
| New column | nest_auth_trusted_devices.revokedAt | datetime, nullable — explicit device revocation. |
| New (relation only, no column on users) | NestAuthUser.platformAccess (OneToOne) | FK lives on nest_auth_platform_accesses.userId. |
Changed
| Change | Column | Detail |
|---|---|---|
| Renamed + retyped | nest_auth_trusted_devices.token → tokenHash | was indexed varchar; now text, select: false. Stores an HMAC, not the raw token. |
| Now hashed + hidden | nest_auth_otps.code | same varchar column, but values are HMAC-SHA256 and select: false. |
| Now hidden | nest_auth_users.passwordHash | select: false — no longer returned by default find(). If you relied on it being populated, addSelect it explicitly. |
Dropped (review before upgrading)
| Removed | Where | What to do |
|---|---|---|
Column isVerified | nest_auth_users | Use emailVerifiedAt / phoneVerifiedAt (timestamps) instead. Drop the column or leave it orphaned. |
Column used | nest_auth_otps | v2 deletes consumed OTP rows instead of marking used. Drop the column. |
Join table nest_auth_role_nest_auth_users | direct user↔role M2M | The direct NestAuthUser.roles / NestAuthRole.users link is gone — roles are now reached through userAccesses (user.userAccesses[].roles). Migrate any direct queries. |
Apply with synchronize: true (next boot) or a reviewed migration:
Step 3 — Backend config (IAuthModuleOptions)
The biggest change: JWT and several token/cookie options moved under session, and token-TTL keys were renamed to *Validity.
jwt moved to session.jwt (and lost its TTL fields)
In v1, jwt was a required top-level object that also held the token TTLs. In v2 there is no root jwt; it lives at session.jwt and holds only secret (+ optional validateToken). The TTLs became session.accessTokenValidity / session.refreshTokenValidity.
If you don't move it, JWT signing throws
Missing session.jwt.secretat runtime.
Other token-TTL renames (under session)
| v1 | v2 |
|---|---|
session.sessionExpiry | session.accessTokenValidity |
session.refreshTokenExpiry | session.refreshTokenValidity |
jwt.accessTokenExpiresIn | session.accessTokenValidity |
jwt.refreshTokenExpiresIn | session.refreshTokenValidity |
Options that moved from root → nested
| v1 (root) | v2 |
|---|---|
accessTokenType | session.accessTokenType (now also allows null) |
cookieOptions | session.cookieOptions |
passwordResetTokenExpiresIn | password.passwordResetTokenExpiresIn |
Options removed (with replacement)
| v1 | v2 |
|---|---|
passwordResetOtpExpiresIn (root) | removed → use otp.codeExpiresIn (default 30m) |
mfa.otpLength | removed → mfa.otp.length |
mfa.otpExpiresIn | removed (no replacement on mfa) |
mfa.defaultOtp (fixed dev/test code) | removed — no replacement |
Behavior default change: slidingExpiration
session.slidingExpiration now defaults to false (was true). Sessions are no longer extended on activity unless you set it explicitly:
Step 4 — Backend hooks
user.serialize → user.getSessionUserData
Renamed; the return type widened to any and it may be async.
password.validate was removed
The IPasswordHooks interface is gone; password is now an inline object with hash, verify, argon2, and passwordResetTokenExpiresIn — no validate. Enforce password policy in registrationHooks.beforeSignup or a DTO validator.
onSignup / onLogin now return void
Both narrowed to Promise<void> | void — a v1 hook that returned the user no longer typechecks. Mutate the passed-in user in place. Their context also gained a transactional manager (the hook now runs inside the signup transaction; onLogin's context additionally gained userAccess and platformAccess).
otp.generate gained a format argument
(length?) => string → (length?, format?: 'numeric' | 'alphanumeric') => string. Existing generators keep working (the new arg is optional).
New, additive hooks (nothing to change)
IUserHooks gained transactional beforeUpdate / afterUpdate / beforeDelete / afterDelete, and afterCreate gained an optional manager third arg. See the hooks reference.
Step 5 — Client SDK (@ackplus/nest-auth-client)
| v1 | v2 | What to do |
|---|---|---|
client.getUser() (sync, cached) | removed | Use await client.getSessionUserData() (async, hits /auth/me), or read from session events. |
client.getUserAccesses() | removed | Read accesses from the session user data. |
client.getTenantMemberships() | removed | (was a deprecated alias) — same. |
client.isAuthenticated() (method) | client.getIsAuthenticated() | Renamed; isAuthenticated is now a private field, not callable. |
client.onAuthStateChange(cb) (method) | removed | Use onSessionVerified() / onRefreshSessionData(), or getTokenState() / subscribeTokenState(). |
config.onAuthStateChange | removed | Same replacements. |
client.resendVerification(dto) | sendEmailVerification(dto?) / sendPhoneVerification(dto?) | Split per channel; dto is now optional. |
client.refresh(): Promise<TokenPair> | client.refresh(): Promise<ITokenPair | null> | Null-check the result — cookie mode returns null (cookies update server-side). |
config.onTokenRefreshed: (TokenPair) | (tokens: ITokenPair | null) | Callback can now receive null in cookie mode. |
verifyForgotPasswordOtp({ otp }) | verifyForgotPasswordOtp({ code }) | Field renamed otp → code (the param is now IVerifyForgotPasswordOtpRequest). |
EndpointConfig (interface, all optional) | type EndpointConfig = typeof DEFAULT_ENDPOINTS | All fields are now required strings. If you declared a partial EndpointConfig, spread DEFAULT_ENDPOINTS or use Partial<EndpointConfig>. |
DEFAULT_ENDPOINTS.me = '/auth/user' | = '/auth/me' | If you overrode endpoints.me, update it. |
AuthState.user: IAuthUser | null | : ISessionUserData | null | The shape changed (see contracts). |
Behavior: client.verifyEmail() no longer mutates a cached user (this.user.isVerified = true); it emits a refreshSessionData event instead. Re-fetch session data if you showed a cached "verified" flag.
Step 6 — React SDK (@ackplus/nest-auth-react)
useNestAuth() / useUser(): user → sessionData
useUser() now returns ISessionUserData | null from context.sessionData (was IAuthUser | null). The verification flag changed from isVerified (boolean) to emailVerifiedAt / phoneVerifiedAt (timestamps).
Method changes on the useNestAuth() value
resendVerification(dto)→sendEmailVerification(dto?)(plus newsendPhoneVerification,verifyPhone).verifyForgotPasswordOtpparam type is nowIVerifyForgotPasswordOtpRequest(otp→code).
AuthProvider props
autoLoadMeremoved — session data now always loads on mount (viagetSessionData/isLoadingSessionData).InitialAuthState.userremoved — SSR hydration takessession/statusonly.
The package no longer re-exports client types
@ackplus/nest-auth-react dropped all client re-exports — AuthClient, AuthUser/AuthSession/AuthState/AuthError, the DTO aliases, and hasRole/hasPermission/hasAnyAccess/hasAllAccess. Import them from the client package:
Behavior changes to know
isAuthenticatedis nowstatus === 'authenticated'only (v1 also required a non-null user). Don't assumesessionDatais populated the instantisAuthenticatedis true — gate onisLoadingSessionData/sessionData.isLoadingis now true while status isloadingor session data is being fetched.useHasRole/useHasPermission(and theRequireRole/RequirePermissionguards) now evaluate againstsessionDatainstead ofuser.RequirePermissionno longer factors authentication into the decision — it denies only on missing permission.
Step 7 — Shared contracts (@ackplus/nest-auth-contracts)
import { IFoo } → import type { IFoo } for type-only symbols
The barrel split interfaces/type-aliases into export type { ... } (only the two enums remain value exports). Under isolatedModules / verbatimModuleSyntax (or esbuild ≥ 0.27), import { ILoginRequest } must become import type { ILoginRequest }.
Renamed / removed types
| v1 | v2 |
|---|---|
IAuthUser | removed → ISessionUserData (different shape; isVerified → emailVerifiedAt, etc.). No longer exported. |
IResetPasswordRequest.otp | .code |
IVerifyForgotPasswordOtpRequest.otp | .code |
IVerifyEmailRequest.otp | .code (+ new tenantId?) |
INestAuthUser.isVerified | removed → emailVerifiedAt / phoneVerifiedAt |
IUserResponse.isVerified | removed → emailVerifiedAt? / phoneVerifiedAt? |
INestAuthOTP.used | removed |
IAuthResponse.user | removed (login/signup response no longer embeds the user) |
IVerify2faResponse.user | removed |
INestAuthTrustedDevice.token | tokenHash (+ new revokedAt?) |
Enum change
NestAuthOTPTypeEnum lost VERIFICATION = 'verification' and gained PASSWORDLESS_LOGIN, MAGIC_LINK_LOGIN, EMAIL_VERIFICATION, PHONE_VERIFICATION. Code comparing against VERIFICATION / the string 'verification' breaks — use EMAIL_VERIFICATION / PHONE_VERIFICATION.
Step 8 — Behavior changes (same API, new runtime behavior)
Even where the API didn't change, these runtime behaviors did — worth knowing before you ship:
- Refresh-token rotation + reuse detection. Each refresh issues a new token (unique
jti) and stores its HMAC on the session; a replayed/old refresh token is now rejected. Existing sessions (empty stored hash) are handled gracefully — the first refresh populates it, no forced re-login. - Refresh hardening. Refresh now re-validates the user (active? has tenant/platform access?) and revokes the session (reason
security) if not — and re-resolves roles/permissions on every refresh. v1 only checked the token signature + session id. - JWT algorithm pinning. Signing/verification are pinned to
HS256(prevents algorithm-confusion). No action unless you somehow issued non-HS256 tokens. - Password-reset token lifetime fix. v1 had a unit bug (added
ms()milliseconds to a seconds exp claim → tokens lived ~41 days). v2 computes seconds correctly, so'1h'now genuinely means one hour. - Atomic user mutations.
createUser/updateUser/deleteUsereach run in a single transaction; the user hooks run inside it (a throwing hook rolls the whole thing back — no partial users). - MFA verification side-effect. A successful email/SMS MFA verification now stamps
emailVerifiedAt/phoneVerifiedAt(only when null). - Google sign-in: verifies against multiple audiences (
google.audiences+clientId) for native iOS/Android id-tokens, and rejectsemail_verified === falsewhengoogle.requireVerifiedEmailis on. - API-key lookup no longer throws on unknown/inactive/expired keys —
validateAccessKey()returnsfalse(constant-time). Roles are eager-loaded viauser.userAccesses[].roles(notuser.roles).
What's new in v2 (additive — nothing to migrate)
A condensed list; see the changelog for detail:
- New SDKs:
@ackplus/nest-auth-react-native(npm) andnest_auth_flutter(pub.dev). - Platform admin (
platformAccess), passwordless login (passwordless), phone verification routes. - Custom session store (
session.store+SessionStorageType.CUSTOM). - Configurable routes (
routePrefix,adminConsole.path). - Role/permission lifecycle events (
ROLE_*,PERMISSION_*) and newLOGIN_FAILED, passwordless, and verification events. - OAuth hardening: Google
requireVerifiedEmail/audiences, Apple nativeaudiences/jwksUrl, GitHub Enterprise endpoint overrides. - OTP customization (
otp.secret/codeExpiresIn/generate), Argon2 tuning (password.argon2), trusted-device HMAC secret (mfa.trustedDeviceSecret). - Client:
getSessionUserData,socialLogin,passwordlessSend,getAuthHeaders(Sync),getTokenState/subscribeTokenState,skipAuthHeaderrequest option. - React:
useAuthHeaderFn/useAuthHeaderFnSync,isLoadingSessionData,getSessionData,passwordlessSend,verifyPhone.
Verify
After it compiles, exercise the paths the breaking changes touch:
- Sign up → log in → call a guarded endpoint.
- Refresh twice with the same token (the second must be rejected — rotation), and confirm your code handles a
nullfromclient.refresh(). - Trigger password reset + email/phone verification (the
otp→coderename). - Rotate and use a new API key; have a user regenerate an MFA recovery code.
- Read a user object and confirm you read
emailVerifiedAt(notisVerified).
Rollback
- Re-pin the packages to your v1 version and reinstall.
- Restore the database snapshot from the checklist — v2 drops a few columns/tables, so a snapshot is the clean revert.
Keep all packages on the same major — don't mix v1 and v2.
Getting help
- Issues: github.com/ack-solutions/nest-auth/issues — use the
migrationlabel. - Include your previous version, your
NestAuthModule.forRootconfig (secrets redacted), and the exact error.