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

# SMS authentication

> SMS authentication enables users to authenticate their Turnkey account using their phone number via a 6-9 digit or bech32 alphanumeric one-time password (OTP). When authenticated, users receive an expiring API key stored in memory within an iframe, which functions like a session key to access their wallet.

export const SmsPriceLookup = ({priceDataJson}) => {
  const countries = Object.keys(priceDataJson).sort();
  const toggleVisibility = () => {
    const targetDiv = document.getElementById('hidden-content-simple');
    if (targetDiv) {
      const isOpen = targetDiv.getAttribute('data-open') === 'true';
      targetDiv.setAttribute('data-open', !isOpen);
    }
  };
  const selectCountry = country => {
    const selectElement = document.getElementById('sms-country-select-simple');
    const buttonElement = document.getElementById('country-toggle-button');
    const targetDiv = document.getElementById('hidden-content-simple');
    const priceDisplayElement = document.getElementById('sms-price-display');
    if (selectElement) {
      selectElement.value = country;
    }
    if (buttonElement) {
      buttonElement.textContent = country;
    }
    if (priceDisplayElement && priceDataJson[country]) {
      const price = priceDataJson[country];
      priceDisplayElement.textContent = `${price}¢`;
    }
    if (targetDiv) {
      targetDiv.setAttribute('data-open', 'false');
    }
  };
  return <div className="relative">
  <div className="text-lg font-medium text-black/70 dark:text-white mb-8">
    Select Your Country To View Pricing
  </div>
  <div className="flex gap-12 items-center items-center rounded-lg callout px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-zinc-500/20 bg-zinc-50/50 dark:border-zinc-500/30 dark:bg-zinc-500/10">
    <button id="country-toggle-button" onClick={toggleVisibility} className="card w-full block not-prose font-normal group relative py-3 px-3 ring-2 ring-transparent rounded-xl bg-white dark:bg-background-dark border border-gray-950/10 dark:border-white/10 overflow-hidden cursor-pointer hover:!border-primary dark:hover:!border-primary-light">
      Select Country
    </button>
    <div className="w-full flex flex-col gap-1 items-center">
      <div className="text-sm font-medium text-black/70 dark:text-white">Cost</div>
      <div id="sms-price-display" className="font-medium text-black/70 dark:text-white text-xl">0.000¢</div>

    </div>

    <div id="hidden-content-simple" data-open="false" style={{
    top: "calc(100% + 0px)"
  }} className="text-base bg-background-light dark:bg-background-dark absolute z-50 max-h-96 max-w-[var(--radix-dropdown-menu-content-available-width)] min-w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border-standard text-gray-950/70 dark:text-white/70 p-1 gap-2">
      <div>
        {countries.map(country => <div key={country} style={{
    padding: "0.5rem",
    cursor: "pointer"
  }} className="hover:bg-gray-100 rounded-md hover:dark:bg-gray-800" onClick={() => selectCountry(country)}>
            {country}
          </div>)}
      </div>
    </div>

  </div>

</div>;
};

