Open any iOS crypto wallet’s onboarding and watch the recovery-phrase flow. The wallet generates 12 or 24 words, shows them on screen, and asks the user to tap “I’ve written it down”. The user taps. The wallet trusts the tap. The wallet provisions the account. Six months later the user reinstalls the app on a new phone, types in what they remember of the phrase, and the wallet rejects it because words 7, 11, and 19 are wrong. The user has lost access to their funds.
This is the most common wallet support ticket on iOS. The user did not write down the phrase. Or they wrote down the wrong words. Or they wrote it down on a piece of paper that is now lost. The wallet’s onboarding flow trusted a “yes I did it” tap that has near-zero information content; the user’s confidence and their actual state diverged silently.
This post is the engineering pattern that closes that gap: BIP-39 verification challenges. The pattern is mechanically simple but the implementation has enough sharp edges that most wallets get one or more of them wrong, leaving the same support-ticket flow in place even after they think they’ve added “verification”.
The threat the verification catches
The threat is not malicious. The threat is the user themselves, six months later. Specifically:
- The user did not write the phrase down (the most common case).
- The user wrote it down but transposed two words.
- The user wrote it down but substituted a similar word (e.g.
arrowforarrest). - The user wrote it down on the second device’s clipboard, which is gone now.
- The user wrote it down in iCloud Notes, defeating the entire point.
All five are caught by the same verification primitive: at enrollment time, the wallet asks the user to re-enter a subset of the words, in order, immediately after showing them. If the user can re-enter words 3, 7, 11, 16, and 22 of a 24-word phrase, they have looked at the phrase carefully enough to plausibly have written it down correctly. The verification does not prove the user wrote it down on paper; it proves the user is capable of reading back what they were shown, which is the lower bound on having recorded it.
The verification primitive
BIP-39’s structure makes the verification cheap. The phrase is 12 / 15 / 18 / 21 / 24 words from a 2048-word list, with a SHA-256 checksum baked into the last word. The verification flow is:
- Generate the mnemonic (using
SecRandomCopyBytesfor entropy, neverarc4random). - Display the full phrase on the protected view (see iOS screen-capture protection for the rendering surface).
- Pick N indices to challenge, typically 3 of 12 or 5 of 24. The indices are non-trivial: don’t pick word 1 (always boring), don’t pick consecutive indices (the user could derive from context), don’t pick indices the user can reverse-engineer.
- Present a quiz UI: “Word #7?” with a text field and the keyboard. The user types the word.
- Validate against the wordlist (the word is in BIP-39), against the position (matches index 7 of the original phrase), and against typo classes (homoglyph substitutions like
arrow↔arrive). - If any challenge fails, do not let the user proceed. Show the full phrase again, ask them to write it down properly, and re-challenge.
InputGuard’s MnemonicVerificationChallenge ships this primitive with sensible defaults (3-of-12 or 5-of-24, no consecutive indices, homoglyph-aware validation); it targets the seed-entry failure modes catalogd in the InputGuard threat model. The hard parts are not the cryptography, they are the UX choices that determine whether the verification catches the failure modes above or just adds friction.
Five UX choices that determine whether verification works
Choice 1, Use the in-app SecureKeyboard, not the system keyboard. If the user can use autocorrect during the verification, they can autocomplete words they didn’t actually write down. InputGuard’s SecureKeyboardView is randomized per-press, has no autocorrect, and is the only correct surface for the verification challenge text field. See iOS keyboard leak surfaces for the broader threat model.
Choice 2, Don’t show the position number with the word. “Word #7” is fine as a prompt. Showing the user which word slot they’re entering during validation defeats the test, if they get word 7 wrong, the user can re-read the phrase, find word 7, and try again. Make it mode-locked: show the phrase, dismiss it, then present the verification quiz with no way back to the phrase view.
Choice 3, Allow case-insensitive matching but flag homoglyphs. BIP-39 words are lowercase. A user who types Arrow should pass. A user who types arrive instead of arrow should fail (homoglyph substitution, these are the words most often transposed). InputGuard’s validator does both: case-folds for matching, runs a Damerau-Levenshtein distance against the candidate word’s expected and the nearest BIP-39 neighbors to detect substitution.
Choice 4, Challenge indices that are stable under re-roll. If the user fails verification and you re-show the phrase, do not change which indices you challenge. The user will get the same prompts and either passes or you’ve confirmed they fundamentally cannot transcribe the phrase. Changing the indices on re-challenge lets a careless user accidentally pass by getting different words right.
Choice 5, Don’t allow paste. This sounds obvious but it is the most common implementation mistake. The text field accepting the word should disable paste, both via UIResponder.canPerformAction(_:withSender:) returning false for paste:, and via observing UIPasteboard.changedNotification to detect cross-app paste attempts. The user must type the word, character by character, on the secure keyboard. Pasting defeats the verification’s purpose because the user could have pasted from their own iCloud Notes copy of the phrase, exactly the case we’re trying to catch.
What good verification looks like, step by step
import InputGuardSDK
// 1. Generate the phrase
let generator = BIP39MnemonicGenerator(wordlist: .english)
let mnemonic = try generator.generate(wordCount: 24)
// 2. Display on the protected view (anti-screenshot, in-process, etc).
try await secureMnemonicDisplay.show(mnemonic)
// User taps "I've written it down, verify"
// 3. Construct the challenge
let challenge = MnemonicVerificationChallenge(
mnemonic: mnemonic,
promptCount: 5, // 5-of-24
avoidConsecutive: true, // never two adjacent indices
excludeFirstAndLast: true, // not word 1 or 24
secureKeyboardOnly: true, // disable system keyboard
disablePaste: true, // refuses cross-app paste
detectHomoglyphSubstitution: true // flag arrow ↔ arrive
)
// 4. Present
let outcome = try await uiPresenter.present(challenge)
// 5. Branch
switch outcome {
case .verified:
await wallet.provision(mnemonic: mnemonic)
case .partial(let wrongIndices):
// User got some right, some wrong. Show the phrase again, re-challenge.
await showPhraseAgain(highlighting: wrongIndices)
case .pasteAttempted:
// User tried to paste. Hard-stop, explain the threat model.
showPasteRefusedAlert()
case .cancelled:
await wallet.discardPendingProvision()
}
The validator returns .partial rather than .failed for genuine typos because the UX has to distinguish “user did write it down but typo’d one word” from “user did not write it down at all”. The first should let them retry; the second should send them back to the phrase view to actually transcribe it.
What verification does not solve
Three failure modes the verification primitive does not address:
- The user wrote it down on a sticky note that goes on the monitor and is photographed by the cleaning crew. Verification cannot reach into the physical world.
- The user wrote it down correctly but on a piece of paper they then lost. Same reason.
- The user wrote it down in their iPhone Notes app that syncs to iCloud. They will pass verification (they can read it back); the phrase still ends up on Apple’s servers. The only mitigation is the in-app UI explicitly warning that any phrase stored on the device defeats the purpose, and refusing to let the user copy the phrase to the system pasteboard (InputGuard does this by default).
The verification challenge is necessary but not sufficient. It catches the common case, the user did not actually look at the phrase carefully, and leaves the harder cases to user education and policy. The wallets that ship the right verification flow see their lost-key support tickets drop by ~60–80% within the first quarter. That’s the entire ROI of integrating the primitive.
See /sdk/inputguard for the marketing summary, /docs/inputguard for the BIP-39 API reference, and the companion post on iOS keyboard leak surfaces for the broader threat model that verification plugs into.
Engineering opinion, not advice. This post reflects the author's engineering reasoning at time of publication. It is not professional security, legal, financial, or compliance advice; do not rely on it as a substitute for review by qualified professionals for your specific situation. Posts may become outdated as iOS, Apple frameworks, attacker techniques, and our own SDKs evolve. The canonical source of truth for shipped SDK behavior is /docs and /changelog.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.