Skip to main content
Turnkey’s current email and SMS OTP flows encrypt each OTP attempt before it leaves the client. The client encrypts the code and a session public key to a Turnkey enclave target key. After successful verification, the resulting verification token is bound to the client public key, so an application server cannot read the OTP or use the token to log in or sign up without the matching client private key. This guide is for integrations that use legacy OTP activities, SDK methods that send 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.
Migrate each OTP attempt as a unit. Do not start an attempt with a legacy INIT_OTP, INIT_OTP_V2, or INIT_OTP_AUTH* activity and finish it with the updated VERIFY_OTP_V2 or OTP_LOGIN_V2 activities. The encryption bundle, verification token, and client key belong to the same attempt and are not interchangeable with the legacy flow.

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.
The updated verification flow is:
  1. INIT_OTP_V3 returns otpId and otpEncryptionTargetBundle.
  2. The client generates or selects a P-256 key pair.
  3. The client uses encryptOtpCodeToBundle to encrypt the OTP code and public key with otpEncryptionTargetBundle.
  4. VERIFY_OTP_V2 accepts the encrypted bundle and returns a verification token bound to the client public key.
  5. The client signs the login or signup payload with the same private key.
  6. OTP_LOGIN_V2 or the sub-organization creation flow validates the verification token and client signature.
See OTP login flow for the protocol diagram and security properties.

Migrating from legacy OTP Auth

This section applies to any integration that calls initOtpAuth 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.
INIT_OTP_AUTH_V3 is part of the legacy family; it is not the same activity as INIT_OTP_V3. Upgrading an SDK without replacing calls to initOtpAuth and otpAuth does not migrate this flow.

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.
  1. 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.
  2. Backend: replace initOtpAuth with initOtp. Submit ACTIVITY_TYPE_INIT_OTP_V3 under the parent organization with the contact, otpType (OTP_TYPE_EMAIL or OTP_TYPE_SMS), and required appName. Return otpId and otpEncryptionTargetBundle to the client.
    • If you use INIT_OTP_AUTH or INIT_OTP_AUTH_V2, move appName out of emailCustomization into the top-level appName field. INIT_OTP_V3 requires it, and its emailCustomization no longer accepts appName. See Breaking change: appName required.
    • If you rely on the organization’s default email template, have Turnkey configure it for ACTIVITY_TYPE_INIT_OTP. Default templates registered for ACTIVITY_TYPE_INIT_OTP_AUTH are not selected automatically by the new flow. An existing template passed explicitly through emailCustomization.templateId can 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.
  3. 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 resulting encryptedOtpBundle to your backend. The private key stays on the client.
  4. Backend: verify with verifyOtp. Submit ACTIVITY_TYPE_VERIFY_OTP_V2 with otpId and encryptedOtpBundle, using the same organization ID as initialization. Return the verificationToken to the client; it is bound to the client key used in the encrypted bundle.
  5. Backend: resolve the user’s sub-organization. After verification, look up the sub-organization for the contact, for example with getSubOrgIds filtered by EMAIL or PHONE_NUMBER. In the legacy flow you resolved the sub-organization before initialization, because INIT_OTP_AUTH is submitted to it. Move that lookup here, after the code is verified.
  6. Client: sign the login message. Build the message with getClientSignatureMessageForLogin and 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.
  7. Backend: replace otpAuth with otpLogin. Submit ACTIVITY_TYPE_OTP_LOGIN_V2 to the user’s sub-organization with verificationToken, the session publicKey, and clientSignature.
  8. Client: store and use the session. Use the returned session with the private key matching the session publicKey you passed to OTP_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)
The following excerpts show the replacement flow in execution order, across separate client and backend contexts. They assume 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
Client: create a key and encrypt the code. Import the helpers below and obtain the key and session methods inside a React component or custom hook under TurnkeyProvider:
Client: helper setup
Client: encrypt (async event handler)
Send 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
Client: sign with the same key used for verification. Here, 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)
Send 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 the session using Wallet Kit.
Client: store session (async event handler)
The 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, or ACTIVITY_TYPE_EMAIL_AUTH_V3
  • ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY or ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY_V2, followed by ACTIVITY_TYPE_RECOVER_USER
These legacy flows deliver an encrypted credential bundle through email. The replacement uses encrypted email OTP verification and a session bound to a client-held key. Email remains available for authentication and recovery through the encrypted OTP flow. This migration changes the activity sequence, email experience, credential handling, and permissions. Upgrading the SDK alone does not replace existing calls to the legacy methods. The numbered sequences and recovery examples below use low-level SDK clients. Core and Wallet Kit login helpers use different activities depending on the SDK version; see Wallet Kit and Core login activities.

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_AUTH is 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, and ACTIVITY_TYPE_OTP_LOGIN_V2 for the users submitting those requests. For recovery, also ensure the recovering user can submit ACTIVITY_TYPE_CREATE_AUTHENTICATORS_V2 with the recovery session. Preserve existing user, organization, and operation restrictions; see Update policies before deploying.
  • Review credential-type checks in policy consensus expressions. Legacy checks for CREDENTIAL_TYPE_EMAIL_AUTH_KEY_P256 or CREDENTIAL_TYPE_RECOVER_USER_KEY_P256 do not match requests stamped with the new CREDENTIAL_TYPE_LOGIN sessions. 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.
