@expo/apple-utils: 2FA verification fails for accounts with voice-call delivery (pushMode: "voice")
- Dominant language
- TypeScript
- Stars
- 1.4k
- Forks
- 236
- Avg merge
- 3d 1h
- Merged PRs (30d)
- 91
Description
## Build/Submit details page URL
N/A — issue occurs during Apple authentication, before any build starts.
## Summary
Apple 2FA codes are rejected with error `-21669` ("Incorrect verification code") for accounts where Apple delivers codes via **voice call** instead of SMS. The `mode` field in `@expo/apple-utils` is hardcoded to `"sms"` in both the code request and verification endpoints, but Apple requires it to match the actual delivery method indicated by `pushMode` in the handshake response. This is the same class of bug Fastlane had and fixed in fastlane/fastlane#17666.
## Managed or bare?
Bare
## Environment
```
expo-env-info 2.0.12 environment info:
System:
OS: macOS 26.4.1
Shell: 5.9 - /bin/zsh
Binaries:
Node: 22.19.0
npm: 11.12.1
SDKs:
iOS SDK:
Platforms: DriverKit 25.2, iOS 26.2, macOS 26.2
IDEs:
Xcode: 26.3/17C529
npmPackages:
expo: ~55.0.15 => 55.0.15
react-native: 0.83.4 => 0.83.4
npmGlobalPackages:
eas-cli: 18.7.0
Expo Workflow: bare
```
## Error output
```
[PATCH] 2FA handshake: {
"trustedPhoneNumbers": [
{
"id": 1,
"pushMode": "voice",
"num": "+1 (•••) •••-••46"
}
],
"noTrustedDevices": true,
"securityCode": {
"length": 6,
"tooManyCodesSent": false,
"tooManyCodesValidated": false,
"securityCodeLocked": false,
"securityCodeCooldown": false
}
}
[PATCH] Apple error: -21669 Incorrect verification code.
```
## Reproducible demo or steps to reproduce from a blank project
### Steps to reproduce
1. Have an Apple Developer account with a single trusted phone number where Apple delivers 2FA codes via **voice call** (not SMS). This is determined by Apple based on account/region/carrier settings — the `pushMode` field in the handshake response will be `"voice"` instead of `"sms"`.
2. Run `eas credentials --platform ios` (or `eas build`)
3. Log in with Apple ID credentials
4. Receive a phone call with the 2FA code
5. Enter the correct code
6. → `✖ Invalid code` (error `-21669: Incorrect verification code`)
The same code works immediately on Apple's website.
### Root cause
I traced through the minified `@expo/apple-utils` (v2.1.19) bundle and identified three issues:
**1. `smsAutomaticallySent` detection ignores delivery method**
The detection function checks `trustedPhoneNumbers.length === 1 && noTrustedDevices`, and if true, assumes Apple auto-sent an SMS and skips the explicit code request. For voice-mode accounts, Apple auto-sent a **call**, not an SMS — but the code doesn't know the difference.
**2. `verifyTwoFactorCodeAsync` hardcodes `mode: "sms"`**
```js
// Current (minified):
e && (n = "phone", i.phoneNumber = { id: e }, i.mode = "sms")
// ^^^
// Always "sms", even when the code was delivered via voice call
```
Apple's POST endpoint at `appleauth/auth/verify/phone/securitycode` requires `mode` to match the actual delivery method. When Apple sent a voice call but the verify request says `"sms"`, Apple rejects it with `-21669`.
**3. `sendRequestTokenToSMSAsync` also hardcodes `mode: "sms"`**
The PUT request to `appleauth/auth/verify/phone` that explicitly requests code delivery also hardcodes `mode: "sms"`. For the fallback path (when `smsAutomaticallySent` is false), this would also fail for voice-mode accounts.
### Verification
I patched the local `@expo/apple-utils/build/index.js`:
- Changed `i.mode = "sms"` → `i.mode = "voice"` in the verify function
- Result: **✔ Valid code** — authentication succeeds immediately
### Suggested fix
Apple's handshake response includes `pushMode` on each trusted phone number. The fix is to read it and pass it through:
```ts
// Read the actual delivery mode from the handshake response
const phoneMode = trustedPhoneNumbers[0].pushMode ?? "sms";
// In verifyTwoFactorCodeAsync — use actual mode instead of hardcoded "sms":
if (phoneNumberId) {
payload.phoneNumber = { id: phoneNumberId };
payload.mode = phoneMode;
}
// In sendRequestTokenToSMSAsync — same fix:
data: { phoneNumber: { id: phoneId }, mode: phoneMode }
```
### Workaround
Use ASC API key environment variables to bypass 2FA entirely:
```sh
EXPO_ASC_API_KEY_PATH=/path/to/AuthKey.p8 \
EXPO_ASC_KEY_ID= \
EXPO_ASC_ISSUER_ID= \
EXPO_APPLE_TEAM_ID= \
eas build --platform ios --profile production --non-interactive
```
### References
- Fastlane had the same bug: fastlane/fastlane#17654
- Fastlane fix: fastlane/fastlane#17666
- Apple's `appleauth/auth/verify/phone/securitycode` endpoint accepts both `"sms"` and `"voice"` as `mode` values
Contributor guide
Assessment
This issue has not been assessed yet.