|
| 1 | +--- |
| 2 | +title: "OTP within Secure Enclaves" |
| 3 | +sidebarTitle: "OTP Enclave Security" |
| 4 | +description: "How Turnkey generates, delivers, and verifies one-time passwords entirely inside the TLS Fetcher Enclave, ensuring the coordinator never sees a plaintext OTP." |
| 5 | +--- |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +## Overview |
| 10 | + |
| 11 | +Turnkey supports two OTP use cases, each using a different init activity: |
| 12 | + |
| 13 | +| Use case | Init activity | Verify activity | OTP generator | |
| 14 | +|---|---|---|---| |
| 15 | +| Contact verification & signup | `ACTIVITY_TYPE_INIT_OTP_V3` | `ACTIVITY_TYPE_VERIFY_OTP_V2` | TLS Fetcher Enclave | |
| 16 | +| Login (existing user auth) | `ACTIVITY_TYPE_INIT_OTP_AUTH_V3` | `ACTIVITY_TYPE_VERIFY_OTP_V2` | Coordinator (legacy) | |
| 17 | + |
| 18 | +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**. |
| 19 | + |
| 20 | +## Key invariant: what the coordinator never sees |
| 21 | + |
| 22 | +In `INIT_OTP_V3` + `VERIFY_OTP_V2`: |
| 23 | + |
| 24 | +- The coordinator renders the email/SMS template with a placeholder string (`TURNKEY_OTP_CODE_PLACEHOLDER`) **before** sending it to the enclave. |
| 25 | +- The enclave generates the real OTP, substitutes the placeholder, sends the message directly via AWS SES/SNS, and returns only **encrypted artifacts**. |
| 26 | +- Redis stores `enclaveEncryptedOtpCode` and `enclaveEncryptedTargetKey` — both opaque to the coordinator. |
| 27 | +- During verification, the coordinator passes these encrypted blobs back to the enclave, which decrypts and compares — then issues a signed verification token. |
| 28 | + |
| 29 | +**The coordinator never has access to the plaintext OTP at any point in the modern flow.** |
| 30 | + |
| 31 | +## Activity types |
| 32 | + |
| 33 | +| Activity | Description | |
| 34 | +|---|---| |
| 35 | +| `ACTIVITY_TYPE_INIT_OTP_V3` | Enclave generates & sends OTP, returns encrypted artifacts + `otpEncryptionTargetBundle` | |
| 36 | +| `ACTIVITY_TYPE_INIT_OTP_AUTH_V3` | Coordinator generates & sends OTP for existing-user login (legacy model) | |
| 37 | +| `ACTIVITY_TYPE_VERIFY_OTP_V2` | Enclave verifies OTP attempt, issues a signed verification token (JWT) | |
| 38 | +| `ACTIVITY_TYPE_OTP_LOGIN_V2` | Consumes verification token, creates a session (signs with the verification token key) | |
| 39 | + |
| 40 | +## INIT_OTP_V3: enclave-generated OTP flow |
| 41 | + |
| 42 | +Used for **contact verification and signup**. The OTP is generated inside the enclave. |
| 43 | + |
| 44 | +``` |
| 45 | +Client → Coordinator: InitOtpIntent(contact, otpType, appName, customizations) |
| 46 | +Coordinator → Coordinator: Render email/SMS template with TURNKEY_OTP_CODE_PLACEHOLDER |
| 47 | +Coordinator → Enclave: InitOtp RPC (ruling, awsKeys, dnsResolvers, otpMessage[placeholder]) |
| 48 | +
|
| 49 | +Enclave: |
| 50 | + - Generate OTP code (6–9 chars, numeric or alphanumeric) |
| 51 | + - Reject weak/predictable codes |
| 52 | + - Generate ephemeral P-256 Target Key |
| 53 | + - Encrypt OTP with Target Key (HPKE) |
| 54 | + - Encrypt Target Key with Quorum Key |
| 55 | + - Substitute placeholder with real OTP |
| 56 | + - Send email/SMS via AWS SES/SNS |
| 57 | +
|
| 58 | +Enclave → Coordinator: otpId, enclaveEncryptedOtpCode, enclaveEncryptedTargetKey, otpEncryptionTargetBundle |
| 59 | +Coordinator → Redis: store OtpInfo (enclaveEncryptedOtpCode + enclaveEncryptedTargetKey), TTL=5min |
| 60 | +Coordinator → Redis: enforce inflight limit (≤3 per contact) |
| 61 | +Coordinator → Client: otpId + otpEncryptionTargetBundle |
| 62 | +``` |
| 63 | + |
| 64 | +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. |
| 65 | + |
| 66 | +<Warning> |
| 67 | +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. |
| 68 | +</Warning> |
| 69 | + |
| 70 | +## INIT_OTP_AUTH_V3: coordinator-generated OTP (legacy) |
| 71 | + |
| 72 | +Used for **existing-user login**. The OTP is generated in the coordinator, not the enclave. |
| 73 | + |
| 74 | +``` |
| 75 | +Client → Coordinator: InitOtpAuthIntent(contact, otpType, appName, customizations) |
| 76 | +Coordinator → Database: look up user by contact |
| 77 | +Coordinator: Generate OTP code (9 chars, alphanumeric), reject weak codes |
| 78 | +Coordinator: AES-GCM encrypt OTP with OtpAuthEncryptionKey |
| 79 | +Coordinator → Redis: store encrypted OTP, TTL=5min |
| 80 | +Coordinator → Redis: enforce inflight limit (≤3 per contact) |
| 81 | +Coordinator → AWS SES/SNS: send email/SMS with real OTP |
| 82 | +Coordinator → Client: otpId |
| 83 | +``` |
| 84 | + |
| 85 | +**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. |
| 86 | + |
| 87 | +## VERIFY_OTP_V2: enclave verification |
| 88 | + |
| 89 | +Verification always happens inside the enclave, regardless of which init activity was used. |
| 90 | + |
| 91 | +``` |
| 92 | +Client: encrypt OTP attempt using otpEncryptionTargetBundle public key (HPKE) |
| 93 | +Client → Coordinator: VerifyOtpIntent(otpId, encryptedAttempt, publicKey) |
| 94 | +Coordinator → Redis: GET otp:{orgId}:{otpId} → OtpInfo |
| 95 | +Coordinator → Enclave: VerifyOtp RPC (ruling, enclaveEncryptedTargetKey, enclaveEncryptedOtpCode) |
| 96 | +
|
| 97 | +Enclave: |
| 98 | + - Decrypt Target Key (Quorum Key) |
| 99 | + - Decrypt stored OTP (Target Key) |
| 100 | + - Decrypt user attempt (Target Key) |
| 101 | + - Constant-time compare |
| 102 | + - Check OtpAttemptLimiter (brute-force guard) |
| 103 | +
|
| 104 | + If OTP matches: |
| 105 | + - Generate verification token {id, orgId, contact, type, publicKey, exp} |
| 106 | + - Sign with Quorum Key (P-256 ECDSA) |
| 107 | + - Return otpVerificationToken (signed JWT) |
| 108 | +
|
| 109 | +Coordinator → Redis: remove otpId from inflight set, store token metadata |
| 110 | +Coordinator → Client: verificationToken |
| 111 | +``` |
| 112 | + |
| 113 | +The client holds the signed `verificationToken` after a successful verification. This token is consumed in login/signup activities. |
| 114 | + |
| 115 | +## Token consumption: login and signup |
| 116 | + |
| 117 | +After `VERIFY_OTP_V2`, the verification token is consumed via: |
| 118 | + |
| 119 | +| Activity | Purpose | |
| 120 | +|---|---| |
| 121 | +| `ACTIVITY_TYPE_OTP_LOGIN_V2` | Validate token, create session (login existing user) | |
| 122 | +| `ACTIVITY_TYPE_OTP_SIGNUP` | Validate token, link contact to new user (signup) | |
| 123 | + |
| 124 | +Token validation checks: |
| 125 | +- **Signature**: signed by TLS Fetcher Enclave's Quorum Key |
| 126 | +- **Expiration**: `exp` field (default 1 hour, max 24 hours) |
| 127 | +- **Contact and type**: match expected values |
| 128 | +- **Replay protection**: `verificationId` not already consumed |
| 129 | + |
| 130 | +## Security controls |
| 131 | + |
| 132 | +### Bundle signature verification |
| 133 | + |
| 134 | +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. |
| 135 | + |
| 136 | +This check is performed via `verifyEnclaveSignature` (exported from `@turnkey/crypto`) before `encryptOtpCode` is called. |
| 137 | + |
| 138 | +### HPKE encryption of OTP attempt |
| 139 | + |
| 140 | +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. |
| 141 | + |
| 142 | +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. |
| 143 | + |
| 144 | +### Constant-time comparison |
| 145 | + |
| 146 | +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. |
| 147 | + |
| 148 | +### Brute-force protection |
| 149 | + |
| 150 | +An `OtpAttemptLimiter` inside the enclave tracks failed attempts per OTP and locks out further attempts after a configurable threshold (default: 3 retries). |
| 151 | + |
| 152 | +### Inflight limits |
| 153 | + |
| 154 | +At most 3 active OTP codes can be in-flight per contact at any time. Requesting a 4th code invalidates the oldest. |
| 155 | + |
| 156 | +### Short TTL |
| 157 | + |
| 158 | +OTP codes expire after **5 minutes** by default (configurable up to 10 minutes). Verification tokens expire after **1 hour** (configurable up to 24 hours). |
| 159 | + |
| 160 | +## Client-side security (SDK changes) |
| 161 | + |
| 162 | +These security invariants are enforced by the Turnkey client SDK starting with `v2026.2.8`: |
| 163 | + |
| 164 | +| Change | Why | |
| 165 | +|---|---| |
| 166 | +| `publicKey` is required in `encryptOtpCode` | Prevents throwaway keypairs with discarded private keys; key provenance is tracked | |
| 167 | +| Bundle signature verified before `encryptOtpCode` | Prevents coordinator from substituting a tampered `otpEncryptionTargetBundle` | |
| 168 | +| `verifyOtp` creates and persists the key via `apiKeyStamper.createKeyPair()` | Returns a `publicKey` in `VerifyOtpResult` for use in subsequent `loginWithOtp`/`signUpWithOtp` | |
| 169 | +| `loginWithOtp`/`signUpWithOtp` sign with the **verification token key** | Previously used the session key; now uses the key tied to the verification result | |
| 170 | +| `verifyEnclaveSignature` exported from `@turnkey/crypto` | Available for reuse by integrators who want to verify enclave signatures independently | |
| 171 | +| HPKE (`encryptOtpCode`) replaces `quorumKeyEncrypt` for OTP | Standard HPKE provides stronger security properties for the OTP attempt encryption | |
| 172 | + |
| 173 | +## Delivery: email and SMS |
| 174 | + |
| 175 | +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. |
| 176 | + |
| 177 | +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. |
| 178 | + |
| 179 | +## Error codes |
| 180 | + |
| 181 | +| Error | Cause | |
| 182 | +|---|---| |
| 183 | +| `UNAUTHENTICATED` | OTP mismatch or attempt limit exceeded | |
| 184 | +| `RESOURCE_EXHAUSTED` | Inflight OTP limit (3 per contact) exceeded | |
| 185 | +| `DEADLINE_EXCEEDED` | OTP or verification token expired | |
| 186 | +| `ALREADY_EXISTS` | Verification token already consumed (replay attempt) | |
| 187 | + |
| 188 | +## Related |
| 189 | + |
| 190 | +- [Enclave secure channels](/security/enclave-secure-channels) — the HPKE protocol used for secure delivery |
| 191 | +- [Secure enclaves](/security/secure-enclaves) — how Turnkey's TLS Fetcher and other enclaves work |
| 192 | +- [Email auth & recovery](/authentication/email) — implementation guide for email OTP |
| 193 | +- [SMS authentication](/authentication/sms) — implementation guide for SMS OTP |
0 commit comments