> ## Documentation Index
> Fetch the complete documentation index at: https://docs.turnkey.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Email auth & recovery

> Email Authentication enables users to authenticate and recover their Turnkey accounts using email-based verification. There are two methods of email authentication:

#### One-time password

* Uses a 6-9 digit or bech32 alphanumeric one-time password sent via email
* Simple, and familiar user experience

**One-Time Password Sandbox Environment**

To test OTP codes in our sandbox environment you can use the following:

* `alphanumeric` must be set to `false`
* `otpLength` must be set to `6`
* Email: [user@example.com](mailto:user@example.com)
* OTP Code: `000000`

#### Credential bundle

* Sends an encrypted API key credential directly via email
* Alternative method for specific use cases
* More secure, but requires copying the full credential to the client

Both methods provide users with an expiring API key for authentication or account recovery.

## Core mechanism

Email Authentication is built with expiring API keys as the foundation. The two delivery mechanisms are:

### OTP-based method

The authentication process happens in two steps:

<Steps>
  <Step>
    A 6-9 digit or alphanumeric OTP code is sent to the user's verified email address
  </Step>

  <Step>
    Upon verification of the correct code, an API key credential is generated and encrypted for the client
  </Step>
</Steps>

### Credential bundle method

<Note> This method is only supported by **legacy iframe-based flows** and is not available in the current Turnkey SDKs. </Note>

The API key credential is encrypted and delivered directly through email to the user. Once the credential is live on the client side (within the context of an iframe), it is readily available to stamp (authenticate) requests. See the [enclave to end-user secure channel](/security/enclave-secure-channels) for more info on how we achieve secure delivery.

