expo / expo/eas-cli

@expo/apple-utils: 2FA verification fails for accounts with voice-call delivery (pushMode: "voice")

Open
#3,618 4 comments 1 reaction 1 assignee Claimed by @EvanBacon View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.