Adding MFA is not a prerequisite for this migration. Existing applicable MFA policies must still be satisfied. For additional recovery protections, see MFA Recovery.

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.
  1. Initialize email OTP. Replace emailAuth with initOtp, submitting ACTIVITY_TYPE_INIT_OTP_V3 under the parent organization. Set otpType to OTP_TYPE_EMAIL, provide the user’s email as contact, and include the required top-level appName. Return both otpId and otpEncryptionTargetBundle to the client.
  2. Encrypt the OTP on the client. Generate or select a client P-256 key pair. Use encryptOtpCodeToBundle to encrypt the OTP and public key with the returned target bundle. Keep the private key and plaintext OTP on the client.
  3. Verify the encrypted submission. Submit ACTIVITY_TYPE_VERIFY_OTP_V2 with otpId and encryptedOtpBundle, using the same organization ID as initialization. Retain the returned verificationToken with the client key used for this attempt.
  4. Resolve the existing account and complete login. Identify the user’s sub-organization after verification. Submit ACTIVITY_TYPE_OTP_LOGIN_V2 to that sub-organization with the verification token, session public key, and required client signature, using the request stamp chosen in Preserve existing MFA enforcement.
  5. 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.
If no existing account is found, handle that result explicitly. Account recovery must not silently create a new account. For encrypted bundle creation and client-signature examples, see Migrating from legacy OTP Auth.

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 returned sessionProfileId:
A session profile limits the session’s permissions; it does not automatically require MFA. Email OTP is an authentication method, not a separate recovery-only credential. For a root user who satisfies a quorum threshold of one, the verification token and its bound client key grant full account access unless applicable MFA policies require an additional factor. Ordinary authorization policies cannot restrict actions that satisfy the root quorum. If email alone should grant only recovery access, configure MFA to protect normal login and sensitive direct attested requests as well as the recovery path; a policy matching only the recovery profile is insufficient. The profile restricts the issued session, but it does not make the email credential recovery-only. For low-level 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:
  1. Resolve the existing account and obtain a recovery session. Submit ACTIVITY_TYPE_OTP_LOGIN_V2 to the user’s sub-organization with the verification token, session public key, required client signature, and recovery sessionProfileId. Use the request stamp chosen in Preserve existing MFA enforcement and complete any MFA challenges. This login creates the recovery session. Omitting sessionProfileId creates a default read/write session with no scope restrictions.
  2. Enroll a replacement passkey. Complete WebAuthn registration on the client, then submit ACTIVITY_TYPE_CREATE_AUTHENTICATORS_V2 for the recovering user, stamped with the recovery session. This replaces the legacy recoverUser step.
  3. 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.
At the SDK level, replace recoverUser with createAuthenticators and wrap the registration result in the authenticators array:
Before: legacy recovery credential
After: recovery session
These excerpts assume initialized low-level SDK clients: 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 of invalidateExisting 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 sessionProfileId values; 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.
In @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:
Pass the bundle to 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_OTP or ACTIVITY_TYPE_INIT_OTP_V2
  • ACTIVITY_TYPE_VERIFY_OTP
  • ACTIVITY_TYPE_OTP_LOGIN
  • ACTIVITY_TYPE_INIT_OTP_AUTH, ACTIVITY_TYPE_INIT_OTP_AUTH_V2, or ACTIVITY_TYPE_INIT_OTP_AUTH_V3
  • ACTIVITY_TYPE_OTP_AUTH
  • ACTIVITY_TYPE_EMAIL_AUTH, ACTIVITY_TYPE_EMAIL_AUTH_V2, or ACTIVITY_TYPE_EMAIL_AUTH_V3
  • ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY, ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY_V2, or ACTIVITY_TYPE_RECOVER_USER
  • ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7

Policies for legacy OTP Auth

The replacement flow changes activity kinds as well as versions: a condition permitting INIT_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:
After rollout, permit only the replacement activity versions:
These examples do not grant signup permission. Add sub-organization creation permissions only if your integration also supports signup.

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.
A kind-based policy also continues to permit the legacy plaintext activities, such as 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 initOtpAuth or otpAuth callers, and that clients use sessions instead of decrypting credentialBundle responses.
  • 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.
For the complete set of SDK breaking changes shipped with this migration, see tkhq/sdk#1250.