Nest Authbeta

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 flat IAuthModuleOptions object.
  • TenancyTenantModeEnum is 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)v1v2Action
API keys (nest_auth_access_keys.privateKey)plaintext, compared with ===domain-separated SHA-256 hash, constant-time compareRegenerate 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 codeHave users regenerate their recovery code; old ones won't validate.
OTP codes (nest_auth_otps.code)plaintextHMAC-SHA256, select: falseIn-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

  1. Pin v1 so you can roll back (e.g. "@ackplus/nest-auth": "1.1.69-beta.9").
  2. Snapshot your database. Schema changes are additive plus a few column drops (below) — a snapshot is your safety net.
  3. Inventory your blast radius — grep for the renamed symbols:
    # backend config
    grep -rn "jwt:\|accessTokenExpiresIn\|refreshTokenExpiresIn\|sessionExpiry\|refreshTokenExpiry\|accessTokenType\|cookieOptions\|passwordResetTokenExpiresIn\|passwordResetOtpExpiresIn\|otpLength\|otpExpiresIn\|defaultOtp\|slidingExpiration\|serialize" src/
    # client SDK
    grep -rn "\.getUser(\|getUserAccesses\|getTenantMemberships\|\.isAuthenticated(\|onAuthStateChange\|resendVerification\|\.refresh(" src/
    # react
    grep -rn "\.user\b\|autoLoadMe\|resendVerification\|from '@ackplus/nest-auth-react'" src/
    # contracts / data shape
    grep -rn "IAuthUser\|isVerified\|\.otp\b\|\.used\b\|\.token\b" src/
  4. 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:

pnpm add @ackplus/nest-auth@2 \
         @ackplus/nest-auth-client@2 \
         @ackplus/nest-auth-react@2 \
         @ackplus/nest-auth-contracts@2
# new in v2 (optional, only if you build a mobile app):
pnpm add @ackplus/nest-auth-react-native@2     # React Native / Expo
# Flutter (pub.dev): add `nest_auth_flutter: ^2.0.0` to pubspec.yaml

The admin dashboard still ships inside @ackplus/nest-auth (enable with adminConsole) — 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

ChangeTable / columnNotes
New tablenest_auth_platform_accessesOne row per platform super-admin (the new platform-admin feature).
New join tablenest_auth_platform_access_rolesM2M linking platform accesses → roles.
New columnnest_auth_trusted_devices.revokedAtdatetime, nullable — explicit device revocation.
New (relation only, no column on users)NestAuthUser.platformAccess (OneToOne)FK lives on nest_auth_platform_accesses.userId.

Changed

ChangeColumnDetail
Renamed + retypednest_auth_trusted_devices.tokentokenHashwas indexed varchar; now text, select: false. Stores an HMAC, not the raw token.
Now hashed + hiddennest_auth_otps.codesame varchar column, but values are HMAC-SHA256 and select: false.
Now hiddennest_auth_users.passwordHashselect: false — no longer returned by default find(). If you relied on it being populated, addSelect it explicitly.

Dropped (review before upgrading)

RemovedWhereWhat to do
Column isVerifiednest_auth_usersUse emailVerifiedAt / phoneVerifiedAt (timestamps) instead. Drop the column or leave it orphaned.
Column usednest_auth_otpsv2 deletes consumed OTP rows instead of marking used. Drop the column.
Join table nest_auth_role_nest_auth_usersdirect user↔role M2MThe 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:

npx typeorm migration:generate ./migrations/UpgradeNestAuthV2 -d ./your-data-source.ts

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.

// v1
NestAuthModule.forRoot({
  jwt: {
    secret: env.JWT_SECRET,
    accessTokenExpiresIn: '15m',     // ← gone
    refreshTokenExpiresIn: '30d',    // ← gone
    validateToken: (p, s) => true,
  },
});
 
// v2
NestAuthModule.forRoot({
  session: {
    jwt: { secret: env.JWT_SECRET, validateToken: (p, s) => true },
    accessTokenValidity: '15m',      // ← renamed + moved under session
    refreshTokenValidity: '30d',     // ← renamed + moved under session
  },
});

If you don't move it, JWT signing throws Missing session.jwt.secret at runtime.

Other token-TTL renames (under session)

v1v2
session.sessionExpirysession.accessTokenValidity
session.refreshTokenExpirysession.refreshTokenValidity
jwt.accessTokenExpiresInsession.accessTokenValidity
jwt.refreshTokenExpiresInsession.refreshTokenValidity

Options that moved from root → nested

v1 (root)v2
accessTokenTypesession.accessTokenType (now also allows null)
cookieOptionssession.cookieOptions
passwordResetTokenExpiresInpassword.passwordResetTokenExpiresIn

Options removed (with replacement)

