Skip to content

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

@2bTwist

Description

@2bTwist

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"

// 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:

// 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:

EXPO_ASC_API_KEY_PATH=/path/to/AuthKey.p8 \
EXPO_ASC_KEY_ID=<key-id> \
EXPO_ASC_ISSUER_ID=<issuer-id> \
EXPO_APPLE_TEAM_ID=<team-id> \
eas build --platform ios --profile production --non-interactive

References

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions