Strong Customer Authentication Without The Drop-off
Passkeys and biometrics in React Native
Passkeys and biometrics in React Native
Strong Customer Authentication (SCA) is one of the most visible regulatory requirements in a mobile banking app, and one of the most expensive in lost conversions when it is designed badly. Every extra screen, SMS code or failed fingerprint at login or payment approval is a point where customers give up, call support or switch to another channel. Yet SCA itself doesn't require friction. It requires two independent authentication elements and, for payments, a clear link between what the customer approves and what the bank executes. Designed well, that can take one glance at the phone.
This article covers the patterns our mobile teams use in React Native to meet SCA and keep drop-off low: passkeys for login, biometric-protected device keys for payment approval, and the UX details that decide whether customers complete the flow.
Under PSD2 and its regulatory technical standards on SCA (Commission Delegated Regulation (EU) 2018/389), strong authentication means combining at least two elements from different categories:
The elements must be independent, so that compromising one doesn't compromise the other.
For payments, the standards add dynamic linking. The customer must see the amount and the payee when approving, the authentication code must be specific to that amount and payee, and any change to either must invalidate it.
The standards also define exemptions, including low-value remote payments, payments to trusted beneficiaries the customer has listed, and transactions assessed as low risk by the bank's real-time transaction risk analysis. Exemptions are the bank's choice to apply, not a customer setting, but they are one of the most effective ways to reduce friction where risk is low.
On a phone, the most natural combination is possession plus inherence: a cryptographic key bound to the device, unlocked by the customer's biometrics. The rest of this article is about implementing that combination well.
Login and payment approval look similar to customers, but they have different requirements, and we use a different tool for each. Login needs to be fast, resistant to phishing and available on all of the customer's devices. Passkeys are built for this.
Payment approval needs dynamic linking and a clear, auditable possession element. A device-bound signing key, generated in the phone's secure hardware and protected by biometrics, fits it best.
The split also handles a compliance question. Passkeys can be synced between a customer's devices through Apple's or Google's credential managers. That is excellent for login convenience, but it raises the question of whether a synced passkey still counts as the possession element of SCA, since it isn't bound to one device. Each bank has to settle that question with its compliance team and, where needed, its supervisor. Using passkeys for login and a device-bound key for payments keeps payment approval on firm ground, whatever the answer.
A passkey is a WebAuthn credential: a key pair where the private key never leaves the customer's device or credential manager, and the public key is stored by the bank. Signing in means approving a system prompt with Face ID, a fingerprint or the device PIN. There is no password to steal and no SMS code to intercept, and because passkeys are tied to the bank's domain, a phishing site can't use them.
In React Native, react-native-passkey gives access to the native passkey APIs on iOS and Android. The login flow has three steps: get a challenge from the server, let the system sign it, and send the result back for verification:
1 import { Passkey } from 'react-native-passkey';
2
3 export async function signInWithPasskey() {
4 const options = await api.post('/webauthn/authenticate/options'); // server-generated challenge
5 const assertion = await Passkey.get(options); // system passkey sheet + biometrics
6 return api.post('/login/webauthn', assertion); // server verifies the signature
7 }
Passkeys only work when the app is associated with the bank's domain. That means an apple-app-site-association file with a webcredentials entry for iOS, and a Digital Asset Links file for Android, both served from the relying-party domain. Missing or misconfigured association files are the most common reason passkeys "work on the simulator but not in production".
On the server, Spring Security has built-in passkey support (based on the WebAuthn4J library) since version 6.4:
1 @Bean
2 SecurityFilterChain security(HttpSecurity http) throws Exception {
3 return http
4 .webAuthn(webAuthn -> webAuthn
5 .rpName("Example Bank")
6 .rpId("bank.example")
7 .allowedOrigins("https://bank.example", ANDROID_APP_ORIGIN))
8 .build();
9 }
For native Android apps, the origin in the WebAuthn response identifies the app by its signing certificate rather than by a web URL. That origin has to be added to the allowed list, or every Android login will fail verification.
Binding the device
When a customer first sets up the app, after identity verification and an initial strong authentication, the app creates a key pair in the phone's secure hardware: the Secure Enclave on iOS, the Android Keystore (ideally StrongBox) on Android. The key is configured so that every use requires biometric authentication. The private key never leaves the hardware; the public key is registered with the bank against that customer and device.
1 import ReactNativeBiometrics from 'react-native-biometrics';
2 const biometrics = new ReactNativeBiometrics();
3 export async function bindDevice() {
4 const { publicKey } = await biometrics.createKeys(); // private key stays in secure hardware
5 await api.post('/devices', { publicKey, platform: Platform.OS });
6 }
This key is the possession element. The biometric check that unlocks it is the inherence element. Because the operating system won't release the key without a successful biometric match, the two elements are enforced by the hardware, not just by app logic.
Approving a payment with dynamic linking
For each payment that needs SCA, the server creates a single-use challenge containing the payment details, and the app signs it after the customer confirms:
1 export async function approvePayment(paymentId: string) {
2 const challenge = await api.post(`/payments/${paymentId}/sca-challenge`);
3 // challenge.display: { amount: '1,250.00 EUR', payee: 'Maria Ionescu', iban: 'RO49 •••• 1234' }
4 // challenge.payload: built by the server from paymentId, amount, currency, payee IBAN, nonce, expiry
5
6 const { success, signature } = await biometrics.createSignature({
7 promptMessage: `Approve ${challenge.display.amount} to ${challenge.display.payee}`,
8 payload: challenge.payload,
9 });
10 if (!success) return { status: 'cancelled' as const };
11
12 return api.post(`/payments/${paymentId}/sca-confirm`, {
13 challengeId: challenge.id,
14 signature,
15 });
16 }
The server checks every condition that makes this a valid SCA with dynamic linking:
1 public void confirm(String customerId, String paymentId, UUID challengeId, byte[] signature) {
2 ScaChallenge challenge = challenges.consume(customerId, challengeId); // single use, not expired
3 Payment payment = payments.get(paymentId);
4
5 if (!challenge.matches(payment)) { // amount, currency and payee unchanged
6 throw new DynamicLinkingViolation(paymentId);
7 }
8 PublicKey deviceKey = devices.activeKey(customerId, challenge.deviceId());
9 if (!signatureVerifier.verify(deviceKey, challenge.payload(), signature)) {
10 throw new ScaFailedException(paymentId);
11 }
12 payments.markAuthorised(paymentId, challenge.id());
13 }
Three details matter here:
The server builds the payload, not the app. The app can't sign anything except what the server sent for this payment.
Show the amount and payee in the app, not only in the system prompt. The biometric prompt has limited space and behaves differently across platforms. The app's own confirmation screen must show the amount, the payee and a masked IBAN clearly before the prompt appears.
Challenges are single-use and short-lived. If the customer edits the amount, the old challenge is discarded and a new one is created.
The cryptography above makes SCA secure. These patterns make it feel effortless:
Ask for passkey enrolment after a successful login, not during onboarding. Customers who have just completed a long onboarding flow are less willing to take on one more step. A short prompt on the second or third login ("Sign in faster next time with Face ID") converts much better.
Explain once, in plain words. A single sentence such as "Your face or fingerprint stays on your phone; the bank never sees it" answers the most common privacy worry.
Apply exemptions deliberately. Trusted beneficiaries, low-value payments and the bank's transaction risk analysis can remove the SCA step entirely where risk is low. Each exemption the bank applies is one less prompt for the customer.
Don't ask twice. If the customer has just authenticated strongly to approve a payment, avoid asking again for the next screen in the same flow when the rules allow it.
Make the approval screen the confirmation screen. Show the payment summary and the approve button on one screen, with the biometric prompt triggered from there. Separate "review" and "authenticate" screens add a step without adding security.
Treat a cancelled prompt as a pause, not a failure. Customers often dismiss a biometric prompt by accident. Return them to the confirmation screen with the payment intact and a clear "Approve" button.
Conversion is decided as much by the edge cases as by the main flow:
Biometrics changed. On iOS, a key can be configured so that it becomes invalid when a new fingerprint or face is enrolled. That is good security, because someone who adds their own fingerprint to the customer's phone can't approve payments, but the app must detect it and guide the customer through re-binding with another strong authentication, rather than showing a generic error.
No biometrics, or customers who can't use them. Some customers can't or don't want to use biometrics. A device-bound key protected by an app PIN gives possession plus knowledge, which is still a valid SCA combination. This fallback is also an accessibility requirement, not an afterthought.
New phone. Passkeys may already be available through the customer's credential manager, which makes login on the new phone easy. The payment approval key, however, is bound to the old device. The app should clearly guide the customer through binding the new device, including a strong authentication that doesn't depend on the old phone.
Lost or stolen phone. Customers must be able to unbind a device from any channel (web, call centre or branch), with the change taking effect immediately on the server.
Too many failed attempts. The SCA standards limit consecutive failed authentication attempts. The app should show how to recover, not just that the customer has been blocked.
SCA flows deserve the same funnel analytics as any other conversion journey. The events we track for each login and payment approval:
Prompt shown, approved, cancelled and failed, split by platform and authentication method.
Fallback used: how often customers switch from biometrics to PIN, or from passkey to another method, and why.
Time to authenticate, from the confirmation screen to a confirmed result.
Abandonment after a prompt: sessions where the customer left the app without completing or retrying.
Exemption rate: the share of payments completed without an SCA step, and the fraud rate on those payments.
These numbers show where customers struggle, and they give the product, risk and compliance teams a shared view when deciding on exemptions or flow changes.
SCA doesn't have to cost conversions. Passkeys make login fast and phishing-resistant. Device-bound keys in secure hardware make payment approval a single biometric confirmation with dynamic linking built in. Careful attention to exemptions, fallbacks and edge cases keeps customers moving. The result is an authentication experience that is stronger than SMS codes and passwords, and noticeably easier for customers to complete.