v1v2
passwordResetOtpExpiresIn (root)removed → use otp.codeExpiresIn (default 30m)
mfa.otpLengthremoved → mfa.otp.length
mfa.otpExpiresInremoved (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:

session: { slidingExpiration: true }   // restore v1 behavior

Step 4 — Backend hooks

user.serializeuser.getSessionUserData

Renamed; the return type widened to any and it may be async.

// v1
user: { serialize: (user) => ({ id: user.id, email: user.email }) }
// v2
user: { getSessionUserData: (user) => ({ id: user.id, email: user.email }) }

password.validate was removed

The IPasswordHooks interface is gone; password is now an inline object with hash, verify, argon2, and passwordResetTokenExpiresInno 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).

// v1
registrationHooks: { onSignup: async (user, input, ctx) => { user.x = 1; return user; } }
// v2
registrationHooks: { onSignup: async (user, input, ctx) => { user.x = 1; /* ctx.manager available; no return */ } }

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)

v1v2What to do
client.getUser() (sync, cached)removedUse await client.getSessionUserData() (async, hits /auth/me), or read from session events.
client.getUserAccesses()removedRead 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)removedUse onSessionVerified() / onRefreshSessionData(), or getTokenState() / subscribeTokenState().
config.onAuthStateChangeremovedSame 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 otpcode (the param is now IVerifyForgotPasswordOtpRequest).
EndpointConfig (interface, all optional)type EndpointConfig = typeof DEFAULT_ENDPOINTSAll 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 | nullThe 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.

// v1
const user = client.getUser();
if (client.isAuthenticated()) { /* ... */ }
await client.resendVerification({ email });
const pair = await client.refresh();          // TokenPair
 
// v2
const user = await client.getSessionUserData();
if (client.getIsAuthenticated()) { /* ... */ }
await client.sendEmailVerification({ email });
const pair = await client.refresh();          // ITokenPair | null  ← null-check

Step 6 — React SDK (@ackplus/nest-auth-react)

useNestAuth() / useUser(): usersessionData

// v1
const { user } = useNestAuth();   const verified = user?.isVerified;
// v2
const { sessionData } = useNestAuth();   const verified = Boolean(sessionData?.emailVerifiedAt);

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 new sendPhoneVerification, verifyPhone).
  • verifyForgotPasswordOtp param type is now IVerifyForgotPasswordOtpRequest (otpcode).

AuthProvider props

  • autoLoadMe removed — session data now always loads on mount (via getSessionData / isLoadingSessionData).
  • InitialAuthState.user removed — SSR hydration takes session / status only.

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:

// v1
import { AuthClient, hasRole } from '@ackplus/nest-auth-react';
// v2
import { AuthClient, hasRole } from '@ackplus/nest-auth-client';

Behavior changes to know

  • isAuthenticated is now status === 'authenticated' only (v1 also required a non-null user). Don't assume sessionData is populated the instant isAuthenticated is true — gate on isLoadingSessionData / sessionData.
  • isLoading is now true while status is loading or session data is being fetched.
  • useHasRole / useHasPermission (and the RequireRole / RequirePermission guards) now evaluate against sessionData instead of user.
  • RequirePermission no 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

v1v2
IAuthUserremovedISessionUserData (different shape; isVerifiedemailVerifiedAt, etc.). No longer exported.
IResetPasswordRequest.otp.code
IVerifyForgotPasswordOtpRequest.otp.code
IVerifyEmailRequest.otp.code (+ new tenantId?)
INestAuthUser.isVerifiedremovedemailVerifiedAt / phoneVerifiedAt
IUserResponse.isVerifiedremovedemailVerifiedAt? / phoneVerifiedAt?
INestAuthOTP.usedremoved
IAuthResponse.userremoved (login/signup response no longer embeds the user)
IVerify2faResponse.userremoved
INestAuthTrustedDevice.tokentokenHash (+ 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 / deleteUser each 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 rejects email_verified === false when google.requireVerifiedEmail is on.
  • API-key lookup no longer throws on unknown/inactive/expired keys — validateAccessKey() returns false (constant-time). Roles are eager-loaded via user.userAccesses[].roles (not user.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) and nest_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 new LOGIN_FAILED, passwordless, and verification events.
  • OAuth hardening: Google requireVerifiedEmail / audiences, Apple native audiences / 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, skipAuthHeader request option.
  • React: useAuthHeaderFn / useAuthHeaderFnSync, isLoadingSessionData, getSessionData, passwordlessSend, verifyPhone.

Verify

After it compiles, exercise the paths the breaking changes touch:

  1. Sign up → log in → call a guarded endpoint.
  2. Refresh twice with the same token (the second must be rejected — rotation), and confirm your code handles a null from client.refresh().
  3. Trigger password reset + email/phone verification (the otpcode rename).
  4. Rotate and use a new API key; have a user regenerate an MFA recovery code.
  5. Read a user object and confirm you read emailVerifiedAt (not isVerified).

Rollback

  1. Re-pin the packages to your v1 version and reinstall.
  2. 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