Skip to content

Commit 41f3791

Browse files
committed
docs: add OTP enclave security page and update auth activity types
- Add security/otp-enclave.mdx: comprehensive doc covering the enclave-first OTP architecture (INIT_OTP_V3, VERIFY_OTP_V2, OTP_LOGIN_V2), including sequence flows, key invariants, client-side security changes from SDK v2026.2.8, and security controls (bundle sig verification, HPKE encryption, brute-force protection, inflight limits) - Update authentication/email.mdx: - Add callout card linking to new security page with summary of SDK v2026.2.8 client-side security improvements - Update activity type references: INIT_OTP → INIT_OTP_V3, VERIFY_OTP → VERIFY_OTP_V2, OTP_LOGIN → OTP_LOGIN_V2 - Expand Breaking Change policy table with VERIFY_OTP_V2 and OTP_LOGIN_V2 version progression - Update Authorization section to list both enclave (V3) and legacy auth (V3) paths with correct activity types - Update authentication/sms.mdx: - Update How it Works activity types to V3/V2 equivalents - Add note linking to OTP enclave security page - Fix sandbox verify/login activity type references - Update docs.json: add security/otp-enclave to Security tab navigation References: tkhq/sdk#1220, tkhq/sdk#1221
1 parent ff7ac26 commit 41f3791

File tree

4 files changed

+238
-13
lines changed

4 files changed

+238
-13
lines changed

authentication/email.mdx

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ To test OTP codes in our sandbox environment you can use the following:
2525

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

28+
## Enclave-based OTP security
29+
30+
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.
31+
32+
<Card title="OTP Enclave Security" icon="shield-halved" href="/security/otp-enclave">
33+
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.
34+
</Card>
35+
36+
Key security improvements in the current SDK:
37+
- The client verifies the enclave's bundle signature before trusting the `otpEncryptionTargetBundle` public key
38+
- OTP attempts are HPKE-encrypted before being sent to the server
39+
- `publicKey` is now required when encrypting an OTP attempt (no throwaway keypairs)
40+
- Login/signup sign with the verification token key, not the session key
41+
2842
## Core mechanism
2943

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

87101
### OTP-based authentication flow
88102

89-
The flow begins with a new activity of type `ACTIVITY_TYPE_INIT_OTP` using the parent organization id with these parameters:
103+
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:
90104