This flow remains available for existing legacy integrations but is not recommended for new implementations.
As an alternative, we recommend using the email OTP flow, which is fully supported by the current SDKs. An example that sends a magic link using [magicLinkTemplate](https://docs.turnkey.com/authentication/email#email-auth-and-recovery:~:text=height%20of%20124px-,magicLinkTemplate,-%3A%20a%20template%20for) can be found [here](https://github.com/tkhq/sdk/tree/main/examples/magic-link-auth).

### Email recovery

In Turnkey, email recovery **does not refer to a separate, recovery-only email address** (as commonly used in consumer products, where a recovery email can reset access but cannot itself be used to sign in).
Instead, it means **adding email as an authentication method for a user**. Once added, that user can authenticate using email by default, alongside any other configured authenticators (such as passkeys, social logins or external wallets).

The legacy iframe-based flow using `INIT_USER_EMAIL_RECOVERY` and `RECOVER_USER` still exists for backward compatibility with older integrations. These flows ultimately attach new authentication material (for example, a passkey) to the user. They are being deprecated and **should not be used for new integrations**.

## Prerequisites

Make sure you have set up your primary Turnkey organization with at least one API user that can programmatically initiate email auth and create suborganizations. Check out our [Quickstart guide](/getting-started/quickstart) if you need help getting started. To allow an API user to initiate email auth, you'll need the following policy in your main organization:

```json theme={"system"}
{
  "effect": "EFFECT_ALLOW",
  "consensus": "approvers.any(user, user.id == '<YOUR_API_USER_ID>')",
  "condition": "(activity.resource == 'AUTH' && activity.action == 'CREATE') || (activity.resource == 'ORGANIZATION' && activity.action == 'CREATE')"
}
```

For OTP Auth signup and login flows you will need a user with the following policy

```json theme={"system"}
{
  "effect": "EFFECT_ALLOW",
  "consensus": "approvers.any(user, user.id == '<YOUR_API_USER_ID>')",
  "condition": "(activity.resource == 'AUTH' && activity.action == 'CREATE') || (activity.resource == 'OTP' && activity.action == 'CREATE') || (activity.resource == 'OTP' && activity.action == 'VERIFY') || (activity.resource == 'ORGANIZATION' && activity.action == 'CREATE')"
}
```

<Note>Avoid using an API key that is also present in the sub-organization that you're targeting within the email activities. Turnkey identifies the user from the request signature and in case of an identical API key it will always prioritize the sub-organization user matching. As a result, it will try to evaluate the sub-organization policies instead of the parent ones.</Note>

## User experience

### OTP-based authentication flow

The flow begins with a new activity of type `ACTIVITY_TYPE_INIT_OTP_V3` using the parent organization id with these parameters:

* `otpType`: specify `"OTP_TYPE_EMAIL"`
* `contact`: user's email address
* `emailCustomization`: optional parameters for customizing emails
* `userIdentifier`: optional parameter for rate limiting SMS OTP requests per user. We recommend generating this server-side based on the user's IP address or public key. See the [OTP Rate Limits](#otp-rate-limits) section below for more details.
* `alphanumeric`: optional parameter for making this code bech32 alphanumeric or not. default: true
* `otpLength`: optional parameter for selecting the length of the OTP. default: 9
* `expirationSeconds`: optional validity window (defaults to 5 minutes)
* `sendFromEmailAddress` : optional custom email address from which to send the OTP email
* `sendFromEmailSenderName` : optional custom sender name for use with sendFromEmailAddress; if left empty, will default to ‘Notifications’
* `replyToEmailAddress` : optional custom email address to use as reply-to

The response to `ACTIVITY_TYPE_INIT_OTP_V3` includes an `otpEncryptionTargetBundle` which is used in OTP verification. After receiving the OTP, users complete OTP verification with `ACTIVITY_TYPE_VERIFY_OTP_V2` using the parent organization id which returns a verificationToken JWT:

* `otpId`: ID from the init activity
* `encryptedOtpBundle`: bundle generated using the otpEncryptionTargetBundle received during `ACTIVITY_TYPE_INIT_OTP_V3` and contains the 6-9 digit or alphanumeric code received via email, and the public key of a client-side generated keypair.
* `expirationSeconds`: optional validity window (defaults to 1 hour)

After receiving the verification token, users complete OTP authentication flow with `ACTIVITY_TYPE_OTP_LOGIN_V2` using the sub-organization ID associated with the contact from the first step:

* `publicKey`: public key to add to organization data associated with the signing key in IndexedDB or SecureStorage.
* `verificationToken`: JWT returned from successful `VERIFY_OTP` activity
* `clientSignature`: This proves authorization for the verification token being used, and is generated using the keypair whose public key was provided in the `encryptedOtpBundle` during verification.
* `expirationSeconds`: optional validity window (defaults to 15 minutes)
* `invalidateExisting`: optional boolean to invalidate previous login sessions

<Note>If you are migrating from a legacy OTP flow to the new updated flow, check out the [OTP Migration Guide](/authentication/otp-migration-guide) for details on required changes.</Note>

<Frame>
  <img src="https://mintcdn.com/turnkey-0e7c1f5b/83HCB8zBjOP3rX5S/images/authentication/img/auth_otp_email.png?fit=max&auto=format&n=83HCB8zBjOP3rX5S&q=85&s=0f4e3ee86d0ec8c07e0912598ca887f7" alt="auth otp email" width="733" height="474" data-path="images/authentication/img/auth_otp_email.png" />
</Frame>

### OTP rate limits

In order to safeguard users, Turnkey enforces rate limits for OTP auth activities. If a `userIdentifier` parameter is provided, the following limits are enforced:

* 3 requests per 3 minutes per unique `userIdentifier`
* 3 retries max per code, after which point that code will be locked
* 3 active codes per user, each with a 5 minute TTL

### Credential bundle authentication flow

This alternative method uses `ACTIVITY_TYPE_EMAIL_AUTH` with these parameters:

* `email`: user's email address (must match their registered email)
* `targetPublicKey`: public key for credential encryption
* `apiKeyName`: optional name (defaults to `Email Auth - <Timestamp>`)
* `expirationSeconds`: optional validity window (defaults to 15 minutes)
* `emailCustomization`: optional parameters for customizing emails
* `invalidateExisting`: optional boolean to invalidate previous Email Auth API keys

<Frame>
  <img src="https://mintcdn.com/turnkey-0e7c1f5b/83HCB8zBjOP3rX5S/images/authentication/img/auth_email.png?fit=max&auto=format&n=83HCB8zBjOP3rX5S&q=85&s=67e0e9c7a43a313eae5188d1e7763460" alt="auth email" width="1178" height="982" data-path="images/authentication/img/auth_email.png" />
</Frame>

## Email customization

We offer customization for the following:

* `appName`: the name of the application. This will be used in the email's subject, e.g. `Sign in to ${appName}`
* `logoUrl`: a link to a PNG with a max width of 340px and max height of 124px
* `magicLinkTemplate`: a template for the URL to be used in the magic link button, e.g. `https://dapp.xyz/%s`. The auth bundle will be interpolated into the `%s`

```js theme={"system"}
// Sign and submits the EMAIL_AUTH activity
const response = await client.emailAuth({
  type: "ACTIVITY_TYPE_EMAIL_AUTH",
  timestampMs: String(Date.now()),
  organizationId: <sub-organization-id>,
  parameters: {
    email: <user-email>,
    targetPublicKey: <iframe-public-key>,
    apiKeyName: <optional-api-key-name>,
    expirationSeconds: <optional-api-key-expiration-in-seconds>,
    emailCustomization: {
      appName: <optional-your-app-name>,
      logoUrl: <optional-your-logo-png>,
      magicLinkTemplate: <optional-magic-link>
    }
  },
});
```

### Breaking change: `appName` required

<Warning>
  **Effective December 16, 2025**

  Beginning December 16, 2025, all email-based OTP and recovery activities will require the `appName` parameter. Existing SDK versions will continue working, but upgrading without setting an `appName` will break email-based flows.

  <CardGroup cols={2}>
    <Card title="Auth Proxy Users" icon="server">
      **Affected:** Anyone using Auth Proxy without `appName` set

      **Action:** Set `appName` under [email configuration](https://app.turnkey.com/dashboard/walletKit)
    </Card>

    <Card title="Server SDK Users" icon="code">
      **Affected:** Users calling `init_otp_auth`, `init_otp`, `email_recovery`, or `email_auth` without `appName`

      **Action:**<br />
      • For `email_recovery` or `email_auth`: include `appName` in your request<br />
      • For `init_otp` or `init_otp_auth`: update your API call to match the new input structure when upgrading to the latest server-SDK
    </Card>
  </CardGroup>

  <Accordion title="Server SDK code examples">
    <Card title="emailAuth & emailRecovery">
      Both methods use the same parameter structure. Here's an example with `emailAuth`:

      ```js theme={"system"}
      await client.emailAuth({
        parameters: {
          email: "user@example.com", 
          targetPublicKey: "<client-public-key>",
          emailCustomization: {
            appName: "Your App Name" // this is now required
          }
        }
      });
      ```
    </Card>

    <Card title="initOtp & initOtpAuth">
      Both methods use the same parameter structure. Here's an example with `initOtp`:

      ```js theme={"system"}
      await client.initOtp({
        parameters: {
          otpType: "OTP_TYPE_EMAIL", 
          contact: "user@example.com", 
          appName: "Your App Name",  // this is now required
          emailCustomization: {
            // other optional customization
          }
        }
      });
      ```
    </Card>
  </Accordion>

  <Card title="Policy activity type updates">
    If you manage email flows with policies, update to the new activity types:

    **EMAIL\_AUTH**<br />
    Old: `ACTIVITY_TYPE_EMAIL_AUTH`, `ACTIVITY_TYPE_EMAIL_AUTH_V2`<br />
    New: `ACTIVITY_TYPE_EMAIL_AUTH_V3`

    **INIT\_OTP\_AUTH**<br />
    Old: `ACTIVITY_TYPE_INIT_OTP_AUTH`, `ACTIVITY_TYPE_INIT_OTP_AUTH_V2`<br />
    New: `ACTIVITY_TYPE_INIT_OTP_AUTH_V3`

    **INIT\_OTP** (contact verification & signup)<br />
    Old: `ACTIVITY_TYPE_INIT_OTP`, `ACTIVITY_TYPE_INIT_OTP_V2`<br />
    New: `ACTIVITY_TYPE_INIT_OTP_V3`

    **VERIFY\_OTP**<br />
    Old: `ACTIVITY_TYPE_VERIFY_OTP`<br />
    New: `ACTIVITY_TYPE_VERIFY_OTP_V2`

    **OTP\_LOGIN**<br />
    Old: `ACTIVITY_TYPE_OTP_LOGIN`<br />
    New: `ACTIVITY_TYPE_OTP_LOGIN_V2`

    **INIT\_USER\_EMAIL\_RECOVERY**<br />
    Old: `ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY`<br />
    New: `ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY_V2`
  </Card>
</Warning>

### Email templates

We also support custom HTML email templates for [Enterprise](https://www.turnkey.com/pricing) clients on the **Scale** tier. This allows you to inject arbitrary data from a JSON string containing key-value pairs. In this case, the `emailCustomization` variable may look like:

```js theme={"system"}
...
emailCustomization: {
  templateId: <HTML-template-stored-in-turnkey>,
  templateVariables: "{\"username\": \"alice and bob\"}"
}
...
```

In this specific example, the value `alice and bob` can be interpolated into the email template using the key `username`, referenced in the template with `{{ index .TemplateVariables "username" }}`.

The use of such template variables is purely optional.

Here’s an example of a custom HTML email template that includes the OTP code, referenced within the markup as `{{ .OtpCode }}`.
For best compatibility across email providers, make sure to use PNG images within your templates.

<Frame>
  <img
    src="https://mintcdn.com/turnkey-0e7c1f5b/wGCQfD161luMPlY9/images/embedded-wallets/img/email-auth-example-template.png?fit=max&auto=format&n=wGCQfD161luMPlY9&q=85&s=6b37bcb995881bc77d14f67672a3f4a8"
    alt="dynamic email auth
example"
    width="643"
    height="331"
    data-path="images/embedded-wallets/img/email-auth-example-template.png"
  />
</Frame>

<Warning>
  **Bespoke HTML templates** and **custom email sender domains** are available to [**Enterprise clients**](https://www.turnkey.com/pricing) on the **Scale tier** or higher.
</Warning>

If you are interested in implementing bespoke, fully-customized email templates, please reach out to [hello@turnkey.com](mailto:hello@turnkey.com).

### Custom email sender domain

[Enterprise](https://www.turnkey.com/pricing) clients  can also customize the email sender domain. To get set up, please reach out to your Turnkey rep to get started but here is what you'll be able to configure:

```js theme={"system"}
// Optional custom email address from which to send the OTP email
"sendFromEmailAddress": "notifs@mail.domain.com"

// Optional custom sender name
"sendFromEmailSenderName": "MyApp Notifications"

// Optional reply-to email address
"replyToEmailAddress": "reply@mail.domain.com"
```

Please keep in mind that:

* Email has to be from a pre-whitelisted domain
* If there is no `sendFromEmailAddress` or it's invalid, the other two fields are ignored
* If `sendFromEmailSenderName` is absent, it defaults to "Notifications" (again, ONLY if `sendFromEmailAddress` is present and valid)
* If `replyToEmailAddress` is absent, then there is no reply-to added. If it is present, it must ALSO be from a valid, whitelisted domain, but it doesn't have to be the same email address as the `sendFromEmailAddress` one (though once again, this first one MUST be present, or the other two feature are ignored)
* **Sender name screening:** Some email clients (e.g. Gmail) screen sender names more aggressively than others (e.g. Apple Mail), which can lead to inconsistent display across clients. To ensure a consistent experience, keep your `sendFromEmailSenderName` "safe looking" — avoid underscores, special symbols, or unusual formatting that may cause it to be filtered or hidden.

If you are interested in implementing bespoke, fully-customized email templates and sender domain, please reach out to [hello@turnkey.com](mailto:hello@turnkey.com).

### Authorization

Authorization is managed through our [policy engine](/concepts/policies/overview):

### Authentication

Both OTP-based and credential bundle authentication activities:

* Can be performed by [root quorum](/concepts/users/root-quorum#root-quorum) or users with proper policy authorization
* Require the respective feature to be enabled in the organization and sub-organization
* Can target any user in the organization or sub-organizations

Specifically:

* For OTP-based auth (current): `ACTIVITY_TYPE_INIT_OTP_V3`, `ACTIVITY_TYPE_VERIFY_OTP_V2`, and `ACTIVITY_TYPE_OTP_LOGIN_V2`
* For credential bundle auth: `ACTIVITY_TYPE_EMAIL_AUTH`

<Frame>
  <img src="https://mintcdn.com/turnkey-0e7c1f5b/83HCB8zBjOP3rX5S/images/authentication/img/diagrams/email_auth_authorization.png?fit=max&auto=format&n=83HCB8zBjOP3rX5S&q=85&s=13f1f4d09d13fd5328cfa82c5a66212a" alt="email auth authorization" width="3120" height="2928" data-path="images/authentication/img/diagrams/email_auth_authorization.png" />
</Frame>

### Example implementations

* [OTP Auth Example](https://github.com/tkhq/sdk/tree/main/examples/otp-auth)
* [Email Auth Example](https://github.com/tkhq/sdk/tree/main/examples/email-auth)
* [Demo Embedded Wallet](https://wallet.tx.xyz) ([code](https://github.com/tkhq/demo-embedded-wallet))

## Implementation in organizations

For organizations accessed via dashboard:

1. Ensure the required features are enabled:
   * `FEATURE_NAME_OTP_EMAIL_AUTH` for OTP-based authentication
   * `FEATURE_NAME_EMAIL_AUTH` for credential bundle authentication
2. Users initiating the request must have appropriate permissions

## Opting out

Organizations can disable email-based features if their security model requires it:

Use `ACTIVITY_TYPE_REMOVE_ORGANIZATION_FEATURE` to disable:

* `FEATURE_NAME_OTP_EMAIL_AUTH` for OTP-based authentication
* `FEATURE_NAME_EMAIL_AUTH` for credential bundle authentication

When creating sub-organizations, use:

* `disableOtpEmailAuth` parameter for OTP-based authentication
* `disableEmailAuth` parameter for credential bundle authentication

## Implementation notes

* Users are limited to:
  * 10 long-lived API keys
  * 10 expiring API keys (oldest are discarded when limit is reached)

### For top-level organizations

* Both authentication methods are disabled by default
* Must be enabled via `ACTIVITY_TYPE_SET_ORGANIZATION_FEATURE`

### For sub-organizations

* Both authentication methods are enabled by default
* Can be disabled during creation using `CreateSubOrganizationIntentV7` activity parameters

Example of enabling OTP-based Email Auth:

```bash theme={"system"}
turnkey request --host api.turnkey.com --path /public/v1/submit/set_organization_feature --body '{
        "timestampMs": "'"$(date +%s)"'000",
        "type": "ACTIVITY_TYPE_SET_ORGANIZATION_FEATURE",
        "organizationId": "<YOUR-ORG-ID>",
        "parameters": {
                "name": "FEATURE_NAME_OTP_EMAIL_AUTH"
        }
}' --organization <YOUR-ORG-ID>
```

Example of enabling credential bundle Email Auth:

```bash theme={"system"}
turnkey request --host api.turnkey.com --path /public/v1/submit/set_organization_feature --body '{
        "timestampMs": "'"$(date +%s)"'000",
        "type": "ACTIVITY_TYPE_SET_ORGANIZATION_FEATURE",
        "organizationId": "<YOUR-ORG-ID>",
        "parameters": {
                "name": "FEATURE_NAME_EMAIL_AUTH"
        }
}' --organization <YOUR-ORG-ID>
```
