> ## Documentation Index
> Fetch the complete documentation index at: https://turnkey-0e7c1f5b-andrew-otp-migration-steps.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate to encrypted OTP flows

> How to migrate to Turnkey's new security-upgraded OTP authentication flows.

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 or SDK methods that send `otpCode` in plaintext. New integrations should use the current flow documented in [Email auth & recovery](/features/authentication/email) and [SMS authentication](/features/authentication/sms).

<Warning>
  Migrate each OTP attempt as a unit. Do not start an attempt with a legacy
  `INIT_OTP` 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.
</Warning>

## Migration checklist

Complete these steps in order. Do not deploy an updated client until its
policies allow the updated activity types.

1. Update exact-version policies. See [Update policies before deploying](#update-policies-before-deploying).
2. Upgrade the frontend and backend Turnkey packages together.
3. Update the OTP flow. Keep each OTP attempt's bundle, token, and client key together.
4. Send the OTP code to the backend only in `encryptedOtpBundle`.
5. Test email and SMS separately, if your application supports both.
6. Test an existing-user login and a new-user signup before deployment.
7. Deploy the updated client and backend together.
8. After every client has migrated, remove legacy activity types from exact-version policies.

## What changed

| Stage    | Legacy SDK activity                                                         | Current SDK activity                                                                 | Required change                                                                    |
| -------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| Initiate | `ACTIVITY_TYPE_INIT_OTP` or `ACTIVITY_TYPE_INIT_OTP_V2`                     | `ACTIVITY_TYPE_INIT_OTP_V3`                                                          | Keep the returned `otpEncryptionTargetBundle` with the `otpId` until verification. |
| Verify   | `ACTIVITY_TYPE_VERIFY_OTP`                                                  | `ACTIVITY_TYPE_VERIFY_OTP_V2`                                                        | Replace plaintext `otpCode` and `publicKey` with an `encryptedOtpBundle`.          |
| Log in   | `ACTIVITY_TYPE_OTP_LOGIN`                                                   | `ACTIVITY_TYPE_OTP_LOGIN_V2`                                                         | Include a `clientSignature` made by the key bound during verification.             |
| Sign up  | `ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7` with a legacy verification token | `ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7` with an enclave-issued verification token | Include a `clientSignature` made by the key bound during verification.             |

<Note>
  `ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7` accepts both legacy verification
  tokens and the enclave-issued tokens from `VERIFY_OTP_V2`. Migrate customers
  to the encrypted flow while continuing to use V7. `ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V8`
  accepts only enclave-issued tokens and is not required for this migration.
</Note>

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](/security/enclave-secure-channels#otp-login-flow) for the protocol diagram and security properties.

## 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.

| Package                            | Minimum version                                                                                    |
| ---------------------------------- | -------------------------------------------------------------------------------------------------- |
| `@turnkey/sdk-react`               | [`6.0.0`](https://github.com/tkhq/sdk/blob/main/packages/sdk-react/CHANGELOG.md#600)               |
| `@turnkey/sdk-browser`             | [`6.0.0`](https://github.com/tkhq/sdk/blob/main/packages/sdk-browser/CHANGELOG.md#600)             |
| `@turnkey/sdk-server`              | [`6.0.0`](https://github.com/tkhq/sdk/blob/main/packages/sdk-server/CHANGELOG.md#600)              |
| `@turnkey/sdk-react-native`        | [`1.5.21`](https://github.com/tkhq/sdk/blob/main/packages/sdk-react-native/CHANGELOG.md#1521)      |
| `@turnkey/react-wallet-kit`        | [`2.0.0`](https://github.com/tkhq/sdk/blob/main/packages/react-wallet-kit/CHANGELOG.md#200)        |
| `@turnkey/react-native-wallet-kit` | [`2.0.0`](https://github.com/tkhq/sdk/blob/main/packages/react-native-wallet-kit/CHANGELOG.md#200) |
| `@turnkey/core`                    | [`2.0.0`](https://github.com/tkhq/sdk/blob/main/packages/core/CHANGELOG.md#200)                    |
| `@turnkey/http`                    | [`4.0.0`](https://github.com/tkhq/sdk/blob/main/packages/http/CHANGELOG.md#400)                    |
| `@turnkey/sdk-types`               | [`1.0.0`](https://github.com/tkhq/sdk/blob/main/packages/sdk-types/CHANGELOG.md#100)               |
| `@turnkey/crypto`                  | [`2.9.0`](https://github.com/tkhq/sdk/blob/main/packages/crypto/CHANGELOG.md#290)                  |

Choose the section below that matches your integration.

<Tabs>
  <Tab title="Wallet Kit and Core">
    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:

    ```ts theme={null}
    // Before
    const otpId = await initOtp({
      otpType: OtpType.Email,
      contact: "user@example.com",
    });

    // After
    const { otpId, otpEncryptionTargetBundle } = await initOtp({
      otpType: OtpType.Email,
      contact: "user@example.com",
    });
    ```

    Pass the bundle to `verifyOtp`. The SDK encrypts the OTP code and public key before calling Turnkey:

    ```ts theme={null}
    const { verificationToken } = await verifyOtp({
      otpId,
      otpCode,
      otpEncryptionTargetBundle,
    });
    ```

    `verifyOtp` no longer accepts `contact` or `otpType`, and it no longer returns `subOrganizationId`. Use `completeOtp` to perform verification plus account lookup and login/signup, or call `proxyGetAccount` separately after verification.

    `loginWithOtp` and `signUpWithOtp` no longer accept a separate `publicKey`. They reuse the key bound during `verifyOtp` and generate the required client signature automatically.

    ```ts theme={null}
    await loginWithOtp({
      verificationToken,
      invalidateExisting: true,
    });
    ```
  </Tab>

  <Tab title="Legacy React SDK">
    `@turnkey/sdk-react` is the legacy React SDK. For new projects, use `@turnkey/react-wallet-kit`; see [Migrating from `@turnkey/sdk-react`](/solutions/embedded-wallets/integration-guide/react/migrating-sdk-react).

    If you are maintaining an `@turnkey/sdk-react` integration, the `OtpVerification` component now requires the encryption target bundle returned by `sendOtp`:

    ```ts theme={null}
    const { otpId, otpEncryptionTargetBundle } = await server.sendOtp({
      appName: "Example App",
      otpType: OtpType.Sms,
      contact: phoneInput,
      customSmsMessage: "Your OTP is {{.OtpCode}}",
      userIdentifier: publicKey,
    });
    ```

    ```tsx theme={null}
    <OtpVerification
      type={type}
      contact={contact}
      otpId={otpId}
      otpEncryptionTargetBundle={otpEncryptionTargetBundle}
      onValidateSuccess={handleValidateSuccess}
      onResendCode={handleResendCode}
    />
    ```

    The component handles creation of the encrypted OTP bundle. Make sure a resend replaces both `otpId` and `otpEncryptionTargetBundle`; never combine values from separate attempts.
  </Tab>

  <Tab title="Server and low-level clients">
    Legacy `@turnkey/sdk-react-native` integrations also follow this section. That package re-exports the low-level `@turnkey/http` client and does not automate OTP bundle creation or client signatures. For new React Native integrations, use [React Native Wallet Kit](/solutions/embedded-wallets/integration-guide/react-native/overview).

    `sendOtp` and `initOtp` now return an encryption target bundle. Persist it only for the lifetime of the OTP attempt and return it to the client that will verify the code.

    ```ts theme={null}
    const { otpId, otpEncryptionTargetBundle } = await server.sendOtp({
      // ...
    });
    ```

    Replace the plaintext `otpCode` field with a bundle created on the client:

    ```ts theme={null}
    import { encryptOtpCodeToBundle } from "@turnkey/crypto";

    const encryptedOtpBundle = await encryptOtpCodeToBundle(
      otpCode,
      otpEncryptionTargetBundle,
      publicKey,
    );

    const { verificationToken } = await server.verifyOtp({
      otpId,
      encryptedOtpBundle,
      sessionLengthSeconds,
    });
    ```

    The private key matching `publicKey` must stay on the client. Use it to sign the login or signup message, then send the resulting `clientSignature` to the backend:

    ```ts theme={null}
    await server.otpLogin({
      suborgID,
      verificationToken,
      publicKey,
      clientSignature,
      sessionLengthSeconds,
    });
    ```

    The [`otp-auth/with-backend`](https://github.com/tkhq/sdk/tree/main/examples/authentication/otp-auth/with-backend) example shows the full split between client-side encryption and signing and server-side Turnkey API calls.
  </Tab>
</Tabs>

## 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_CREATE_SUB_ORGANIZATION_V7`

During the rollout, exact-type policies must allow the legacy activity types and
the updated `INIT_OTP_V3`, `VERIFY_OTP_V2`, and `OTP_LOGIN_V2` activity types.
Keep `CREATE_SUB_ORGANIZATION_V7` allowed because it accepts both token types.
This does not make activity versions interchangeable within one OTP attempt.

Prefer `activity.kind` when the same policy must cover every version of a
specific activity:

```json theme={null}
{
  "effect": "EFFECT_ALLOW",
  "consensus": "approvers.any(user, user.id == '<YOUR_API_USER_ID>')",
  "condition": "activity.kind in ['INIT_OTP', 'VERIFY_OTP', 'OTP_LOGIN', 'CREATE_SUB_ORGANIZATION']"
}
```

<Note>
  `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.
</Note>

For the complete set of SDK breaking changes shipped with this migration, see [tkhq/sdk#1250](https://github.com/tkhq/sdk/pull/1250).
