Biometric prompt localization often fails at the last step. The sign-in screen uses the app's selected language, then Face ID, fingerprint, or device-credential UI opens with an English reason or cancel action. The operating system draws the dialog, but your code or authentication library still supplies part of its copy. If that copy comes from a default, a stale locale, or a service outside the UI translation context, users get mixed-language security UI.
Fix the boundary with one locale-aware prompt adapter. Resolve the effective app locale when authentication starts, build every app-owned prompt string from the reviewed catalog, and route every biometric entry point through that adapter. Then test failures and fallback paths, not only the successful unlock.
Why biometric prompts drift from the app language
A biometric dialog combines text from more than one owner. The platform owns labels for biometric type, device credentials, and some system actions. The app supplies other text. Apple says the localizedReason passed to LAContext.evaluatePolicy appears in the authentication dialog and should be clear, short, and written in the user's language. Android's BiometricPrompt.PromptInfo.Builder accepts an app-provided title and can also accept a subtitle, description, negative-button text, and authenticator policy.
Four failure paths follow from that split.
First, the authentication call may live outside the screen that shows the unlock button. A foreground listener, session service, state-management thunk, or native bridge can start authentication without access to the screen's translation hook.
Second, a library may provide English defaults. The app appears functional in the source locale, so nobody notices that its own catalog never contained the prompt text. A Trezor Suite fix for its biometric prompt documents this exact failure: the prompt stayed English regardless of the app language because the authentication call omitted translated options and fell back to library defaults.
Third, the locale can be captured too early. Building a prompt object at process startup means an in-app language change does not affect the retained title or reason. Recreating the visible screen will not repair a prompt object held by a singleton.
Fourth, successful authentication hides untranslated failure routes. Cancellation, lockout, missing enrollment, and credential fallback can take different code paths. A team may translate the primary reason but leave a custom retry dialog, settings link, or cancel label in English.
Define ownership before writing code
List every string that can appear from the moment the user taps Unlock until the app reaches an authenticated state. Assign each string to one of three owners:
- Platform-owned text: biometric type names, device PIN or passcode language, and operating-system error descriptions that your app does not supply.
- App-owned prompt text: the reason, title, subtitle, description, and custom cancel action passed to the platform or a wrapper library.
- App-owned recovery text: retry, open settings, use another method, session expired, and support messages rendered by the app after the prompt closes.
Translate the second and third groups. Do not copy platform wording into your resource files to make every platform look identical. That produces stale text and can misdescribe the authentication method available on a device.
The prompt should also describe the resulting action. "Authenticate" says little. "Unlock your saved projects" or "Approve this payment" tells the user what a successful scan will do. Apple's API guidance explicitly asks for a clear reason and resulting action. Keep the message short because the system controls available space.
Build one biometric prompt localization adapter
The adapter should accept a semantic action, resolve the current effective locale, and return the strings and policy needed for the platform call. It should not accept already-rendered English copy from callers.
type BiometricAction = "unlockApp" | "approvePayment" | "changeSecurity";
type PromptCopy = {
title: string;
reason: string;
cancel: string;
};
async function authenticate(action: BiometricAction): Promise<AuthResult> {
const locale = localeStore.getEffectiveLocale();
const copy: PromptCopy = {
title: messages.get(locale, `biometric.${action}.title`),
reason: messages.get(locale, `biometric.${action}.reason`),
cancel: messages.get(locale, "actions.cancel"),
};
return biometricGateway.prompt({
action,
locale,
copy,
allowDeviceCredential: policyFor(action).allowDeviceCredential,
});
}
The interface prevents callers from passing arbitrary display text. It also makes the effective locale visible in logs and tests without recording biometric data. If a translation is missing, follow the app's normal locale fallback policy and record a safe diagnostic event containing the message key and locale. Never fail authentication merely because one display string is missing.
Route all call sites through the same function. Typical entry points include cold launch, return from background after a timeout, a manual unlock button, a setting that enables biometrics, and confirmation of a sensitive operation. The Trezor report found three call sites covered by a shared thunk; fixing only the visible unlock button would have left the foreground and settings paths inconsistent.
Resolve the locale at invocation time. Do not cache rendered prompt copy in the gateway. A retained native authentication context may be useful for platform behavior, but its app-owned strings should be rebuilt for each attempt.
Map the adapter to iOS
On iOS, evaluate whether the required policy is available before presenting the prompt. Build the localized reason immediately before calling evaluatePolicy. The platform can reject, cancel, lock out, or redirect the flow, so map the result to semantic application outcomes rather than translated error strings.
func authenticate(action: BiometricAction) async -> AuthResult {
let locale = localeStore.effectiveLocale
let reason = catalog.string(
key: action.reasonKey,
locale: locale
)
let context = LAContext()
context.localizedCancelTitle = catalog.string(
key: "actions.cancel",
locale: locale
)
guard context.canEvaluatePolicy(action.policy, error: nil) else {
return .unavailable
}
do {
try await context.evaluatePolicy(
action.policy,
localizedReason: reason
)
return .authenticated
} catch {
return mapLocalAuthenticationError(error)
}
}
Treat this as a structural pattern rather than drop-in code. Your policy may allow biometrics only or permit device-owner authentication. Choose according to the protected action and the account recovery design.
Do not derive the prompt language from the device region if the app supports an explicit language choice. Use the same effective-locale resolver as the surrounding screen. The system-owned portions may still follow system settings. Your job is to prevent app-supplied English from being inserted into that dialog.
After the prompt returns, render recovery copy in the current locale again. The user may have changed language while the app was in the background, and a failed prompt should not revive strings captured before that change.
Map the adapter to Android
Android's biometric authentication guide separates capability checks, prompt configuration, callbacks, and authenticator selection. Build PromptInfo when the user starts the action, using resources selected for the current app locale.
fun buildPromptInfo(
action: BiometricAction,
context: Context
): BiometricPrompt.PromptInfo {
val builder = BiometricPrompt.PromptInfo.Builder()
.setTitle(context.getString(action.titleResource))
.setSubtitle(context.getString(action.subtitleResource))
.setAllowedAuthenticators(action.allowedAuthenticators)
if (!action.allowsDeviceCredential) {
builder.setNegativeButtonText(
context.getString(R.string.action_cancel)
)
}
return builder.build()
}
Use a locale-aware context when the app language differs from the device language. Keep that context scoped to resource lookup. Do not mutate the process-wide default locale to force one prompt into the right language.
Authenticator configuration affects which controls the system can show. Follow the Android API constraints when combining biometric and device-credential fallback. Do not assume a custom negative button is available in every policy configuration. The adapter can expose a semantic allowDeviceCredential flag, but the Android implementation must translate that into a valid PromptInfo.
Map callback codes to stable internal results such as cancelledByUser, lockedOut, notEnrolled, hardwareUnavailable, and authenticationFailed. Use those codes to select reviewed recovery messages. Never compare or parse a localized platform error message to decide what happened.
Handle fallback without changing the promise
Biometric authentication can be unavailable even on a supported device. Hardware may be absent, enrollment may be empty, the sensor may be locked after failed attempts, or policy may require a credential fallback. Define a localized response for each case.
For a temporary failure, keep the protected action pending and offer a retry only when the platform permits it. For missing enrollment, explain that the user must configure biometrics in system settings and provide another account-authentication path when product policy allows one. For lockout, route to device credentials or the established account recovery path. For cancellation, return to the previous state without showing an error that blames the user.
Preserve the action in every message. A generic "Biometrics unavailable" alert does not tell someone whether a payment was cancelled, an app remained locked, or a security setting was unchanged.
Do not translate security state into product state too early. A successful biometric match proves the device policy completed for that attempt. Your application still needs to validate session state, authorization, and the requested operation. Keep those checks outside the prompt-copy adapter.
Verify every entry point and language transition
A unit test should prove that the adapter reads the effective locale at call time and requests the expected message keys. Add a test that changes the app locale between two calls and checks that the second prompt receives different rendered copy. Add another that removes a target translation and confirms the documented fallback rather than a library default.
Run device tests for each supported platform across this matrix:
| Scenario | What to verify |
|---|---|
| Cold launch while locked | Prompt copy matches the first rendered app screen |
| Return from background | A retained session service resolves the current locale |
| Manual unlock | The button, prompt, and recovery message agree |
| Enable biometrics in settings | Confirmation uses the settings screen locale |
| Change language, then authenticate | No cached title, reason, or cancel text survives |
| Cancel the prompt | The app returns without an untranslated error |
| Trigger lockout | Recovery copy and credential fallback match policy |
| Remove biometric enrollment | The unavailable path explains the next valid action |
| Omit one target translation | The app uses its declared fallback and logs the missing key |
Test on real devices where possible. Simulators are useful for deterministic state transitions, but they do not cover every vendor implementation, enrollment state, or device-credential path.
Add a release check that searches biometric call sites. Every direct platform or library invocation should either live in the gateway or have an explicit exception. This catches new features that bypass the adapter and quietly restore English defaults.
Common mistakes that recreate mixed-language prompts
Passing source-language literals is the obvious bug. The less visible version passes translated copy from a screen into a service, which then retains it after the app language changes.
Calling translation hooks from non-UI code is another trap. Hooks often depend on a component lifecycle and cannot serve a background listener. Provide an imperative catalog lookup that accepts an explicit locale, and make it share the same message source and fallback rules as UI rendering.
Translating platform error descriptions also causes trouble. Error text can change across OS versions and locales. Branch on stable platform codes, then render your own recovery message from a reviewed key.
Finally, do not validate only the happy path in the source language. A prompt that succeeds immediately hides most of the copy that appears during cancellation, lockout, unavailable hardware, missing enrollment, and fallback.
Make biometric prompt localization a release gate
Start by inventorying every biometric entry point and the app-owned strings each one supplies. Move those calls behind one adapter, resolve the effective locale at invocation time, and add the language-transition matrix to mobile release testing. The next authentication feature should have to use that path before it can ship.
Sign-in screens raise the same mixed-language problem; see how to keep hosted OAuth login in the app's selected language.
References
- Apple:
LAContext.evaluatePolicysupports the iOS localized-reason and prompt-guidance requirements. - Android:
BiometricPrompt.PromptInfo.Buildersupports the Android app-owned prompt fields and authenticator configuration. - Android: Show a biometric authentication dialog supports capability checks, prompt construction, callbacks, and fallback handling.
- Trezor Suite biometric prompt localization fix documents a practitioner failure caused by untranslated library defaults and non-UI locale lookup.
