otpCode in plaintext, or legacy email authentication and recovery flows that deliver encrypted credential bundles. New integrations should use the current flow documented in Email auth & recovery and SMS authentication.
If your integration calls initOtpAuth followed by otpAuth, start with Migrating from legacy OTP Auth. That flow changes both the activity sequence and how the client receives and uses credentials.
If your integration uses emailAuth or initUserEmailRecovery followed by recoverUser, start with Migrating from legacy email authentication and recovery.
What changed
ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7 is transitional: it accepts both
legacy verification tokens and the enclave-issued tokens returned by
VERIFY_OTP_V2. ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V8 accepts only
enclave-issued tokens. During a staged rollout, legacy attempts must remain on
V7; updated attempts can use V7 or V8.INIT_OTP_V3returnsotpIdandotpEncryptionTargetBundle.- The client generates or selects a P-256 key pair.
- The client uses
encryptOtpCodeToBundleto encrypt the OTP code and public key withotpEncryptionTargetBundle. VERIFY_OTP_V2accepts the encrypted bundle and returns a verification token bound to the client public key.- The client signs the login or signup payload with the same private key.
OTP_LOGIN_V2or the sub-organization creation flow validates the verification token and client signature.
Migrating from legacy OTP Auth
This section applies to any integration that callsinitOtpAuth and otpAuth, regardless of SDK. These methods are exposed through @turnkey/http, apiClient() in @turnkey/sdk-server, the browser SDK’s passkeyClient / iframeClient / walletClient / indexedDbClient, and the httpClient exposed by Core and Wallet Kit. The built-in OTP helpers in Wallet Kit and Core don’t use the legacy flow, but direct calls such as useTurnkey().httpClient.otpAuth(...) still require migration. To check whether you’re affected, search your code for initOtpAuth or otpAuth(.
The legacy flow used two activities: one to send the code, and one to verify it and return a credential. The replacement flow splits verification and login into separate activities:
All four legacy activities are deprecated.
Replace initialization, verification, and login
These steps assume your backend holds a parent-organization API key, which stays on the backend. If you submit OTP activities with sub-organization credentials instead, use that sub-organization ID for every step and skip step 5; see Sub-organization initiated OTP.- Before you deploy: update policies. In the parent organization, permit the three replacement activities for the non-root API user that submits them, preserving existing user and organization restrictions. A root API user satisfying a root quorum threshold of one does not need these additional allow policies. See Update policies before deploying.
- Backend: replace
initOtpAuthwithinitOtp. SubmitACTIVITY_TYPE_INIT_OTP_V3under the parent organization with the contact,otpType(OTP_TYPE_EMAILorOTP_TYPE_SMS), and requiredappName. ReturnotpIdandotpEncryptionTargetBundleto the client.- If you use
INIT_OTP_AUTHorINIT_OTP_AUTH_V2, moveappNameout ofemailCustomizationinto the top-levelappNamefield.INIT_OTP_V3requires it, and itsemailCustomizationno longer acceptsappName. See Breaking change:appNamerequired. - If you rely on the organization’s default email template, have Turnkey configure it for
ACTIVITY_TYPE_INIT_OTP. Default templates registered forACTIVITY_TYPE_INIT_OTP_AUTHare not selected automatically by the new flow. An existing template passed explicitly throughemailCustomization.templateIdcan still be selected. Review its content for compatibility with the new flow. - Review your other email and SMS customization parameters against the new activity rather than copying the legacy request unchanged.
- If you use
- Client: encrypt the code. Generate or select a P-256 key pair. Use
encryptOtpCodeToBundle(otpCode, otpEncryptionTargetBundle, publicKey)to encrypt the code and public key, and send only the resultingencryptedOtpBundleto your backend. The private key stays on the client. - Backend: verify with
verifyOtp. SubmitACTIVITY_TYPE_VERIFY_OTP_V2withotpIdandencryptedOtpBundle, using the same organization ID as initialization. Return theverificationTokento the client; it is bound to the client key used in the encrypted bundle. - Backend: resolve the user’s sub-organization. After verification, look up the sub-organization for the contact, for example with
getSubOrgIdsfiltered byEMAILorPHONE_NUMBER. In the legacy flow you resolved the sub-organization before initialization, becauseINIT_OTP_AUTHis submitted to it. Move that lookup here, after the code is verified. - Client: sign the login message. Build the message with
getClientSignatureMessageForLoginand sign it with the private key bound during verification. The message covers the token ID and the session public key; do not sign an arbitrary JSON representation of the token. - Backend: replace
otpAuthwithotpLogin. SubmitACTIVITY_TYPE_OTP_LOGIN_V2to the user’s sub-organization withverificationToken, the sessionpublicKey, andclientSignature. - Client: store and use the session. Use the returned
sessionwith the private key matching the sessionpublicKeyyou passed toOTP_LOGIN_V2, following your SDK’s session handling. Remove the legacy credential-bundle decryption and import step. See Credential handling changes.
Before (legacy OTP Auth)
turnkey is an initialized Turnkey instance from @turnkey/sdk-server, configured with the parent organization’s ID and API key on the backend. The client uses @turnkey/react-wallet-kit. Application request handlers, React state, and error handling are omitted; keep the returned values as state for the same OTP attempt.
Backend: initialize and return the OTP ID and encryption bundle to the client.
Backend: initialize
TurnkeyProvider:
Client: helper setup
Client: encrypt (async event handler)
otpId and encryptedOtpBundle to the backend. The plaintext code and private key stay on the client.
Backend: verify, then resolve the user’s sub-organization. Return the verification token and sub-organization ID to the client.
Backend: verify and resolve
signWithApiKey is the Wallet Kit hook method obtained above; it is not the function of the same name exported by @turnkey/sdk-server.
Client: sign (async event handler)
verificationToken, publicKey, and clientSignature to the backend to complete login for the resolved sub-organization.
Backend: log in and return the session to the client.
Backend: log in
Client: store session (async event handler)
otp-auth/with-backend example shows the complete client and backend split, including key creation and new-user signup. Despite its name, this example uses the replacement flow.
Credential handling changes
Allow legacy OTP Auth credentials to expire, or revoke them separately if your application requires an immediate cutoff. Credential checks belong in a policy’s
consensus expression, not its condition. A check such as credentials.any(c, c.type == 'CREDENTIAL_TYPE_OTP_AUTH_KEY_P256') will not match requests authenticated with the new login sessions. For those requests, use credentials.any(c, c.type == 'CREDENTIAL_TYPE_LOGIN'), preserving any other restrictions in your consensus expression.
Requests authenticated with an attested stamp using the verification token and its bound client key expose CREDENTIAL_TYPE_EMAIL or CREDENTIAL_TYPE_PHONE in policy evaluation, depending on the verification method. These are policy-expression values for attested stamps, not members of the public CredentialType enum or stored API-key types; see Attested-stamp credential types. They are distinct from CREDENTIAL_TYPE_LOGIN session requests. Update the relevant credential checks before you roll out, then test session expiration, invalidation, and at least one wallet operation with the new session. A successful login alone does not show that later operations have the permissions your application expects.
Migrating from legacy email authentication and recovery
This section applies to integrations using:ACTIVITY_TYPE_EMAIL_AUTH,ACTIVITY_TYPE_EMAIL_AUTH_V2, orACTIVITY_TYPE_EMAIL_AUTH_V3ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERYorACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY_V2, followed byACTIVITY_TYPE_RECOVER_USER
Choose the flow that matches your integration
Recovery includes authenticator enrollment. Completing OTP login alone does not replace the entire legacy
INIT_USER_EMAIL_RECOVERY → RECOVER_USER flow.
Before you deploy
- Upgrade the relevant SDK packages to the versions listed in Upgrade the SDKs.
- Confirm that
FEATURE_NAME_OTP_EMAIL_AUTHis enabled for the target sub-organization, as it is by default for new sub-organizations. The legacy email-authentication and email-recovery feature settings do not substitute for this setting. If it is disabled, enabling it requires the sub-organization’s own root quorum; the parent organization cannot enable it on the user’s behalf during recovery. See Organization features. - Update activity permissions to allow
ACTIVITY_TYPE_INIT_OTP_V3,ACTIVITY_TYPE_VERIFY_OTP_V2, andACTIVITY_TYPE_OTP_LOGIN_V2for the users submitting those requests. For recovery, also ensure the recovering user can submitACTIVITY_TYPE_CREATE_AUTHENTICATORS_V2with the recovery session. Preserve existing user, organization, and operation restrictions; see Update policies before deploying. - Review credential-type checks in policy
consensusexpressions. Legacy checks forCREDENTIAL_TYPE_EMAIL_AUTH_KEY_P256orCREDENTIAL_TYPE_RECOVER_USER_KEY_P256do not match requests stamped with the newCREDENTIAL_TYPE_LOGINsessions. When updating these checks, retain the recovery flow’s restrictions on permitted operations and users; matching a login session alone does not restrict it to recovery. - Review your email templates and callback UI. The replacement sends an OTP to verify, rather than a recovery or authentication credential bundle to import.
- Review existing MFA policy conditions as well as which user stamps each request. Conditions that name legacy activity kinds or versions must be updated for the replacement flow.
Preserve existing MFA enforcement
Update MFA conditions that match legacy activities. For example,activity.kind == 'EMAIL_AUTH' does not match OTP_LOGIN. The legacy recovery initialization kind is INIT_USER_EMAIL_RECOVERY; there is no RECOVER_USER activity kind. Review conditions using exact activity.type values too. Recovery now includes separate login and authenticator-enrollment activities, so review the intended MFA requirements for both rather than substituting a single activity name.
For Core and Wallet Kit versions using attested login, include STAMP_LOGIN in the relevant login conditions; a condition matching only OTP_LOGIN will miss that path. Preserve existing user and operation restrictions, and keep legacy activities covered during a staged rollout.
Before implementing login, decide which user will stamp the request. MFA policies apply to the user whose credential stamps the request.
A login stamped with a parent-organization API key evaluates that parent user’s MFA policies. It does not evaluate the target sub-organization user’s MFA policies merely because the login is for that user.
The clientSignature parameter and the request’s authentication stamp serve different purposes. Including clientSignature in a backend-stamped login does not make the request end-user-stamped. If authentication or recovery relies on the end user’s MFA policies, have the client submit login with an attested stamp using the verification token and its bound client key. See Obtaining the attested stamp.
Handle any required authentication challenges before using the returned session. The resulting session may also encounter MFA requirements when enrolling an authenticator. See MFA evaluation for details.
Replace email authentication
The following sequence assumes initialization and verification use a parent-organization backend.- Initialize email OTP. Replace
emailAuthwithinitOtp, submittingACTIVITY_TYPE_INIT_OTP_V3under the parent organization. SetotpTypetoOTP_TYPE_EMAIL, provide the user’s email ascontact, and include the required top-levelappName. Return bothotpIdandotpEncryptionTargetBundleto the client. - Encrypt the OTP on the client. Generate or select a client P-256 key pair. Use
encryptOtpCodeToBundleto encrypt the OTP and public key with the returned target bundle. Keep the private key and plaintext OTP on the client. - Verify the encrypted submission. Submit
ACTIVITY_TYPE_VERIFY_OTP_V2withotpIdandencryptedOtpBundle, using the same organization ID as initialization. Retain the returnedverificationTokenwith the client key used for this attempt. - Resolve the existing account and complete login. Identify the user’s sub-organization after verification. Submit
ACTIVITY_TYPE_OTP_LOGIN_V2to that sub-organization with the verification token, session public key, and required client signature, using the request stamp chosen in Preserve existing MFA enforcement. - Use the returned session. Store the session using your SDK’s session handling. Remove the legacy credential-bundle decryption and import step. The signing private key remains on the client.
Replace email recovery
Before enabling recovery, create a short-lived session profile in the parent organization with scope limited to authenticator enrollment. For example, submit Create session profile with these parameters and retain the returnedsessionProfileId:
OTP_LOGIN_V2, clientSignature does not cover sessionProfileId, so the party submitting login can omit it or request another profile. Login also does not consume the verification token, which remains usable for attested requests until it expires. Token expiration does not revoke sessions or authenticators created beforehand. Validate the intended MFA requirements with requests that omit or change sessionProfileId, and with direct attested requests outside recovery.
For each recovery attempt, follow the initialization, encryption, and verification steps above, then complete login directly into the recovery session:
- Resolve the existing account and obtain a recovery session. Submit
ACTIVITY_TYPE_OTP_LOGIN_V2to the user’s sub-organization with the verification token, session public key, required client signature, and recoverysessionProfileId. Use the request stamp chosen in Preserve existing MFA enforcement and complete any MFA challenges. This login creates the recovery session. OmittingsessionProfileIdcreates a default read/write session with no scope restrictions. - Enroll a replacement passkey. Complete WebAuthn registration on the client, then submit
ACTIVITY_TYPE_CREATE_AUTHENTICATORS_V2for the recovering user, stamped with the recovery session. This replaces the legacyrecoverUserstep. - Sign in with the new passkey. Confirm that the new authenticator works before considering recovery complete. Handle removal of lost or compromised authenticators according to your application’s recovery policy.
recoverUser with createAuthenticators and wrap the registration result in the authenticators array:
Before: legacy recovery credential
After: recovery session
legacyRecoveryClient uses the imported legacy recovery credential, and recoverySessionClient uses the newly issued recovery session and its client-held signing key. Set subOrgId and userId to the existing account being recovered. The authenticator object contains authenticatorName, challenge, and attestation from the new WebAuthn registration; see Create authenticators for the request fields.
The new passkey is an ordinary authenticator and does not inherit the recovery session’s scope or expiration. Subsequent requests authenticated with it remain subject to the user’s applicable authorization and MFA policies.
See Session Profiles for session restrictions and MFA Recovery for the recommended additional-factor recovery pattern.
Review session invalidation and legacy credentials
The meaning ofinvalidateExisting changes when you replace email authentication with OTP login:
Review this behavior before carrying
invalidateExisting: true into the new login flow. Account for outstanding legacy credentials separately: allow them to expire or revoke them according to your application’s policy. Creating a new session does not itself revoke credentials issued by the old flow.
Verify the migration
Before removing the legacy flow, confirm that:- An existing user can complete email authentication or recovery.
- Recovery enrolls a working replacement authenticator in the existing account.
- MFA conditions cover the replacement activities and still trigger the intended challenges. During rollout, verify the legacy flow remains covered too.
- Any recovery-session restrictions prevent operations outside the intended scope.
- If email alone should grant only recovery access, MFA requires an additional factor for normal login and sensitive direct attested requests, including for root users with a quorum threshold of one. Test omitted or changed
sessionProfileIdvalues; ordinary authorization policies and a recovery session profile do not enforce this restriction for those users. - The replacement passkey is subject to the intended authorization and MFA policies when used for normal login and subsequent operations.
- Session expiration and invalidation behave as expected, including the effect on sessions created through other login methods.
- Subsequent wallet operations work with the normal login session; recovery sessions remain limited to authenticator enrollment.
- Legacy method calls and credential-bundle handling have been removed.
Upgrade the SDKs
The encrypted flow was introduced in the following releases. Use these versions or later, and upgrade related Turnkey packages together so their generated activity types and request shapes remain compatible.Wallet Kit and Core login activities
The versions above are the minimum for encrypted OTP support. Attested login, session-profile parameters on auth helpers, and built-in MFA handling were added later:@turnkey/core 2.7.0, @turnkey/react-wallet-kit 2.4.0, and @turnkey/react-native-wallet-kit 2.3.0.
Starting with those releases, loginWithOtp submits STAMP_LOGIN with an end-user attested stamp. Earlier encrypted-OTP releases use proxyOtpLoginV2, whose login is stamped by the Auth Proxy. Low-level integrations calling otpLogin directly submit OTP_LOGIN_V2. Review permissions and MFA conditions against the activity and stamping user used by your integration.
Choose the section below that matches your integration.
- Wallet Kit and Core
- Legacy React SDK
- Server and low-level clients
In Pass the bundle to
@turnkey/react-wallet-kit, @turnkey/react-native-wallet-kit, and @turnkey/core, initOtp now returns an object instead of a plain OTP ID. Keep both values:verifyOtp. The SDK encrypts the OTP code and public key before calling Turnkey:verifyOtp no longer accepts contact or otpType, and it no longer returns subOrganizationId. Use completeOtp to perform verification plus account lookup and login/signup. For Auth Proxy integrations that perform account lookup separately, call httpClient.proxyGetAccount after verification. For backend-driven integrations, use the getSubOrgIds lookup shown in Migrating from legacy OTP Auth.loginWithOtp and signUpWithOtp no longer accept a separate publicKey. They reuse the key bound during verifyOtp. signUpWithOtp generates the required signup clientSignature automatically. In the attested-login releases, loginWithOtp instead submits STAMP_LOGIN with an attested stamp; earlier encrypted-OTP releases generate a login clientSignature for the Auth Proxy flow.Update policies before deploying
If a policy checks exact activity versions, update it before the new SDKs begin submitting activities. At minimum, review policies that name:ACTIVITY_TYPE_INIT_OTPorACTIVITY_TYPE_INIT_OTP_V2ACTIVITY_TYPE_VERIFY_OTPACTIVITY_TYPE_OTP_LOGINACTIVITY_TYPE_INIT_OTP_AUTH,ACTIVITY_TYPE_INIT_OTP_AUTH_V2, orACTIVITY_TYPE_INIT_OTP_AUTH_V3ACTIVITY_TYPE_OTP_AUTHACTIVITY_TYPE_EMAIL_AUTH,ACTIVITY_TYPE_EMAIL_AUTH_V2, orACTIVITY_TYPE_EMAIL_AUTH_V3ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY,ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY_V2, orACTIVITY_TYPE_RECOVER_USERACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7
Policies for legacy OTP Auth
The replacement flow changes activity kinds as well as versions: a condition permittingINIT_OTP_AUTH or OTP_AUTH will not match INIT_OTP, VERIFY_OTP, or OTP_LOGIN. For the parent-initiated flow described above, create the following policies in the parent organization for the non-root backend API user performing existing-user login. Parent-organization policies are evaluated even though OTP_LOGIN_V2 targets the user’s sub-organization. A root API user satisfying a root quorum threshold of one does not need these additional allow policies. If you initiate the flow with sub-organization credentials instead, configure the policies in that sub-organization. Replace the user ID and preserve any additional restrictions from your existing policies.
During rollout, allow both flows until every client uses the replacement flow:
Policies for other OTP flows
During a staged rollout, exact-type policies must allow both legacy and updated versions until all clients and servers use the new flow. This does not make the activity versions interchangeable within an OTP attempt. While both versions are in use,activity.kind covers every version of each activity:
CREATE_SUB_ORGANIZATION covers every version of sub-organization creation,
not only OTP signup. Preserve any existing restrictions on who may create a
sub-organization and which parameters they may submit.VERIFY_OTP. After all clients have migrated, replace it with exact activity types.
Rollout checklist
- Update policies before deploying updated SDKs.
- Upgrade the frontend and backend Turnkey packages together.
- Store
otpId,otpEncryptionTargetBundle, and the client key as state for one OTP attempt. - On resend, replace all attempt state rather than reusing an old bundle or key.
- Test email and SMS separately if your application supports both.
- Test both an existing-user login and a new-user signup.
- Confirm that OTP codes are sent to your backend only as
encryptedOtpBundle. - For legacy OTP Auth integrations, confirm there are no remaining
initOtpAuthorotpAuthcallers, and that clients use sessions instead of decryptingcredentialBundleresponses. - Test session expiration, invalidation, and a wallet operation with the new session. Account for any legacy OTP Auth credentials that have not yet expired or been revoked.
- After all clients have migrated, remove legacy activity versions from exact-version policies, and replace kind-based OTP policies with exact types so the plaintext flows are no longer permitted.