Biometric Step-Up Auth on iOS and Android with Hardware-Bound Keys
Face ID alone proves nothing to your server. Use Secure Enclave and Android Keystore keys to sign one-time challenges for real step-up authentication.
This article is published in English.
Sources: Biometrics, the Secure Enclave, and Auth Tokens: Three Ideas That Sound the Same But Aren’t
Product specs often say "Face ID unlocks the app", and for users that description is good enough. For a team building payments, health records, admin consoles or a login flow, it hides the part that matters: a biometric scan never reaches your backend, never mints a token, and never hands your code a face or fingerprint template. What a well-designed app actually gets is permission for protected hardware to use a private key your code cannot read, so that key can sign a one-time challenge the server then verifies. This guide separates the three ideas that tend to get blurred together (biometric prompts, hardware-backed keys and auth tokens) and walks through a register-then-sign flow on both platforms.
Why a token alone is a weak promise on a mobile device
A mobile app runs on hardware the user controls, not you. Phones get lost, lock screens get shoulder-surfed, debugging tools attach during development, and a rooted or jailbroken device can expose far more of your process than you would like.
Despite all that, a sensitive endpoint needs to trust a precise claim: the call originates from a phone holding a key the server enrolled earlier, and a person approved using that key moments ago.
Compare that with "this request carries a token". Any value that lives in app memory or storage can be copied and replayed from somewhere else. A private key that is generated inside protected hardware and can never be exported is a much harder target, because stealing it means attacking the hardware rather than reading a string.
Before building anything, it helps to name what each piece proves:
- Biometric prompt: the phone accepted Face ID, Touch ID or a strong Android biometric.
- Secure hardware key: a protected private key produced a signature.
- Auth token: your server granted a session or some access.
A robust design uses all three, each for its own job.
HTTPS protects the channel, not the identity
HTTPS encrypts traffic between the app and your API, so someone sharing a café Wi-Fi network cannot trivially read or tamper with requests.
phone ── encrypted HTTPS connection ──► server
It is mandatory, and it is also insufficient on its own. If an attacker has already obtained a valid bearer token, TLS will faithfully and securely deliver that stolen token to your server. Nothing in the encrypted connection tells the backend whether the token came from the legitimate device or from a copy.
So the baseline is: use HTTPS on every call, validate certificates the normal way, keep the TLS stack patched, and then layer an authentication mechanism on top that says something verifiable about the device and the user's action.
Certificate pinning is a narrow tool
Pinning restricts the app to a known certificate or public-key identity. In tightly controlled apps it can cut down on certain man-in-the-middle scenarios.
What it cannot do is make a bearer token behave like a hardware-backed credential. It also carries real operational risk: ship a wrong pin, or miss a certificate rotation, and every installed copy of the app stops talking to your API at once. Adopt it only if your threat model justifies it and your release process can handle key rotation reliably, and never treat it as a replacement for device-bound authentication.
A biometric prompt is only a local yes or no
On iOS, the app asks an LAContext to present the system authentication sheet; on Android, the equivalent is BiometricPrompt. The operating system performs the scan and reports back success or failure. The snippet below is Swift: it evaluates the biometrics-only policy with a reason string and starts the transfer if the check passes.
let context = LAContext()
let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics
context.evaluatePolicy(policy, localizedReason: "Approve this transfer") { success, error in
guard success else { return }
startTransfer()
}
Your code never sees face geometry or fingerprint images. Apple describes LocalAuthentication as working with the Secure Enclave and returning nothing more than a pass/fail result to the app (LocalAuthentication framework reference).
That is perfectly adequate for locking private content inside the app. As evidence for a server, it falls short for three reasons:
- The result only says "the device accepted the check"; it carries no information about who, or which account.
- The backend has no way to verify a boolean produced on the client.
- If the app process is compromised, an attacker can skip or fake the callback and simply reuse a token that already exists.
The point to take from this pattern is that success is just a flag in your own memory space. The stronger design uses the biometric check to unlock a single cryptographic operation whose output the server can verify independently.
Hardware key stores keep the private key out of your process
Apple devices include the Secure Enclave, an isolated subsystem that can generate and use private keys without ever exposing the raw key material to the main application processor. According to Apple's documentation, the key stays inside the enclave and the app only receives outputs such as signatures (Apple's Secure Enclave key guide).
The counterpart on the other platform is the Android Keystore. Depending on the hardware, keys may be backed by a Trusted Execution Environment (TEE) or by a dedicated StrongBox secure element. Be careful with naming here: "Titan M" is the chip in Google Pixel phones, not a general Android API, and you should not assume every device has one. From the app's point of view, what counts is how a key is backed and which usage rules you attach to it.
For a high-value action, the key should satisfy four rules:
- It is generated on this specific device.
- Every use requires a fresh strong biometric approval.
- The private part can never be exported.
- Only the public key is ever stored on the server.
On Android, BiometricPrompt.CryptoObject can gate a crypto operation only when the authenticator class is BIOMETRIC_STRONG, and the key can be configured to demand a new approval on each use (BiometricPrompt reference, Keystore overview).
The protocol: register a key once, sign fresh challenges afterwards
The whole scheme has two phases, enrollment and use.
Enrollment
- The app generates a hardware-backed key pair.
- It uploads the public key to the backend over HTTPS.
- The server records the public key alongside the account and a device identifier.
- The private key remains on the phone, inside secure hardware.
Sending the public key is harmless: it can verify signatures but cannot produce them. Only the private key can sign, and it never leaves the protected hardware.
Approving a sensitive request
- The app requests a one-time random challenge from the server.
- The user passes the biometric prompt.
- The secure hardware signs the challenge.
- The app returns the challenge ID and the signature.
- The server verifies the signature against the stored public key.
- The server accepts or rejects the action, and may then issue a token.
The challenge is where most implementations go wrong. It must be random, expire quickly, and be consumable exactly once. It should also be bound to the account, to the specific device key, and to the action being approved (for example, "transfer 200 EUR to account X"). Skip any of these and a captured signature can be reused later for a different request, which is a classic replay attack. Binding the action also prevents a signature collected for a harmless operation from being redirected to a dangerous one.
iOS: a Secure Enclave key that demands Face ID on every use
On iOS you create the key directly inside the Secure Enclave and attach an access-control object that requires biometrics each time the key is used. In the Swift code below, notice three choices. kSecAttrAccessibleWhenUnlockedThisDeviceOnly keeps the key from migrating to backups or other devices. The flags .privateKeyUsage and .biometryCurrentSet require biometric approval for signing and tie the key to the currently enrolled faces or fingers. And kSecAttrTokenIDSecureEnclave is what places the key in hardware; the Secure Enclave only supports 256-bit elliptic-curve (P-256) keys, which is why the key type and size are set as they are.
// 1. Create access control requiring Face ID / Touch ID for every use
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
&error
) else { return }
// 2. Generate the key pair in the Secure Enclave
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.keys.transfer",
kSecAttrAccessControl as String: accessControl
]
]
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { return }
A practical consequence of .biometryCurrentSet is that enrolling a new fingerprint or resetting Face ID invalidates the key. That is usually what you want for payments, but it means your app must handle a missing key gracefully and send the user through re-registration.
Upload the public key once the user has completed normal account login. Later, when the backend hands back a random challenge for a transfer, sign it with the private key. You do not show a prompt yourself: because of the access-control flags, the signing call triggers Face ID or Touch ID automatically.
let algorithm: SecKeyAlgorithm = .ecdsaSignatureMessageX962SHA256
var signError: Unmanaged<CFError>?
// 3. This call automatically triggers the Face ID prompt because of the accessControl flags
guard let signature = SecKeyCreateSignature(
privateKey,
algorithm,
challengeData as CFData,
&signError
) else { return }
api.approve(challengeId, signature)
If the user cancels or fails the scan, SecKeyCreateSignature returns nil and no signature exists to send, so there is no success flag an attacker could flip.
Android: a Keystore key unlocked through BiometricPrompt
The Kotlin below builds a signing-only EC key in the AndroidKeyStore provider. setUserAuthenticationRequired(true) makes authentication mandatory, and setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) sets the validity window to zero seconds, which means every single use needs a new strong biometric check rather than a grace period after the last unlock.
val spec = KeyGenParameterSpec.Builder(
"transfer-key",
KeyProperties.PURPOSE_SIGN
).setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(
0,
KeyProperties.AUTH_BIOMETRIC_STRONG
).build()
val generator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_EC,
"AndroidKeyStore"
)
generator.initialize(spec)
val publicKey = generator.generateKeyPair().public.encoded
Send the encoded publicKey to the backend after the user signs in normally, and store it with the user and device record.
When the server later issues a challenge for a transfer, initialize a Signature object, wrap it in a CryptoObject, and pass that to the prompt. Only inside onAuthenticationSucceeded do you feed the challenge bytes in and produce the signature.
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(privateKey)
val crypto = BiometricPrompt.CryptoObject(signature)
prompt.authenticate(promptInfo, crypto)
override fun onAuthenticationSucceeded(result: AuthenticationResult) {
val signed = result.cryptoObject!!.signature!!
.apply { update(challenge) }.sign()
api.approve(challengeId, signed)
}
The dialog is not the security boundary; the CryptoObject is. The Keystore will refuse to perform the signing operation unless the biometric check it is attached to succeeded, and the key material itself stays inside the protected store. Your app only ever holds the resulting signature.
On the server, verify that signature with the registered public key. If it matches an unexpired, unused challenge, approve the transfer or issue a short-lived token scoped to that action.
Where auth tokens still belong
Verifying a hardware-backed signature does not eliminate tokens. After the check, issuing a regular access token is completely reasonable, since tokens remain the practical way to authorize the bulk of API traffic. If you are designing the token side of the system, the trade-offs in our refresh token strategy guide apply here as well.
Keep those tokens in platform-protected storage: the Keychain on iOS and Keystore-backed storage on Android. Long-lived refresh tokens should never sit in plain preferences, ordinary files or JavaScript-accessible storage, which matters especially in React Native apps where AsyncStorage is an easy but unencrypted default.
The design question then becomes how often to demand a new hardware signature:
- On every API call: almost always too slow and too irritating.
- On every app launch: a sensible choice for a private app lock.
- On payments, profile edits, data exports or approving a new device: frequently the sweet spot.
- On every refresh-token use: worth it for higher-risk accounts, provided the user experience can absorb the prompts.
Think of the signature as the step-up proof and the token as the short-lived permission that follows from it.
Limits of hardware-backed keys
This approach is strong, but it is not a cure-all:
- It does nothing against a legitimate user who unlocks their own phone and approves a harmful action, for example under social-engineering pressure.
- It does not make a rooted or jailbroken device trustworthy; it only makes extracting and cloning the key harder.
- It does not replace server-side fraud detection, rate limiting or anomaly monitoring.
- It does not remove the need for account recovery, because people lose phones and re-enroll biometrics.
- It proves control of a registered key after local approval, not a person's real-world identity.
Plan the recovery path from the start, and make it deliberately slower and more heavily verified than the everyday path. When a device key disappears, the lock screen is reset or biometric enrollment changes, your app should be ready to register a fresh key and your server should revoke the old one. Android documents that authentication-bound keys can be permanently invalidated when the secure lock screen is changed or when biometric enrollment changes, depending on how the key was configured (KeyProtection.Builder reference).
How passkeys relate to this design
Passkeys rest on the same foundation: a public/private key pair and a server challenge signed with the private key. For website and account sign-in they are usually the best available answer, because the operating system and platform take care of most of the hard parts. Passkeys are a standardized, cross-platform protocol that builds on this style of hardware-backed signature flow. One nuance: many passkeys are synced across a user's devices through the platform's credential manager, so they are not always bound to a single phone the way a non-exportable Keystore or Secure Enclave key is.
That distinction suggests a simple split. Reach for a hardware-backed app key when a native app needs device-bound proof for a particular action. Reach for passkeys for modern passwordless sign-in wherever your product can support them. Either way, the server's job is the same: verify a signature with a public key it already trusts.
Choosing the right tool for each need
- Protecting data in transit: HTTPS.
- Keeping a user signed in: a short-lived access token plus a protected refresh token.
- Locking private content when the app opens: Face ID, Touch ID or BiometricPrompt.
- Approving a payment or other high-risk action: a biometric-gated hardware signature.
- Passwordless sign-in: passkeys.
- Reducing a narrow TLS interception risk: certificate pinning.
- Keeping a payload private even from your own server: end-to-end payload encryption.
Key takeaways
Face ID and fingerprint scans do not authenticate a backend request. They authorize the use of a key; secure hardware uses that key to sign a fresh server challenge; and the server verifies the signature before deciding what the user may do.
- Biometric: local approval, invisible to the server.
- Hardware key: proof of possession that cannot be copied out of the device.
- Token: the server's short-term grant of permission.
Keep those roles distinct, bind every challenge to an account, key and action, and plan for key invalidation and recovery. The result is a mobile authentication design that is easier to reason about and considerably harder to fake.