diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8f9a0e0..3ce9144 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,7 +1,7 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import type { DirectUsbMuxClient } from 'webmuxd';
 import type { AnisetteData } from './anisette-service';
-import type { AppleDeveloperContext } from './apple-signing';
+import type { AppleDeveloperContext, TwoFactorContext } from './apple-signing';
 
 import { Header, type AppPage } from './components/Header';
 import { LoginPage } from './components/LoginPage';
@@ -80,9 +80,13 @@ export function App() {
   const [loginContext, setLoginContext] = useState<AppleDeveloperContext | null>(null);
   const [savedAccounts, setSavedAccounts] = useState<StoredAccountSummary[]>(() => loadStoredAccountList());
   const accountContextMapRef = useRef<Map<string, AppleDeveloperContext>>(new Map());
+  // Version counter so cachedAccountKeys useMemo re-runs when the map changes.
+  // useRef mutations don't trigger re-renders; this state variable does.
+  const [accountCacheVersion, setAccountCacheVersion] = useState(0);
 
   // Login modal
   const [loginModalOpen, setLoginModalOpen] = useState(false);
+  const [loginError, setLoginError] = useState<string | null>(null);
 
   // Device
   const [pairedDeviceInfo, setPairedDeviceInfo] = useState<PairedDeviceInfo | null>(null);
@@ -114,9 +118,12 @@ export function App() {
   const [trustState, setTrustState] = useState<TrustModalState>(TRUST_MODAL_CLOSED);
   const [twoFactor, setTwoFactor] = useState<{
     open: boolean;
-    submit: ((code: string) => void) | null;
+    ctx: TwoFactorContext | null;
     error: string | null;
-  }>({ open: false, submit: null, error: null });
+  }>({ open: false, ctx: null, error: null });
+  // Set to true by handleTwoFactorCancel so handleLogin's catch can distinguish
+  // a user-cancelled 2FA from a genuine verification failure.
+  const twoFactorCancelledRef = useRef(false);
 
   // Derived
   const isWebUsbSupported = useMemo(() => {
@@ -136,7 +143,7 @@ export function App() {
 
   const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null;
 
-  const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]);
+  const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [accountCacheVersion]);
   const trustOpen = trustState !== TRUST_MODAL_CLOSED;
 
   // ---- log + progress plumbing ----
@@ -183,6 +190,7 @@ export function App() {
   useEffect(() => {
     const restored = restorePersistedAccountContexts();
     accountContextMapRef.current = restored;
+    setAccountCacheVersion((v) => v + 1);
     const summary = loadStoredAccountSummary();
     if (summary) {
       const active = restored.get(accountKey(summary.appleId, summary.teamId));
@@ -332,13 +340,17 @@ export function App() {
     }
 
     setBusy((prev) => ({ ...prev, loginSign: true }));
+    twoFactorCancelledRef.current = false;
+    setLoginError(null);
     let twoFactorOpened = false;
     let twoFactorErrorShown = false;
     try {
       saveText(APPLE_ID_STORAGE_KEY, trimmedAppleId);
       addLog('login: initializing anisette...');
 
-      const { anisetteData: nextAnisette } = await ensureAnisetteData(anisetteData, addLog);
+      // Always fetch a fresh anisette OTP for login — the OTP is one-time-use
+      // and the cached value is consumed after any previous attempt (even failed).
+      const { anisetteData: nextAnisette } = await ensureAnisetteData(null, addLog);
       setAnisetteData(nextAnisette);
       setAnisetteProvisioned(true);
       addLog('login: anisette ready, authenticating...');
@@ -348,17 +360,19 @@ export function App() {
         password,
         anisetteData: nextAnisette,
         log: addLog,
-        onTwoFactorRequired: (submit) => {
+        onTwoFactorRequired: (ctx) => {
           twoFactorOpened = true;
-          setTwoFactor({ open: true, submit, error: null });
+          setTwoFactor({ open: true, ctx, error: null });
           addLog('login: 2FA required, opening verification dialog');
         },
       });
 
-      setTwoFactor({ open: false, submit: null, error: null });
+      setTwoFactor({ open: false, ctx: null, error: null });
+      setLoginError(null);
       addLog(`login: authenticated as ${context.appleId} / ${context.team.identifier}`);
       const key = accountKey(context.appleId, context.team.identifier);
       accountContextMapRef.current.set(key, context);
+      setAccountCacheVersion((v) => v + 1);
       persistAccountSummary(context);
       persistAccountSession(context, nextAnisette);
       addLog('login: session persisted');
@@ -371,36 +385,43 @@ export function App() {
       addLog('login: done, navigating to sign page');
       navigateToPage('sign');
     } catch (error) {
+      console.error('[login] caught error:', error);
       const msg = formatError(error);
       addLog(`login failed: ${msg}`);
-      if (twoFactorOpened) {
+      // Only show the error inside the 2FA modal if it's still open and the user
+      // did NOT cancel — a cancellation is intentional, not an error.
+      if (twoFactorOpened && !twoFactorCancelledRef.current) {
         setTwoFactor((prev) => ({ ...prev, error: msg }));
         twoFactorErrorShown = true;
+      } else if (!twoFactorOpened) {
+        // Pre-2FA failure (wrong password, rate limit, etc.) — show in login modal.
+        setLoginError(msg);
       }
     } finally {
       if (!twoFactorErrorShown) {
-        setTwoFactor({ open: false, submit: null, error: null });
+        setTwoFactor({ open: false, ctx: null, error: null });
       }
       setBusy((prev) => ({ ...prev, loginSign: false }));
     }
   }, [addLog, anisetteData, appleId, clearPrepared, navigateToPage, password]);
 
-  const handleTwoFactorSubmit = useCallback(
-    (code: string) => {
-      const submit = twoFactor.submit;
-      if (submit) submit(code);
-    },
-    [twoFactor.submit],
-  );
-
   const handleTwoFactorCancel = useCallback(() => {
-    const submit = twoFactor.submit;
-    setTwoFactor({ open: false, submit: null, error: null });
-    if (submit) {
+    const ctx = twoFactor.ctx;
+    twoFactorCancelledRef.current = true;
+    setTwoFactor({ open: false, ctx: null, error: null });
+    if (ctx) {
       addLog('login: 2FA canceled');
-      submit('__CANCELLED__');
+      // Signal cancellation via the device-code path with a sentinel value.
+      ctx.submitDeviceCode('__CANCELLED__');
     }
-  }, [addLog, twoFactor.submit]);
+  }, [addLog, twoFactor.ctx]);
+
+  const handleTwoFactorRetry = useCallback(() => {
+    // Close the modal (clearing the stale ctx + error) then re-run the full
+    // login flow so the user doesn't have to re-type their password.
+    setTwoFactor({ open: false, ctx: null, error: null });
+    void handleLogin();
+  }, [handleLogin]);
 
   // ---- sign flow ----
   const handleSign = useCallback(async () => {
@@ -534,6 +555,7 @@ export function App() {
       // Remove from session map
       removeStoredAccountSession(summary.appleId, summary.teamId);
       accountContextMapRef.current.delete(key);
+      setAccountCacheVersion((v) => v + 1);
 
       // Remove from account list
       const list = loadStoredAccountList().filter(
@@ -598,7 +620,7 @@ export function App() {
     <main className="min-h-screen bg-bg">
       <Header currentPage={currentPage} onNavigate={navigateToPage} />
 
-      <section className="mx-auto max-w-[760px] px-5 py-10 sm:px-7">
+      <section className="mx-auto max-w-190 px-5 py-10 sm:px-7">
         {currentPage === 'login' ? (
           <LoginPage
             loggedIn={!!loginContext}
@@ -642,14 +664,15 @@ export function App() {
 
       <LoginModal
         open={loginModalOpen}
-        onClose={() => setLoginModalOpen(false)}
+        onClose={() => { setLoginModalOpen(false); setLoginError(null); }}
         appleId={appleId}
         password={password}
         busyLoginSign={busy.loginSign}
         canSubmit={loginCanSubmit}
-        onAppleIdChange={setAppleId}
+        error={loginError}
+        onAppleIdChange={(v) => { setAppleId(v); setLoginError(null); }}
         onAppleIdBlur={handleAppleIdBlur}
-        onPasswordChange={setPassword}
+        onPasswordChange={(v) => { setPassword(v); setLoginError(null); }}
         onSubmit={handleLogin}
       />
 
@@ -668,9 +691,10 @@ export function App() {
       />
       <TwoFactorModal
         open={twoFactor.open}
-        onSubmit={handleTwoFactorSubmit}
+        ctx={twoFactor.ctx}
         onCancel={handleTwoFactorCancel}
         serverError={twoFactor.error}
+        onRetry={handleTwoFactorRetry}
       />
     </main>
   );
diff --git a/frontend/src/apple-signing.ts b/frontend/src/apple-signing.ts
index b9c98a4..3c9bc24 100644
--- a/frontend/src/apple-signing.ts
+++ b/frontend/src/apple-signing.ts
@@ -57,11 +57,29 @@ export interface AppleDeveloperContext {
   devices: Device[];
 }
 
+export interface TrustedPhoneNumber {
+  id: number;
+  numberWithDialCode: string;
+  obfuscatedNumber: string;
+  pushMode: string;
+}
+
+export interface TwoFactorContext {
+  /** Submit a 6-digit code that was pushed to a trusted device. */
+  submitDeviceCode: (code: string) => void;
+  /** Available trusted phone numbers for SMS fallback. Empty when none. */
+  trustedPhoneNumbers: TrustedPhoneNumber[];
+  /** Request an SMS be sent to the given phone id, then call submitSmsCode. */
+  requestSms: (phoneId: number) => Promise<void>;
+  /** Submit the code received via SMS for the given phone id. */
+  submitSmsCode: (phoneId: number, code: string) => Promise<void>;
+}
+
 export interface AppleDeveloperLoginRequest {
   anisetteData: AnisetteData;
   credentials: AppleSigningCredentials;
   onLog?: (message: string) => void;
-  onTwoFactorRequired?: (submitCode: (code: string) => void) => void;
+  onTwoFactorRequired?: (ctx: TwoFactorContext) => void;
 }
 
 export interface AppleSigningWithContextRequest {
@@ -101,8 +119,346 @@ interface AltsignModule {
   }): Promise<{ data: Uint8Array }>;
 }
 
-let appleApiInstance: AppleAPI | null = null;
+/** Minimal type alias for the Fetch instance used in the SMS patch. */
+type AltsignFetch = {
+  get(url: string, headers?: Record<string, string>): Promise<{ text(): Promise<string>; ok: boolean }>;
+  request(
+    method: string,
+    url: string,
+    headers: Record<string, string>,
+    body?: string,
+  ): Promise<{ text(): Promise<string>; ok: boolean }>;
+};
+
 let altsignModulePromise: Promise<AltsignModule> | null = null;
+/** Cached fetch wrapper — the libcurl WASM underneath is expensive to re-init. */
+let appleFetchInstance: AltsignFetch | null = null;
+
+// ---------- Apple GSA SMS 2FA endpoints ----------
+
+const GSA_TRUSTED_DEVICE_URL = 'https://gsa.apple.com/auth/verify/trusteddevice';
+const GSA_VALIDATE_URL = 'https://gsa.apple.com/grandslam/GsService2/validate';
+/** Request an SMS be sent to a phone number (PUT, no `/put` suffix). */
+const GSA_PHONE_URL = 'https://gsa.apple.com/auth/verify/phone';
+const GSA_PHONE_CODE_URL = 'https://gsa.apple.com/auth/verify/phone/securitycode';
+
+/** Replace the private handleTwoFactor method on the auth sub-object so we get
+ *  the trusted-phone list and can drive SMS verification from the UI. */
+function patchHandleTwoFactor(api: AppleAPI, fetch: AltsignFetch): void {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const auth: any = (api as any).auth;
+  if (!auth || typeof auth.handleTwoFactor !== 'function') return;
+
+  auth.handleTwoFactor = async function (
+    dsid: string,
+    idmsToken: string,
+    anisetteData: AnisetteData,
+    verificationHandler: ((ctx: TwoFactorContext) => void) | undefined,
+  ): Promise<boolean> {
+    if (!verificationHandler) return false;
+    try {
+      const identityToken = btoa(`${dsid}:${idmsToken}`);
+      const headers: Record<string, string> = {
+        'Content-Type': 'text/x-xml-plist',
+        'User-Agent': 'Xcode',
+        Accept: 'text/x-xml-plist',
+        'Accept-Language': 'en-us',
+        'X-Apple-App-Info': 'com.apple.gs.xcode.auth',
+        'X-Xcode-Version': '11.2 (11B41)',
+        'X-Apple-Identity-Token': identityToken,
+        'X-Apple-I-MD-M': anisetteData.machineID,
+        'X-Apple-I-MD': anisetteData.oneTimePassword,
+        'X-Apple-I-MD-LU': anisetteData.localUserID,
+        'X-Apple-I-MD-RINFO': String(anisetteData.routingInfo),
+        'X-Mme-Device-Id': anisetteData.deviceUniqueIdentifier,
+        'X-MMe-Client-Info': anisetteData.deviceDescription,
+        'X-Apple-I-Client-Time': auth.formatDate(anisetteData.date),
+        'X-Apple-Locale': anisetteData.locale,
+        'X-Apple-I-TimeZone': anisetteData.timeZone,
+      };
+
+      // Trigger push to trusted devices AND get trusted phone numbers.
+      let trustedPhoneNumbers: TrustedPhoneNumber[] = [];
+      try {
+        const deviceResp = await fetch.get(GSA_TRUSTED_DEVICE_URL, headers);
+        const deviceText = await deviceResp.text();
+        trustedPhoneNumbers = parseTrustedPhones(deviceText);
+        console.log('[2FA] parsed trustedPhoneNumbers:', trustedPhoneNumbers);
+        if (trustedPhoneNumbers.length === 0) {
+          // Log key fragments to diagnose the response format
+          const snippets = [
+            deviceText.match(/"trustedPhoneNumbers"\s*:\s*(\[[\s\S]{0,300}?\])/)?.[1],
+            deviceText.match(/phoneNumbers[\s\S]{0,200}/)?.[0],
+            // Apple auth HTML embeds data in various script tags
+            deviceText.match(/window\.AUTH_INIT_DATA\s*=\s*({[\s\S]{0,400}?});/)?.[1],
+            deviceText.match(/var\s+bootstrap\s*=\s*({[\s\S]{0,400}?});/)?.[1],
+            deviceText.match(/<script[^>]*id="boot_args"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
+            deviceText.match(/<script[^>]*type="text\/x-apple-plist"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
+            deviceText.match(/<script[^>]*type="application\/json"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
+          ].filter(Boolean);
+          console.log('[2FA] phone fragments:', snippets);
+        }
+      } catch (e) {
+        console.warn('[2FA] trusteddevice request failed (non-fatal):', e);
+      }
+
+      return await new Promise<boolean>((resolve) => {
+        const jsonHeaders = {
+          ...headers,
+          'Content-Type': 'application/json',
+          Accept: 'application/json',
+        };
+
+        const submitDeviceCode = async (code: string) => {
+          const trimmed = code.trim();
+          // '__CANCELLED__' sentinel: user dismissed the modal — skip network request.
+          if (!trimmed || trimmed === '__CANCELLED__') { resolve(false); return; }
+          try {
+            const verifyHeaders = { ...headers, 'security-code': trimmed };
+            const resp = await fetch.get(GSA_VALIDATE_URL, verifyHeaders);
+            const text = await resp.text();
+            const plist = parsePlistSimple(text);
+            resolve((plist as Record<string, unknown>)['ec'] === 0);
+          } catch { resolve(false); }
+        };
+
+        const requestSms = async (phoneId: number): Promise<void> => {
+          const body = JSON.stringify({ phoneNumber: { id: phoneId }, mode: 'sms' });
+          console.log('[2FA] requestSms → PUT', GSA_PHONE_URL, 'phoneId:', phoneId);
+          const resp = await fetch.request('PUT', GSA_PHONE_URL, jsonHeaders, body);
+          const respText = await resp.text();
+          console.log('[2FA] requestSms response ok:', resp.ok, 'body:', respText.slice(0, 200));
+          if (!resp.ok) throw new Error(`Failed to send SMS (${respText.slice(0, 100)})`);
+        };
+
+        const submitSmsCode = async (phoneId: number, code: string): Promise<void> => {
+          const body = JSON.stringify({
+            phoneNumber: { id: phoneId },
+            securityCode: { code: code.trim() },
+            mode: 'sms',
+          });
+          const resp = await fetch.request('POST', GSA_PHONE_CODE_URL, jsonHeaders, body);
+          const text = await resp.text();
+          console.log('[2FA] submitSmsCode response ok:', resp.ok, 'body:', text.slice(0, 200));
+          let ok = resp.ok;
+          try {
+            const parsed = JSON.parse(text) as Record<string, unknown>;
+            if (parsed['ec'] !== undefined) ok = parsed['ec'] === 0;
+          } catch { /* plist fallback */ }
+          if (!ok) throw new Error('SMS verification failed');
+          // SMS 2FA is complete — resolve(true) so altsign.js retries authenticate()
+          // with fresh anisette (handled by patchAuthenticateRetry).
+          resolve(true);
+        };
+
+        try {
+          verificationHandler({ submitDeviceCode, trustedPhoneNumbers, requestSms, submitSmsCode });
+        } catch { resolve(false); }
+      });
+    } catch {
+      return false;
+    }
+  };
+}
+
+/** patchAuthenticateRetry: on the post-2FA re-call, swap in a fresh anisette OTP
+ *  so the SRP exchange doesn't crash on a stale one-time password.
+ *
+ *  patchSendAuthRequest: Apple may return au:"secondaryAuth" instead of
+ *  au:"trustedDeviceSecondaryAuth" for accounts without a trusted device (SMS-only
+ *  2FA). altsign.js only checks for the latter, so it silently falls through to
+ *  fetchAuthToken which then crashes with atob(undefined). We normalize the value
+ *  so altsign.js's existing 2FA path fires correctly.
+ *
+ *  For accounts where Apple signals 2FA ONLY at the apptokens stage (no au in the
+ *  SRP complete response), we intercept the empty apptokens response, trigger 2FA,
+ *  then throw a sentinel so the authenticate wrapper performs a full fresh SRP
+ *  exchange (Apple invalidates the SRP session after 2FA at this stage). */
+const REAUTH_SENTINEL = '__SIDEIMPACTOR_NEEDS_FULL_REAUTH__';
+
+function patchAuthenticateRetry(api: AppleAPI, _apiFetch: AltsignFetch): void {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const auth: any = (api as any).auth;
+  if (!auth || typeof auth.authenticate !== 'function') return;
+
+  // Store the verificationHandler from the current authenticate() call so the
+  // sendAuthRequest patch can forward it to handleTwoFactor when needed.
+  let storedVerificationHandler: ((ctx: TwoFactorContext) => void) | undefined;
+  // After 2FA completes, mark it done so we don't re-trigger if the second
+  // apptokens attempt also returns empty (shouldn't happen, but guards loops).
+  let twoFactorDone = false;
+
+  // --- patch sendAuthRequest ---
+  if (typeof auth.sendAuthRequest === 'function') {
+    const origSend: (...a: unknown[]) => Promise<Record<string, unknown>> =
+      auth.sendAuthRequest.bind(auth);
+    auth.sendAuthRequest = async function (...args: unknown[]): Promise<Record<string, unknown>> {
+      const params = args[0] as Record<string, unknown>;
+      const anisetteArg = args[1] as AnisetteData;
+
+      // Wrap origSend so we can intercept apptokens 2FA signals.
+      // Apple uses two different conventions to signal "2FA required at apptokens":
+      //   (a) Older: returns a non-plist / empty body → parsePlist yields {} (no Status)
+      //   (b) Newer: returns ec=-22421 "This action could not be completed. Try again."
+      //       which altsign.js converts into a thrown Error before returning to us.
+      // We catch case (b) here and fall through to the shared 2FA-gate logic below.
+      let response: Record<string, unknown>;
+      try {
+        response = await origSend(...args);
+      } catch (e) {
+        const isApptokens = params?.['o'] === 'apptokens';
+        const is22421 = e instanceof Error && e.message.includes('(-22421)');
+        if (isApptokens && is22421 && !twoFactorDone) {
+          console.log('[auth] apptokens threw -22421 — treating as 2FA gate (Apple new-style signal)');
+          response = {}; // fall through to 2FA handling below
+        } else {
+          throw e;
+        }
+      }
+
+      const status = response['Status'] as Record<string, unknown> | undefined;
+      const au = status?.['au'];
+      console.log('[auth] sendAuthRequest o=%s au=%s keys=%o', params?.['o'], au, Object.keys(response));
+
+      // Normalize legacy au value
+      if (au === 'secondaryAuth') {
+        console.log('[auth] normalizing au: secondaryAuth → trustedDeviceSecondaryAuth');
+        (status as Record<string, unknown>)['au'] = 'trustedDeviceSecondaryAuth';
+        return response;
+      }
+
+      // Detect 2FA required at apptokens stage.
+      // Covers both empty response (case a) and the -22421 catch above (case b).
+      if (params?.['o'] === 'apptokens' && Object.keys(response).length === 0) {
+        if (twoFactorDone) {
+          // 2FA was already completed — this is a genuine failure, not a gate.
+          console.error('[auth] apptokens still empty/erroring after 2FA — authentication failed');
+          return response;
+        }
+        console.log('[auth] apptokens 2FA gate detected — triggering verification');
+        const adsid = params['u'] as string;
+        const idmsToken = params['t'] as string;
+        if (adsid && idmsToken && storedVerificationHandler) {
+          const success = await auth.handleTwoFactor(
+            adsid, idmsToken, anisetteArg, storedVerificationHandler,
+          );
+          if (success) {
+            // Apple invalidates the SRP session after 2FA at this stage.
+            // Throw a sentinel so loginAppleDeveloperAccount starts a fresh SRP exchange.
+            console.log('[auth] 2FA done — signalling full re-authentication');
+            twoFactorDone = true;
+            throw new Error(REAUTH_SENTINEL);
+          }
+        }
+        console.error('[auth] apptokens 2FA failed or no handler available');
+      }
+
+      return response;
+    };
+  } else {
+    console.warn('[auth] sendAuthRequest not found on auth — patch skipped');
+  }
+
+  // --- patch authenticate ---
+  // Responsibilities:
+  //   1. Capture verificationHandler so sendAuthRequest can pass it to handleTwoFactor.
+  //   2. On post-2FA retry (callCount > 1), swap in fresh anisette.
+  //   3. If REAUTH_SENTINEL is thrown from the apptokens path, re-throw it unchanged
+  //      so loginAppleDeveloperAccount can catch it and do a clean fresh login with a
+  //      new AppleAPI instance (avoids state-pollution in the current auth object).
+  const original: (...args: unknown[]) => Promise<unknown> = auth.authenticate.bind(auth);
+  let callCount = 0;
+
+  auth.authenticate = async function (...args: unknown[]): Promise<unknown> {
+    callCount++;
+    storedVerificationHandler = args[3] as ((ctx: TwoFactorContext) => void) | undefined;
+    console.log(`[auth] authenticate call #${callCount}`);
+
+    // Always use fresh anisette on any retry call (callCount > 1).
+    if (callCount > 1) {
+      try {
+        const { getAnisetteData } = await import('./anisette-service');
+        const freshAnisette = await getAnisetteData();
+        args = [...args];
+        args[2] = freshAnisette;
+        console.log('[auth] post-2FA retry: swapped in fresh anisette OTP');
+      } catch (e) {
+        console.warn('[auth] post-2FA retry: could not fetch fresh anisette, proceeding with original', e);
+      }
+    }
+
+    try {
+      return await original(...args);
+    } catch (e) {
+      const msg = e instanceof Error ? e.message : String(e);
+      // Bubble the sentinel unchanged — loginAppleDeveloperAccount handles it
+      // by creating a fresh AppleAPI instance for a clean re-auth.
+      if (msg === REAUTH_SENTINEL) {
+        throw e;
+      }
+      console.error(`[auth] authenticate call #${callCount} threw:`, e);
+      throw e;
+    }
+  };
+}
+
+/** Extract `ec` from an Apple GSA response (JSON or XML plist). */
+function parsePlistSimple(text: string): unknown {
+  const trimmed = text.trim();
+  if (trimmed.startsWith('{')) {
+    try { return JSON.parse(trimmed); } catch { /* fall through */ }
+  }
+  // XML plist — reuse the file's own parser
+  return parseXmlPlist(trimmed);
+}
+
+function parseTrustedPhones(text: string): TrustedPhoneNumber[] {
+  const trimmed = text.trim();
+
+  // Path 1: plain JSON response  { "trustedPhoneNumbers": [...] }
+  if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
+    try {
+      const data = JSON.parse(trimmed) as Record<string, unknown>;
+      const phones = data['trustedPhoneNumbers'];
+      if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
+    } catch { /* fall through */ }
+  }
+
+  // Path 2: Apple Auth HTML — phone numbers are in
+  //   <script type="application/json" class="boot_args">{ "direct": { "trustedDeviceVerification": { "phoneNumberVerification": { "trustedPhoneNumbers": [...] } } } }</script>
+  const bootArgsMatch = trimmed.match(
+    /<script[^>]+class="boot_args"[^>]*>([\s\S]*?)<\/script>/i,
+  );
+  if (bootArgsMatch) {
+    try {
+      const boot = JSON.parse(bootArgsMatch[1]) as Record<string, unknown>;
+      const tdv = (boot['direct'] as Record<string, unknown> | undefined)?.['trustedDeviceVerification'] as Record<string, unknown> | undefined;
+      const pnv = tdv?.['phoneNumberVerification'] as Record<string, unknown> | undefined;
+      const phones = pnv?.['trustedPhoneNumbers'];
+      if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
+    } catch { /* fall through */ }
+  }
+
+  // Path 3: XML plist (older Apple endpoints)
+  if (trimmed.startsWith('<?xml') || trimmed.startsWith('<plist')) {
+    const data = parseXmlPlist(trimmed);
+    if (data && typeof data === 'object' && !Array.isArray(data)) {
+      const phones = (data as Record<string, unknown>)['trustedPhoneNumbers'];
+      if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
+    }
+  }
+
+  return [];
+}
+
+function mapPhones(raw: Record<string, unknown>[]): TrustedPhoneNumber[] {
+  return raw.map((p) => ({
+    id: typeof p['id'] === 'number' ? p['id'] : Number(p['id']),
+    numberWithDialCode: typeof p['numberWithDialCode'] === 'string' ? p['numberWithDialCode'] : '',
+    obfuscatedNumber: typeof p['obfuscatedNumber'] === 'string' ? p['obfuscatedNumber'] : '',
+    pushMode: typeof p['pushMode'] === 'string' ? p['pushMode'] : 'sms',
+  }));
+}
 
 /**
  * Keep `altsign.js` and its transitive `zsign-wasm` bundle out of the initial
@@ -117,26 +473,65 @@ async function loadAltsignModule(): Promise<AltsignModule> {
   return await altsignModulePromise;
 }
 
-async function getAppleApi(): Promise<AppleAPI> {
-  if (appleApiInstance) {
-    return appleApiInstance;
-  }
+async function getAppleApi(log?: (msg: string) => void): Promise<{ api: AppleAPI; fetch: AltsignFetch }> {
+  const uiLog = log ?? (() => undefined);
   const { AppleAPI, Fetch } = await loadAltsignModule();
-  const appleFetch = new Fetch(initLibcurl, async (url, options) => {
-    const libcurl = requireLibcurl();
-    const response = await libcurl.fetch(url, {
-      method: options.method,
-      headers: options.headers,
-      body: options.body,
-      redirect: 'manual',
-      insecure: true,
-      verbose: 4,
-      _libcurl_http_version: 1.1,
-    } as never);
-    return response;
-  });
-  appleApiInstance = new AppleAPI(appleFetch);
-  return appleApiInstance;
+  if (!appleFetchInstance) {
+    const doFetch = async (url: string, options: {
+      method?: string;
+      headers?: HeadersInit;
+      body?: BodyInit | null;
+    }): Promise<Response> => {
+      const libcurl = requireLibcurl();
+      const libcurlOpts = {
+        method: options.method,
+        headers: options.headers,
+        body: options.body,
+        redirect: 'manual',
+        insecure: true,
+        verbose: 4,
+        _libcurl_http_version: 1.1,
+      } as never;
+      let response = await libcurl.fetch(url, libcurlOpts) as Response;
+      // Auto-retry on Apple GSA 503 (transient rate-limit / overload).
+      // Use exponential backoff: 5s, 10s, 20s, 40s — up to ~75s total.
+      if (url.includes('gsa.apple.com') && response.status === 503) {
+        const delays = [5000, 10000, 20000, 40000];
+        for (let attempt = 0; attempt < delays.length; attempt++) {
+          const waitSec = delays[attempt] / 1000;
+          const msg = `login: Apple server busy (503) — retrying in ${waitSec}s (${attempt + 1}/${delays.length})...`;
+          console.warn(`[fetch] ${msg}`);
+          uiLog(msg);
+          await new Promise((r) => setTimeout(r, delays[attempt]));
+          response = await libcurl.fetch(url, libcurlOpts) as Response;
+          if (response.status !== 503) break;
+        }
+        if (response.status === 503) {
+          throw new Error('Apple authentication server is temporarily unavailable (503). Please wait a few minutes and try again.');
+        }
+      }
+      // Log raw response body for Apple auth endpoint calls (diagnosis aid).
+      if (url.includes('gsa.apple.com')) {
+        const origText = response.text.bind(response);
+        response.text = async () => {
+          const text = await origText();
+          console.log('[fetch] raw text from %s status=%d (%d chars): %s',
+            url.split('?')[0], response.status, text.length, text.slice(0, 300));
+          return text;
+        };
+      }
+      return response;
+    };
+    const f = new Fetch(initLibcurl, doFetch);
+    appleFetchInstance = f as AltsignFetch;
+  }
+  // Always create a fresh AppleAPI per call — it holds no expensive state,
+  // but reusing it across separate authenticate() calls causes -22421 because
+  // the anisette OTP embedded in the previous call is already consumed.
+  const api = new AppleAPI(appleFetchInstance);
+  patchHandleTwoFactor(api, appleFetchInstance);
+  patchAuthenticateRetry(api, appleFetchInstance);
+  return { api, fetch: appleFetchInstance };
 }
 
 export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginRequest): Promise<AppleDeveloperContext> {
@@ -149,24 +544,46 @@ export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginReq
   const log = request.onLog ?? (() => undefined);
   log(`Login stage: authenticating Apple account ${maskEmail(appleId)}...`);
 
-  const api = await getAppleApi();
-  const { session } = await api.authenticate(
-    appleId,
-    password,
-    request.anisetteData,
-    (submitCode: (code: string) => void) => {
-      if (!request.onTwoFactorRequired) {
-        throw new Error('2FA required but no in-page handler provided');
-      }
-      request.onTwoFactorRequired((code) => {
-        const normalized = code.trim();
-        if (normalized.length === 0) {
-          throw new Error('2FA code is required');
-        }
-        submitCode(normalized);
-      });
-    },
-  );
+  // Reset the shared fetch instance so each login attempt starts with a fresh
+  // libcurl TCP connection — prevents state bleed between accounts and between
+  // a cancelled-then-retried login on the same account.
+  appleFetchInstance = null;
+
+  const verificationCallback = (submitCode: (code: string) => void) => {
+    if (!request.onTwoFactorRequired) {
+      throw new Error('2FA required but no in-page handler provided');
+    }
+    // The patched handleTwoFactor passes a TwoFactorContext; the raw altsign.js
+    // VerificationHandler signature still receives `submitCode` as first arg,
+    // but our patch replaces that with the full context object.
+    const ctx = submitCode as unknown as TwoFactorContext;
+    request.onTwoFactorRequired(ctx);
+  };
+
+  let { api } = await getAppleApi(log);
+
+  let session: AppleDeveloperSession;
+  try {
+    ({ session } = await api.authenticate(appleId, password, request.anisetteData, verificationCallback));
+  } catch (e) {
+    // After 2FA at the apptokens stage, Apple invalidates the SRP session.
+    // The sendAuthRequest patch throws REAUTH_SENTINEL to signal this.
+    // We create a completely fresh AppleAPI (new auth state, new patches) and
+    // retry the full SRP exchange with fresh anisette.
+    if (e instanceof Error && e.message === REAUTH_SENTINEL) {
+      log('Login stage: 2FA verified — restarting SRP with fresh session...');
+      // Reset the shared fetch instance so the re-auth uses a fresh libcurl
+      // TCP connection to Apple — eliminates connection-state as a failure cause.
+      appleFetchInstance = null;
+      const fresh = await getAppleApi(log);
+      api = fresh.api;
+      const { getAnisetteData } = await import('./anisette-service');
+      const freshAnisette = await getAnisetteData();
+      ({ session } = await api.authenticate(appleId, password, freshAnisette, verificationCallback));
+    } else {
+      throw e;
+    }
+  }
 
   log('Login stage: fetching team/certificates/devices...');
   const team = await api.fetchTeam(session);
@@ -191,7 +608,7 @@ export async function refreshAppleDeveloperContext(
   onLog?: (message: string) => void,
 ): Promise<AppleDeveloperContext> {
   const log = onLog ?? (() => undefined);
-  const api = await getAppleApi();
+  const { api } = await getAppleApi();
   log('Signing stage: refreshing team/certificates/devices...');
   const team = await api.fetchTeam(context.session);
   const [certificates, devices] = await Promise.all([
@@ -234,7 +651,7 @@ export async function signIpaWithAppleContext(request: AppleSigningWithContextRe
     throw new Error('Cannot sign IPA: bundle identifier is missing');
   }
 
-  const api = await getAppleApi();
+  const { api } = await getAppleApi();
   const team = context.team;
   onLog(`Signing stage: using team ${team.identifier} (${team.name}).`);
 
diff --git a/frontend/src/components/LoginModal.tsx b/frontend/src/components/LoginModal.tsx
index 7348eda..0b54926 100644
--- a/frontend/src/components/LoginModal.tsx
+++ b/frontend/src/components/LoginModal.tsx
@@ -9,6 +9,7 @@ interface LoginModalProps {
   password: string;
   busyLoginSign: boolean;
   canSubmit: boolean;
+  error?: string | null;
   onAppleIdChange: (value: string) => void;
   onAppleIdBlur: () => void;
   onPasswordChange: (value: string) => void;
@@ -22,6 +23,7 @@ export function LoginModal({
   password,
   busyLoginSign,
   canSubmit,
+  error,
   onAppleIdChange,
   onAppleIdBlur,
   onPasswordChange,
@@ -31,7 +33,7 @@ export function LoginModal({
 
   return (
     <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 backdrop-blur-sm p-4 pt-[8vh]">
-      <div className="w-full max-w-[440px] rounded-2xl border border-border bg-bg p-6 shadow-2xl anim-in">
+      <div className="w-full max-w-110 rounded-2xl border border-border bg-bg p-6 shadow-2xl anim-in">
         <div className="mb-5 flex items-center justify-between">
           <h2 className="text-[18px] font-semibold tracking-tight text-ink">Add Account</h2>
           <button
@@ -83,22 +85,29 @@ export function LoginModal({
           <p className="text-[11.5px] text-muted">
             Your credentials are stored locally in this browser and are sent directly to Apple.
           </p>
-          <p className="text-[11.5px] text-[var(--color-danger)] underline underline-offset-2 decoration-[var(--color-danger)]/40">
+          <p className="text-[11.5px] text-danger underline underline-offset-2 decoration-danger/40">
             Verify that you trust the server hosting this page. A compromised server can intercept your credentials.
           </p>
         </div>
 
-        <div className="mt-5 flex justify-end">
-          <Button
-            variant="primary"
-            busy={busyLoginSign}
-            busyLabel="Signing In…"
-            disabled={!canSubmit}
-            onClick={onSubmit}
-            className="min-w-[140px]"
-          >
-            Sign In
-          </Button>
+        <div className="mt-5 flex flex-col gap-3">
+          {error && (
+            <p className="rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[12.5px] text-danger">
+              {error}
+            </p>
+          )}
+          <div className="flex justify-end">
+            <Button
+              variant="primary"
+              busy={busyLoginSign}
+              busyLabel="Signing In…"
+              disabled={!canSubmit}
+              onClick={onSubmit}
+              className="min-w-35"
+            >
+              Sign In
+            </Button>
+          </div>
         </div>
       </div>
     </div>
diff --git a/frontend/src/components/TwoFactorModal.tsx b/frontend/src/components/TwoFactorModal.tsx
index da36943..eaa1a28 100644
--- a/frontend/src/components/TwoFactorModal.tsx
+++ b/frontend/src/components/TwoFactorModal.tsx
@@ -1,40 +1,122 @@
 import { useEffect, useRef, useState } from 'react';
 import { Modal } from './ui/Modal';
 import { Button } from './ui/Button';
+import type { TrustedPhoneNumber, TwoFactorContext } from '../apple-signing';
+
+type Mode = 'device' | 'sms';
 
 interface TwoFactorModalProps {
   open: boolean;
-  onSubmit: (code: string) => void;
+  ctx: TwoFactorContext | null;
   onCancel: () => void;
-  /** Server-side error (e.g. wrong code). Shown when the login flow rejects after 2FA submit. */
+  /** Server-side error passed back from the login flow after a failed verify. */
   serverError?: string | null;
+  /** Called when the user wants to retry the entire login after a server error. */
+  onRetry?: () => void;
 }
 
-export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFactorModalProps) {
+export function TwoFactorModal({ open, ctx, onCancel, serverError, onRetry }: TwoFactorModalProps) {
+  const [mode, setMode] = useState<Mode>('device');
+  const [selectedPhone, setSelectedPhone] = useState<TrustedPhoneNumber | null>(null);
   const [code, setCode] = useState('');
-  const [error, setError] = useState<string | null>(null);
+  const [localError, setLocalError] = useState<string | null>(null);
+  const [smsSent, setSmsSent] = useState(false);
+  const [smsBusy, setSmsBusy] = useState(false);
+  const [verifyBusy, setVerifyBusy] = useState(false);
   const inputRef = useRef<HTMLInputElement>(null);
-  const displayError = serverError || error;
 
+  const displayError = serverError || localError;
+  const phones = ctx?.trustedPhoneNumbers ?? [];
+
+  // Reset state on open/close
   useEffect(() => {
     if (open) {
+      setMode('device');
       setCode('');
-      setError(null);
+      setLocalError(null);
+      setSmsSent(false);
+      setSmsBusy(false);
+      setVerifyBusy(false);
+      setSelectedPhone(phones.length > 0 ? phones[0] : null);
       const timer = window.setTimeout(() => {
         inputRef.current?.focus();
         inputRef.current?.select();
       }, 0);
       return () => window.clearTimeout(timer);
     }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [open]);
 
-  const handleSubmit = () => {
+  // Focus input when switching modes or after SMS sent
+  useEffect(() => {
+    if (open && (mode === 'device' || smsSent)) {
+      const timer = window.setTimeout(() => inputRef.current?.focus(), 50);
+      return () => window.clearTimeout(timer);
+    }
+  }, [mode, smsSent, open]);
+
+  const switchToSms = () => {
+    setMode('sms');
+    setCode('');
+    setLocalError(null);
+    setSmsSent(false);
+  };
+
+  const switchToDevice = () => {
+    setMode('device');
+    setCode('');
+    setLocalError(null);
+  };
+
+  const handleRequestSms = async () => {
+    if (!ctx || !selectedPhone) return;
+    setSmsBusy(true);
+    setLocalError(null);
+    try {
+      await ctx.requestSms(selectedPhone.id);
+      setSmsSent(true);
+    } catch (err) {
+      setLocalError(err instanceof Error ? err.message : 'Failed to send SMS');
+    } finally {
+      setSmsBusy(false);
+    }
+  };
+
+  const handleSubmit = async () => {
     const trimmed = code.trim();
     if (trimmed.length === 0) {
-      setError('Please enter verification code.');
+      setLocalError('Please enter the verification code.');
       return;
     }
-    onSubmit(trimmed);
+    if (!ctx) return;
+
+    setVerifyBusy(true);
+    setLocalError(null);
+    try {
+      if (mode === 'device') {
+        ctx.submitDeviceCode(trimmed);
+        // submitDeviceCode resolves the outer Promise — no async result here
+      } else {
+        if (!selectedPhone) return;
+        await ctx.submitSmsCode(selectedPhone.id, trimmed);
+      }
+    } catch (err) {
+      setLocalError(err instanceof Error ? err.message : 'Verification failed');
+      setVerifyBusy(false);
+    }
+    // On success the parent modal will close; don't clear busy so button stays
+    // disabled until the modal is removed.
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      if (mode === 'sms' && !smsSent) {
+        void handleRequestSms();
+      } else {
+        void handleSubmit();
+      }
+    }
   };
 
   return (
@@ -42,44 +124,152 @@ export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFac
       <h2 id="two-factor-title" className="text-[16px] font-semibold tracking-tight text-ink">
         Two-Factor Authentication
       </h2>
-      <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
-        Enter the verification code from your trusted Apple device.
-      </p>
-
-      <label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
-        Verification Code
-      </label>
-      <input
-        ref={inputRef}
-        id="two-factor-code"
-        type="text"
-        inputMode="numeric"
-        autoComplete="one-time-code"
-        maxLength={8}
-        placeholder="123456"
-        className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
-        value={code}
-        onChange={(e) => {
-          setCode(e.target.value);
-          if (error) setError(null);
-        }}
-        onKeyDown={(e) => {
-          if (e.key === 'Enter') {
-            e.preventDefault();
-            handleSubmit();
-          }
-        }}
-      />
-      <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
-
-      <div className="mt-4 grid grid-cols-2 gap-2">
-        <Button variant="ghost" onClick={onCancel}>
-          Cancel
-        </Button>
-        <Button variant="primary" onClick={handleSubmit} disabled={!!serverError}>
-          Verify
-        </Button>
-      </div>
+
+      {mode === 'device' ? (
+        <>
+          <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
+            Enter the verification code from your trusted Apple device or Mac.
+          </p>
+
+          <label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
+            Verification Code
+          </label>
+          <input
+            ref={inputRef}
+            id="two-factor-code"
+            type="text"
+            inputMode="numeric"
+            autoComplete="one-time-code"
+            maxLength={8}
+            placeholder="123456"
+            className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
+            value={code}
+            onChange={(e) => { setCode(e.target.value); setLocalError(null); }}
+            onKeyDown={handleKeyDown}
+          />
+          <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
+
+          <div className="mt-4 grid grid-cols-2 gap-2">
+            <Button variant="ghost" onClick={onCancel}>Cancel</Button>
+            {serverError ? (
+              <Button variant="primary" onClick={onRetry}>
+                Retry Login
+              </Button>
+            ) : (
+              <Button
+                variant="primary"
+                busy={verifyBusy}
+                busyLabel="Verifying…"
+                onClick={() => void handleSubmit()}
+                disabled={verifyBusy}
+              >
+                Verify
+              </Button>
+            )}
+          </div>
+
+          {phones.length > 0 && (
+            <button
+              type="button"
+              onClick={switchToSms}
+              className="mt-3 w-full text-center text-[12px] text-muted underline underline-offset-2 hover:text-ink transition-colors"
+            >
+              Get SMS instead →
+            </button>
+          )}
+        </>
+      ) : (
+        <>
+          <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
+            {smsSent
+              ? 'Enter the code sent via SMS.'
+              : 'Choose a phone number to receive a verification code.'}
+          </p>
+
+          {/* Phone picker */}
+          {!smsSent && phones.length > 1 && (
+            <div className="mt-4 space-y-1">
+              {phones.map((phone) => (
+                <button
+                  key={phone.id}
+                  type="button"
+                  onClick={() => setSelectedPhone(phone)}
+                  className={`w-full rounded-lg border px-3 py-2 text-left text-[13px] transition-colors ${
+                    selectedPhone?.id === phone.id
+                      ? 'border-blue-500 bg-blue-500/10 text-ink'
+                      : 'border-border text-muted hover:border-blue-400 hover:text-ink'
+                  }`}
+                >
+                  {phone.numberWithDialCode || phone.obfuscatedNumber}
+                </button>
+              ))}
+            </div>
+          )}
+
+          {!smsSent && phones.length === 1 && (
+            <p className="mt-3 rounded-lg border border-border px-3 py-2 text-[13px] text-muted">
+              {phones[0].numberWithDialCode || phones[0].obfuscatedNumber}
+            </p>
+          )}
+
+          <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{localError ?? ''}</p>
+
+          {!smsSent ? (
+            <div className="mt-2 grid grid-cols-2 gap-2">
+              <Button variant="ghost" onClick={switchToDevice}>Back</Button>
+              <Button
+                variant="primary"
+                busy={smsBusy}
+                busyLabel="Sending…"
+                disabled={!selectedPhone || smsBusy}
+                onClick={() => void handleRequestSms()}
+              >
+                Send SMS
+              </Button>
+            </div>
+          ) : (
+            <>
+              <label htmlFor="sms-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
+                SMS Code
+              </label>
+              <input
+                ref={inputRef}
+                id="sms-code"
+                type="text"
+                inputMode="numeric"
+                autoComplete="one-time-code"
+                maxLength={8}
+                placeholder="123456"
+                className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
+                value={code}
+                onChange={(e) => { setCode(e.target.value); setLocalError(null); }}
+                onKeyDown={handleKeyDown}
+              />
+              <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
+              <div className="mt-4 grid grid-cols-2 gap-2">
+                <Button variant="ghost" onClick={() => { setSmsSent(false); setCode(''); }}>
+                  Resend
+                </Button>
+                {serverError ? (
+                  <Button variant="primary" onClick={onRetry}>
+                    Retry Login
+                  </Button>
+                ) : (
+                  <Button
+                    variant="primary"
+                    busy={verifyBusy}
+                    busyLabel="Verifying…"
+                    onClick={() => void handleSubmit()}
+                    disabled={verifyBusy}
+                  >
+                    Verify
+                  </Button>
+                )}
+              </div>
+            </>
+          )}
+        </>
+      )}
     </Modal>
   );
 }
diff --git a/frontend/src/flows/login.ts b/frontend/src/flows/login.ts
index f70177e..306787b 100644
--- a/frontend/src/flows/login.ts
+++ b/frontend/src/flows/login.ts
@@ -1,5 +1,5 @@
 import type { AnisetteData } from '../anisette-service';
-import type { AppleDeveloperContext } from '../apple-signing';
+import type { AppleDeveloperContext, TwoFactorContext } from '../apple-signing';
 import { shortToken } from '../lib/ids';
 
 type AnisetteService = typeof import('../anisette-service');
@@ -61,7 +61,7 @@ export interface LoginRequest {
   password: string;
   anisetteData: AnisetteData;
   log: (msg: string) => void;
-  onTwoFactorRequired: (submit: (code: string) => void) => void;
+  onTwoFactorRequired: (ctx: TwoFactorContext) => void;
 }
 
 export async function loginAccount(req: LoginRequest): Promise<AppleDeveloperContext> {