export const priceDataJson = {
  Albania: 9.029,
  Andorra: 11.38,
  Angola: 8.983,
  Argentina: 6.928,
  Armenia: 14.113,
  Australia: 3.45,
  Austria: 10.526,
  Azerbaijan: 36.91,
  Bahamas: 7.011,
  Bahrain: 3.549,
  Bangladesh: 24.928,
  Barbados: 13.238,
  Belgium: 9.582,
  Belize: 26.502,
  Benin: 12.25,
  Bermuda: 21.114,
  Bolivia: 14.057,
  "Bosnia And Herzegovina": 7.463,
  Botswana: 3.831,
  Brazil: 2.297,
  "British Virgin Islands": 18.022,
  Brunei: 1.289,
  Bulgaria: 13.600,
  Cameroon: 12.333,
  Canada: 0.581,
  "Cape Verde": 13.74,
  "Cayman Islands": 7.3,
  Chad: 14.44,
  Chile: 5.178,
  Colombia: 0.157,
  "Costa Rica": 4.678,
  Croatia: 8.741,
  Curacao: 12.667,
  Cyprus: 2.995,
  Czechia: 6.609,
  "Democratic Republic Of The Congo": 11.435,
  Denmark: 6.206,
  Djibouti: 11.18,
  Dominica: 13.002,
  Ecuador: 17.655,
  "El Salvador": 6.609,
  England: 4.5,
  "Equatorial Guinea": 13.06,
  Estonia: 7.687,
  "Faroe Islands": 4.369,
  Fiji: 15.002,
  Finland: 10.009,
  France: 6.933,
  "French Polynesia": 12.86,
  Gambia: 8.444,
  Georgia: 17.028,
  Germany: 8.848,
  Ghana: 16.611,
  Gibraltar: 7.611,
  Greece: 8.621,
  Greenland: 1.511,
  Grenada: 14.593,
  Guadelupe: 16.302,
  Guatemala: 10.06,
  Guinea: 14.26,
  Guyana: 11.133,
  Haiti: 16.299,
  Honduras: 8.31,
  "Hong Kong": 7.656,
  Hungary: 10.824,
  Iceland: 9.64,
  India: 6.9,
  Indonesia: 36.308,
  Ireland: 9.556,
  Israel: 17.031,
  Italy: 7.5,
  "Ivory Coast": 19.076,
  Jamaica: 16.714,
  Japan: 7.451,
  Kosovo: 11.404,
  Kuwait: 14.94,
  Kyrgyzstan: 20.282,
  Laos: 5.365,
  Latvia: 6.848,
  Lebanon: 25.954,
  Lesotho: 9.89,
  Liberia: 8.793,
  Liechtenstein: 2.929,
  Lithuania: 4.358,
  Luxembourg: 8.412,
  Madagascar: 36.064,
  Malaysia: 15.107,
  Maldives: 4.149,
  Mali: 27.733,
  Malta: 6.98,
  Mauritius: 15.744,
  Mexico: 5.956,
  Moldova: 6.226,
  Mongolia: 12.765,
  Montenegro: 6.569,
  Morocco: 17.586,
  Mozambique: 12.638,
  Namibia: 8.284,
  Nepal: 15.017,
  Netherlands: 11.89,
  "New Zealand": 9.416,
  Nicaragua: 7.773,
  Niger: 26.123,
  Nigeria: 22.809,
  "Northern Ireland": 4.5,
  Norway: 8.675,
  Pakistan: 33.692,
  Panama: 8.786,
  Peru: 9.209,
  Philippines: 15.885,
  Poland: 3.222,
  Portugal: 3.031,
  "Puerto Rico 1": 1.356,
  "Puerto Rico 2": 1.356,
  Qatar: 3.973,
  Reunion: 17.186,
  Romania: 7.804,
  "Saint Lucia": 8.1,
  "Saint Vincent And Grenadines": 13.192,
  Samoa: 15.394,
  "San Marino": 10.32,
  "Saudi Arabia": 10.154,
  Scotland: 4.5,
  Senegal: 18.815,
  Serbia: 5.1,
  Seychelles: 4.272,
  "Sierra Leone": 18.37,
  Singapore: 3.918,
  Slovakia: 8.212,
  Slovenia: 14.522,
  "South Africa": 1.98,
  "South Korea": 2.414,
  Spain: 6.087,
  Suriname: 12.681,
  Swaziland: 8.031,
  Sweden: 7.256,
  Switzerland: 5.124,
  Tajikistan: 44.377,
  Tanzania: 19.222,
  Thailand: 1.272,
  Togo: 14.97,
  "Trinidad And Tobago": 12.953,
  Tunisia: 25.684,
  Turkey: 0.318,
  "United Arab Emirates": 10.809,
  "United Kingdom": 4.500,
  "United States": 0.581,
  Uruguay: 7.317,
  Uzbekistan: 36.803,
  Venezuela: 6.78,
  Wales: 4.5
};

## Access and pricing

SMS authentication is available to all Enterprise customers. To enable this feature, please reach out to the Turnkey team ([help@turnkey.com](mailto:help@turnkey.com)).

SMS pricing is usage-based and varies depending on the country of the destination phone number and the carrier.
Prices are shown in U.S. cents per outbound SMS message segment. Taxes/surcharges separate.

Select your country below to view pricing.

<SmsPriceLookup priceDataJson={priceDataJson} />

## Prerequisites

Make sure you have set up your primary Turnkey organization with at least one API user that can programmatically initiate OTP and create sub-organizations. 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 == 'OTP' && activity.action == 'CREATE') || (activity.resource == 'OTP' && activity.action == 'VERIFY') || (activity.resource == 'ORGANIZATION' && activity.action == 'CREATE')"
}
```

## How it works

SMS authentication uses three activities:

1. `INIT_OTP_V3` —  initiates a secure OTP flow and sends a 6–9 digit or alphanumeric OTP to the specified phone number. The response includes an `otpEncryptionTargetBundle` which is used in OTP verification.
2. `VERIFY_OTP_V2` — securely verifies the code and returns a signed verificationToken JWT
3. `OTP_LOGIN_V2` — validates the verificationToken and returns a session (signed with the verification token key)

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

## Implementation

### Initiating SMS authentication

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_SMS"`
* `contact`: user's phone number
* `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)

#### 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`
* Phone Number: +1 999-999-9999
* OTP Code: `000000`

In the sandbox environment, SMS delivery is simulated. Use the fixed OTP code `000000` (with the returned otpId) when calling `ACTIVITY_TYPE_VERIFY_OTP_V2` with the parent organization ID to obtain 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 SMS, 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>

## Authorization

SMS authentication requires proper permissions through policies or parent organization status.

## Enabling/disabling SMS auth

### For top-level organizations

SMS authentication is disabled by default. Enable it using `ACTIVITY_TYPE_SET_ORGANIZATION_FEATURE`:

```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_SMS_AUTH"
        }
}' --organization <YOUR-ORG-ID>
```

### For sub-organizations

* SMS auth is enabled by default
* Disable during creation using `disableSmsAuth: true` in the `CreateSubOrganizationIntentV7` activity
* Disable after creation using `ACTIVITY_TYPE_REMOVE_ORGANIZATION_FEATURE` with feature name `FEATURE_NAME_SMS_AUTH`

## Implementation notes

* Users are limited to 10 long-lived API keys and 10 expiring API keys
* When the expiring API key limit is reached, the oldest key is automatically discarded

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