diff --git a/authentication/email.mdx b/authentication/email.mdx
index e85b56a1..24d37f11 100644
--- a/authentication/email.mdx
+++ b/authentication/email.mdx
@@ -25,6 +25,20 @@ To test OTP codes in our sandbox environment you can use the following:
Both methods provide users with an expiring API key for authentication or account recovery.
+## Enclave-based OTP security
+
+Starting with SDK `v2026.2.8`, Turnkey's OTP system uses an **enclave-first architecture** where the OTP code is generated, delivered, and verified entirely inside the TLS Fetcher Enclave. The coordinator never sees a plaintext OTP code.
+
+
+ Learn how Turnkey generates and verifies OTPs inside secure enclaves, the cryptographic invariants that protect your users, and the client-side security changes in SDK v2026.2.8.
+
+
+Key security improvements in the current SDK:
+- The client verifies the enclave's bundle signature before trusting the `otpEncryptionTargetBundle` public key
+- OTP attempts are HPKE-encrypted before being sent to the server
+- `publicKey` is now required when encrypting an OTP attempt (no throwaway keypairs)
+- Login/signup sign with the verification token key, not the session key
+
## Core mechanism
Email Authentication is built with expiring API keys as the foundation. The two delivery mechanisms are:
@@ -86,7 +100,7 @@ For OTP Auth signup and login flows you will need a user with the following poli
### OTP-based authentication flow
-The flow begins with a new activity of type `ACTIVITY_TYPE_INIT_OTP` using the parent organization id with these parameters:
+The flow begins with a new activity of type `ACTIVITY_TYPE_INIT_OTP_V3` (enclave-generated OTP) using the parent organization id with these parameters:
- `otpType`: specify `"OTP_TYPE_EMAIL"`
- `contact`: user's email address
@@ -99,13 +113,13 @@ The flow begins with a new activity of type `ACTIVITY_TYPE_INIT_OTP` using the p
- `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
-After receiving the OTP, users complete OTP verification with `ACTIVITY_TYPE_VERIFY_OTP` using the parent organization id which returns a verificationToken JWT:
+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
- `otpCode`: the 6-9 digit or alphanumeric code received via email
- `expirationSeconds`: optional validity window (defaults to 1 hour)
-After receiving the verification token, users complete OTP authentication flow with with `ACTIVITY_TYPE_OTP_LOGIN` using the sub orgazanition ID associated with the contact from the first step:
+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 successfull `VERIFY_OTP` activity
@@ -235,9 +249,21 @@ const response = await client.emailAuth({
Old: `ACTIVITY_TYPE_INIT_OTP_AUTH`, `ACTIVITY_TYPE_INIT_OTP_AUTH_V2`
New: `ACTIVITY_TYPE_INIT_OTP_AUTH_V3`
- **INIT_OTP**
- Old: `ACTIVITY_TYPE_INIT_OTP`
- New: `ACTIVITY_TYPE_INIT_OTP_V2`
+ **INIT_OTP** (enclave-generated, contact verification & signup)
+ Old: `ACTIVITY_TYPE_INIT_OTP`, `ACTIVITY_TYPE_INIT_OTP_V2`
+ New: `ACTIVITY_TYPE_INIT_OTP_V3`
+
+ **INIT_OTP_AUTH** (coordinator-generated, existing-user login)
+ Old: `ACTIVITY_TYPE_INIT_OTP_AUTH`, `ACTIVITY_TYPE_INIT_OTP_AUTH_V2`
+ New: `ACTIVITY_TYPE_INIT_OTP_AUTH_V3`
+
+ **VERIFY_OTP**
+ Old: `ACTIVITY_TYPE_VERIFY_OTP`
+ New: `ACTIVITY_TYPE_VERIFY_OTP_V2`
+
+ **OTP_LOGIN**
+ Old: `ACTIVITY_TYPE_OTP_LOGIN`
+ New: `ACTIVITY_TYPE_OTP_LOGIN_V2`
**INIT_USER_EMAIL_RECOVERY**
Old: `ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY`
@@ -317,7 +343,8 @@ Both OTP-based and credential bundle authentication activities:
Specifically:
-- For OTP-based auth: `ACTIVITY_TYPE_INIT_OTP`, `ACTIVITY_TYPE_VERIFY_OTP` and `ACTIVITY_TYPE_OTP_LOGIN`
+- For OTP-based auth (current): `ACTIVITY_TYPE_INIT_OTP_V3`, `ACTIVITY_TYPE_VERIFY_OTP_V2`, and `ACTIVITY_TYPE_OTP_LOGIN_V2`
+- For OTP-based auth (login legacy): `ACTIVITY_TYPE_INIT_OTP_AUTH_V3`, `ACTIVITY_TYPE_VERIFY_OTP_V2`, and `ACTIVITY_TYPE_OTP_LOGIN_V2`
- For credential bundle auth: `ACTIVITY_TYPE_EMAIL_AUTH`
diff --git a/authentication/sms.mdx b/authentication/sms.mdx
index f1eeb798..65974043 100644
--- a/authentication/sms.mdx
+++ b/authentication/sms.mdx
@@ -34,15 +34,19 @@ Make sure you have set up your primary Turnkey organization with at least one AP
SMS authentication uses three activities:
-1. `INIT_OTP` - sends a 6-9 digit or bech32 alphanumeric OTP code to the specified phone number
-2. `VERIFY_OTP` - verifies the code and returns a verificationToken JWT
-3. `OTP_LOGIN` - verified the verificationToken and returns a session JWT
+1. `INIT_OTP_V3` — enclave generates and sends a 6–9 digit or alphanumeric OTP to the specified phone number; the coordinator never sees the plaintext code
+2. `VERIFY_OTP_V2` — enclave verifies the code and returns a signed verificationToken JWT
+3. `OTP_LOGIN_V2` — validates the verificationToken and returns a session (signs with the verification token key)
+
+
+ The OTP code is generated and verified entirely inside the TLS Fetcher Enclave. See [OTP Enclave Security](/security/otp-enclave) for the full cryptographic architecture.
+
## Implementation
### Initiating SMS authentication
-The flow begins with a new activity of type `ACTIVITY_TYPE_INIT_OTP` using the parent organization id with these parameters:
+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
@@ -63,13 +67,13 @@ To test OTP codes in our sandbox environment you can use the following:
- 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` with the parent organization ID to obtain a verificationToken JWT:
+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
- `otpCode`: the 6-9 digit or alphanumeric code received via SMS
- `expirationSeconds`: optional validity window (defaults to 1 hour)
-After receiving the verification token, users complete OTP authentication flow with with `ACTIVITY_TYPE_OTP_LOGIN` using the sub orgazanition ID associated with the contact from the first step:
+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 successfull `VERIFY_OTP` activity
diff --git a/docs.json b/docs.json
index ac5e93ec..ef4ae8a4 100644
--- a/docs.json
+++ b/docs.json
@@ -842,6 +842,7 @@
"security/turnkey-verified",
"security/disaster-recovery",
"security/enclave-secure-channels",
+ "security/otp-enclave",
"security/whitepaper",
"security/shared-responsibility-model",
"security/reporting-a-vulnerability"
diff --git a/security/otp-enclave.mdx b/security/otp-enclave.mdx
new file mode 100644
index 00000000..a2e8928b
--- /dev/null
+++ b/security/otp-enclave.mdx
@@ -0,0 +1,193 @@
+---
+title: "OTP within Secure Enclaves"
+sidebarTitle: "OTP Enclave Security"
+description: "How Turnkey generates, delivers, and verifies one-time passwords entirely inside the TLS Fetcher Enclave, ensuring the coordinator never sees a plaintext OTP."
+---
+
+Turnkey's OTP system (email and SMS) has been redesigned so that the entire lifecycle of a one-time password — generation, delivery, and verification — happens inside the **TLS Fetcher Enclave**. This page explains the security architecture, the cryptographic invariants that protect your users, and how the SDK enforces them on the client side.
+
+## Overview
+
+Turnkey supports two OTP use cases, each using a different init activity:
+
+| Use case | Init activity | Verify activity | OTP generator |
+|---|---|---|---|
+| Contact verification & signup | `ACTIVITY_TYPE_INIT_OTP_V3` | `ACTIVITY_TYPE_VERIFY_OTP_V2` | TLS Fetcher Enclave |
+| Login (existing user auth) | `ACTIVITY_TYPE_INIT_OTP_AUTH_V3` | `ACTIVITY_TYPE_VERIFY_OTP_V2` | Coordinator (legacy) |
+
+In the **enclave-first flow** (`INIT_OTP_V3` + `VERIFY_OTP_V2`), the OTP code is generated, encrypted, delivered, and verified entirely inside the TLS Fetcher Enclave. The coordinator handles orchestration but **never observes the plaintext OTP**.
+
+## Key invariant: what the coordinator never sees
+
+In `INIT_OTP_V3` + `VERIFY_OTP_V2`:
+
+- The coordinator renders the email/SMS template with a placeholder string (`TURNKEY_OTP_CODE_PLACEHOLDER`) **before** sending it to the enclave.
+- The enclave generates the real OTP, substitutes the placeholder, sends the message directly via AWS SES/SNS, and returns only **encrypted artifacts**.
+- Redis stores `enclaveEncryptedOtpCode` and `enclaveEncryptedTargetKey` — both opaque to the coordinator.
+- During verification, the coordinator passes these encrypted blobs back to the enclave, which decrypts and compares — then issues a signed verification token.
+
+**The coordinator never has access to the plaintext OTP at any point in the modern flow.**
+
+## Activity types
+
+| Activity | Description |
+|---|---|
+| `ACTIVITY_TYPE_INIT_OTP_V3` | Enclave generates & sends OTP, returns encrypted artifacts + `otpEncryptionTargetBundle` |
+| `ACTIVITY_TYPE_INIT_OTP_AUTH_V3` | Coordinator generates & sends OTP for existing-user login (legacy model) |
+| `ACTIVITY_TYPE_VERIFY_OTP_V2` | Enclave verifies OTP attempt, issues a signed verification token (JWT) |
+| `ACTIVITY_TYPE_OTP_LOGIN_V2` | Consumes verification token, creates a session (signs with the verification token key) |
+
+## INIT_OTP_V3: enclave-generated OTP flow
+
+Used for **contact verification and signup**. The OTP is generated inside the enclave.
+
+```
+Client → Coordinator: InitOtpIntent(contact, otpType, appName, customizations)
+Coordinator → Coordinator: Render email/SMS template with TURNKEY_OTP_CODE_PLACEHOLDER
+Coordinator → Enclave: InitOtp RPC (ruling, awsKeys, dnsResolvers, otpMessage[placeholder])
+
+Enclave:
+ - Generate OTP code (6–9 chars, numeric or alphanumeric)
+ - Reject weak/predictable codes
+ - Generate ephemeral P-256 Target Key
+ - Encrypt OTP with Target Key (HPKE)
+ - Encrypt Target Key with Quorum Key
+ - Substitute placeholder with real OTP
+ - Send email/SMS via AWS SES/SNS
+
+Enclave → Coordinator: otpId, enclaveEncryptedOtpCode, enclaveEncryptedTargetKey, otpEncryptionTargetBundle
+Coordinator → Redis: store OtpInfo (enclaveEncryptedOtpCode + enclaveEncryptedTargetKey), TTL=5min
+Coordinator → Redis: enforce inflight limit (≤3 per contact)
+Coordinator → Client: otpId + otpEncryptionTargetBundle
+```
+
+The `otpEncryptionTargetBundle` contains the enclave's ephemeral public key, which the client **must** use to encrypt the OTP attempt before submitting it back for verification.
+
+
+Before trusting `otpEncryptionTargetBundle`, the client SDK **verifies the bundle signature against the TLS Fetcher signing key**. This prevents a compromised coordinator from substituting a tampered target key and intercepting the user's OTP attempt. See [Client-side security](#client-side-security) below.
+
+
+## INIT_OTP_AUTH_V3: coordinator-generated OTP (legacy)
+
+Used for **existing-user login**. The OTP is generated in the coordinator, not the enclave.
+
+```
+Client → Coordinator: InitOtpAuthIntent(contact, otpType, appName, customizations)
+Coordinator → Database: look up user by contact
+Coordinator: Generate OTP code (9 chars, alphanumeric), reject weak codes
+Coordinator: AES-GCM encrypt OTP with OtpAuthEncryptionKey
+Coordinator → Redis: store encrypted OTP, TTL=5min
+Coordinator → Redis: enforce inflight limit (≤3 per contact)
+Coordinator → AWS SES/SNS: send email/SMS with real OTP
+Coordinator → Client: otpId
+```
+
+**Key difference:** The coordinator generates and sees the plaintext OTP. The OTP is AES-GCM encrypted before Redis storage, but the coordinator holds the encryption key. This is the legacy model and is why we consider `INIT_OTP_AUTH_V3` a transitional path rather than the preferred one.
+
+## VERIFY_OTP_V2: enclave verification
+
+Verification always happens inside the enclave, regardless of which init activity was used.
+
+```
+Client: encrypt OTP attempt using otpEncryptionTargetBundle public key (HPKE)
+Client → Coordinator: VerifyOtpIntent(otpId, encryptedAttempt, publicKey)
+Coordinator → Redis: GET otp:{orgId}:{otpId} → OtpInfo
+Coordinator → Enclave: VerifyOtp RPC (ruling, enclaveEncryptedTargetKey, enclaveEncryptedOtpCode)
+
+Enclave:
+ - Decrypt Target Key (Quorum Key)
+ - Decrypt stored OTP (Target Key)
+ - Decrypt user attempt (Target Key)
+ - Constant-time compare
+ - Check OtpAttemptLimiter (brute-force guard)
+
+ If OTP matches:
+ - Generate verification token {id, orgId, contact, type, publicKey, exp}
+ - Sign with Quorum Key (P-256 ECDSA)
+ - Return otpVerificationToken (signed JWT)
+
+Coordinator → Redis: remove otpId from inflight set, store token metadata
+Coordinator → Client: verificationToken
+```
+
+The client holds the signed `verificationToken` after a successful verification. This token is consumed in login/signup activities.
+
+## Token consumption: login and signup
+
+After `VERIFY_OTP_V2`, the verification token is consumed via:
+
+| Activity | Purpose |
+|---|---|
+| `ACTIVITY_TYPE_OTP_LOGIN_V2` | Validate token, create session (login existing user) |
+| `ACTIVITY_TYPE_OTP_SIGNUP` | Validate token, link contact to new user (signup) |
+
+Token validation checks:
+- **Signature**: signed by TLS Fetcher Enclave's Quorum Key
+- **Expiration**: `exp` field (default 1 hour, max 24 hours)
+- **Contact and type**: match expected values
+- **Replay protection**: `verificationId` not already consumed
+
+## Security controls
+
+### Bundle signature verification
+
+When the SDK receives the `otpEncryptionTargetBundle` from `INIT_OTP_V3`, it verifies the bundle's signature against the TLS Fetcher Enclave's signing key before trusting the enclosed public key. This prevents a malicious coordinator from substituting a controlled key to intercept the user's OTP attempt.
+
+This check is performed via `verifyEnclaveSignature` (exported from `@turnkey/crypto`) before `encryptOtpCode` is called.
+
+### HPKE encryption of OTP attempt
+
+The client encrypts its OTP attempt using the enclave's ephemeral public key (from `otpEncryptionTargetBundle`) via HPKE before submitting it. This means even a compromised coordinator cannot read the user's plaintext OTP attempt in transit.
+
+The SDK's `encryptOtpCode` function requires `publicKey` as a mandatory parameter — generating throwaway keypairs whose private keys are immediately discarded is explicitly disallowed to preserve key provenance.
+
+### Constant-time comparison
+
+OTP comparison inside the enclave uses constant-time equality checks to prevent timing-based side channels that could leak information about how many characters matched.
+
+### Brute-force protection
+
+An `OtpAttemptLimiter` inside the enclave tracks failed attempts per OTP and locks out further attempts after a configurable threshold (default: 3 retries).
+
+### Inflight limits
+
+At most 3 active OTP codes can be in-flight per contact at any time. Requesting a 4th code invalidates the oldest.
+
+### Short TTL
+
+OTP codes expire after **5 minutes** by default (configurable up to 10 minutes). Verification tokens expire after **1 hour** (configurable up to 24 hours).
+
+## Client-side security (SDK changes)
+
+These security invariants are enforced by the Turnkey client SDK starting with `v2026.2.8`:
+
+| Change | Why |
+|---|---|
+| `publicKey` is required in `encryptOtpCode` | Prevents throwaway keypairs with discarded private keys; key provenance is tracked |
+| Bundle signature verified before `encryptOtpCode` | Prevents coordinator from substituting a tampered `otpEncryptionTargetBundle` |
+| `verifyOtp` creates and persists the key via `apiKeyStamper.createKeyPair()` | Returns a `publicKey` in `VerifyOtpResult` for use in subsequent `loginWithOtp`/`signUpWithOtp` |
+| `loginWithOtp`/`signUpWithOtp` sign with the **verification token key** | Previously used the session key; now uses the key tied to the verification result |
+| `verifyEnclaveSignature` exported from `@turnkey/crypto` | Available for reuse by integrators who want to verify enclave signatures independently |
+| HPKE (`encryptOtpCode`) replaces `quorumKeyEncrypt` for OTP | Standard HPKE provides stronger security properties for the OTP attempt encryption |
+
+## Delivery: email and SMS
+
+Both email (via AWS SES) and SMS (via AWS SNS) are supported. The delivery mechanism is the same regardless of OTP type — the enclave sends the message directly, not the coordinator.
+
+For sandbox testing, see the [email auth](/authentication/email#one-time-password-sandbox-environment) and [SMS auth](/authentication/sms#one-time-password-sandbox-environment) docs for fixed test credentials.
+
+## Error codes
+
+| Error | Cause |
+|---|---|
+| `UNAUTHENTICATED` | OTP mismatch or attempt limit exceeded |
+| `RESOURCE_EXHAUSTED` | Inflight OTP limit (3 per contact) exceeded |
+| `DEADLINE_EXCEEDED` | OTP or verification token expired |
+| `ALREADY_EXISTS` | Verification token already consumed (replay attempt) |
+
+## Related
+
+- [Enclave secure channels](/security/enclave-secure-channels) — the HPKE protocol used for secure delivery
+- [Secure enclaves](/security/secure-enclaves) — how Turnkey's TLS Fetcher and other enclaves work
+- [Email auth & recovery](/authentication/email) — implementation guide for email OTP
+- [SMS authentication](/authentication/sms) — implementation guide for SMS OTP