91105
- `otpType`: specify `"OTP_TYPE_EMAIL"`
92106
- `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
99113
- `sendFromEmailSenderName` : optional custom sender name for use with sendFromEmailAddress; if left empty, will default to ‘Notifications’
100114
- `replyToEmailAddress` : optional custom email address to use as reply-to
101115

102-
After receiving the OTP, users complete OTP verification with `ACTIVITY_TYPE_VERIFY_OTP` using the parent organization id which returns a verificationToken JWT:
116+
After receiving the OTP, users complete OTP verification with `ACTIVITY_TYPE_VERIFY_OTP_V2` using the parent organization id which returns a verificationToken JWT:
103117

104118
- `otpId`: ID from the init activity
105119
- `otpCode`: the 6-9 digit or alphanumeric code received via email
106120
- `expirationSeconds`: optional validity window (defaults to 1 hour)
107121

108-
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:
122+
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:
109123

110124
- `publicKey`: public key to add to organization data associated with the signing key in IndexedDB or SecureStorage.
111125
- `verificationToken`: JWT returned from successfull `VERIFY_OTP` activity
@@ -235,9 +249,21 @@ const response = await client.emailAuth({
235249
Old: `ACTIVITY_TYPE_INIT_OTP_AUTH`, `ACTIVITY_TYPE_INIT_OTP_AUTH_V2`<br/>
236250
New: `ACTIVITY_TYPE_INIT_OTP_AUTH_V3`
237251

238-
**INIT_OTP**<br/>
239-
Old: `ACTIVITY_TYPE_INIT_OTP`<br/>
240-
New: `ACTIVITY_TYPE_INIT_OTP_V2`
252+
**INIT_OTP** (enclave-generated, contact verification & signup)<br/>
253+
Old: `ACTIVITY_TYPE_INIT_OTP`, `ACTIVITY_TYPE_INIT_OTP_V2`<br/>
254+
New: `ACTIVITY_TYPE_INIT_OTP_V3`
255+
256+
**INIT_OTP_AUTH** (coordinator-generated, existing-user login)<br/>
257+
Old: `ACTIVITY_TYPE_INIT_OTP_AUTH`, `ACTIVITY_TYPE_INIT_OTP_AUTH_V2`<br/>
258+
New: `ACTIVITY_TYPE_INIT_OTP_AUTH_V3`
259+
260+
**VERIFY_OTP**<br/>
261+
Old: `ACTIVITY_TYPE_VERIFY_OTP`<br/>
262+
New: `ACTIVITY_TYPE_VERIFY_OTP_V2`
263+
264+
**OTP_LOGIN**<br/>
265+
Old: `ACTIVITY_TYPE_OTP_LOGIN`<br/>
266+
New: `ACTIVITY_TYPE_OTP_LOGIN_V2`
241267

242268
**INIT_USER_EMAIL_RECOVERY**<br/>
243269
Old: `ACTIVITY_TYPE_INIT_USER_EMAIL_RECOVERY`<br/>
@@ -317,7 +343,8 @@ Both OTP-based and credential bundle authentication activities:
317343

318344
Specifically:
319345

320-
- For OTP-based auth: `ACTIVITY_TYPE_INIT_OTP`, `ACTIVITY_TYPE_VERIFY_OTP` and `ACTIVITY_TYPE_OTP_LOGIN`
346+
- For OTP-based auth (current): `ACTIVITY_TYPE_INIT_OTP_V3`, `ACTIVITY_TYPE_VERIFY_OTP_V2`, and `ACTIVITY_TYPE_OTP_LOGIN_V2`
347+
- For OTP-based auth (login legacy): `ACTIVITY_TYPE_INIT_OTP_AUTH_V3`, `ACTIVITY_TYPE_VERIFY_OTP_V2`, and `ACTIVITY_TYPE_OTP_LOGIN_V2`
321348
- For credential bundle auth: `ACTIVITY_TYPE_EMAIL_AUTH`
322349

323350
<Frame>

authentication/sms.mdx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,19 @@ Make sure you have set up your primary Turnkey organization with at least one AP
3434

3535
SMS authentication uses three activities:
3636

37-
1. `INIT_OTP` - sends a 6-9 digit or bech32 alphanumeric OTP code to the specified phone number
38-
2. `VERIFY_OTP` - verifies the code and returns a verificationToken JWT
39-
3. `OTP_LOGIN` - verified the verificationToken and returns a session JWT
37+
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
38+
2. `VERIFY_OTP_V2` — enclave verifies the code and returns a signed verificationToken JWT
39+
3. `OTP_LOGIN_V2` — validates the verificationToken and returns a session (signs with the verification token key)
40+
41+
<Note>
42+
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.
43+
</Note>
4044

4145
## Implementation
4246

4347
### Initiating SMS authentication
4448

45-
The flow begins with a new activity of type `ACTIVITY_TYPE_INIT_OTP` using the parent organization id with these parameters:
49+
The flow begins with a new activity of type `ACTIVITY_TYPE_INIT_OTP_V3` using the parent organization id with these parameters:
4650

4751
- `otpType`: specify `"OTP_TYPE_SMS"`
4852
- `contact`: user's phone number
@@ -63,13 +67,13 @@ To test OTP codes in our sandbox environment you can use the following:
6367
- Phone Number: +1 999-999-9999
6468
- OTP Code: `000000`
6569

66-
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:
70+
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:
6771

6872
- `otpId`: ID from the init activity
6973
- `otpCode`: the 6-9 digit or alphanumeric code received via SMS
7074
- `expirationSeconds`: optional validity window (defaults to 1 hour)
7175

72-
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:
76+
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:
7377

7478
- `publicKey`: public key to add to organization data associated with the signing key in IndexedDB or SecureStorage.
7579
- `verificationToken`: JWT returned from successfull `VERIFY_OTP` activity

docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,7 @@
842842
"security/turnkey-verified",
843843
"security/disaster-recovery",
844844
"security/enclave-secure-channels",
845+
"security/otp-enclave",
845846
"security/whitepaper",
846847
"security/shared-responsibility-model",
847848
"security/reporting-a-vulnerability"

security/otp-enclave.mdx

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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

Comments
 (0)