Short answer: sign users in with passkeys, keep a non-exportable device key in the Secure Enclave or Android Keystore that signs every request, let biometrics release a stored secret rather than return a yes or no, ask for a fresh check before transfers and new payees, and make account recovery as hard to abuse as sign-in. SMS codes stay as a fallback with risk checks around them.
This article goes deeper on authentication than the mobile banking security checklist, which covers storage, pinning, runtime integrity, logging and incident response as well. Here the subject is only how a fintech app proves who is holding the phone, from the threat model to the server code, mapped to the OWASP MASVS-AUTH controls. I worked on the Linking, KYC and Registration modules of the Citi Mobile app through NTT Data, and enrolment and recovery are where most of the decisions below get made in practice.
The Threat Model for Fintech Sign-In
Each control later in the article exists because of one of these five attacks. If a proposed control does not move one of them, it is decoration.
| Threat | How it works | What stops it |
|---|---|---|
| Phishing | A look-alike site or message collects the password and relays the one-time code in real time | Passkeys, which only sign for the domain they were created on |
| Credential stuffing | Passwords leaked elsewhere are replayed against your login API at volume | Passkeys, plus rate limits and breached-password checks while passwords remain |
| SIM swap | The attacker moves the victim’s number to their own SIM and receives every SMS code | No SMS-only recovery; SIM-change and port signals before any code is sent |
| Token theft | A bearer token is lifted from logs, a proxy, a backup or malware and replayed from another device | Short-lived tokens bound to a device key that signs each request |
| Device compromise | Malware, a hooked app or a phone left open in someone else’s hands drives the real app | Step-up on sensitive actions, biometric-gated keys, server-side risk signals |
Device compromise is the one you cannot fully solve on the client. A rooted phone with a hooking framework can call your own code. What you can do is make each sensitive action require something the attacker does not have at that moment, such as the enrolled face or a key that cannot be copied off the device.
Passkeys as the Primary Sign-In
A passkey is a FIDO2 / WebAuthn key pair. The private key stays in the platform credential manager, the server stores the public key, and every sign-in is a signature over a fresh server challenge for a specific relying party ID. A phishing site on another domain gets nothing it can use, and there is no shared secret in your database to leak or stuff. The FIDO Alliance distinguishes synced passkeys, copied across a user’s devices by a cloud service, from device-bound ones that never leave a single authenticator.
On iOS the API is ASAuthorizationPlatformPublicKeyCredentialProvider in AuthenticationServices. Apple’s passkey documentation is blunt about the prerequisite: registration and assertion requests return an error unless the app has an associated domain with the webcredentials service. That means a webcredentials:bank.example.com entry in the Associated Domains entitlement and a webcredentials block listing TEAMID.bundle.id in the apple-app-site-association file under /.well-known/. Devices read that file through Apple’s CDN, which requests it within 24 hours, and installed apps check for updates about once a week, so publish changes well before the release that needs them.
import AuthenticationServices
import UIKit
final class PasskeySignIn: NSObject, ASAuthorizationControllerDelegate,
ASAuthorizationControllerPresentationContextProviding {
private let window: UIWindow
private var completion: ((Result<ASAuthorizationPlatformPublicKeyCredentialAssertion, Error>) -> Void)?
init(window: UIWindow) {
self.window = window
}
// challenge comes from your server's WebAuthn options endpoint, single use
func signIn(challenge: Data,
completion: @escaping (Result<ASAuthorizationPlatformPublicKeyCredentialAssertion, Error>) -> Void) {
self.completion = completion
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
relyingPartyIdentifier: "bank.example.com"
)
let request = provider.createCredentialAssertionRequest(challenge: challenge)
request.userVerificationPreference = .required
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
// iOS 16+: fail fast if no passkey is on this device, then fall back to the old login
controller.performRequests(options: .preferImmediatelyAvailableCredentials)
}
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
window
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization) {
guard let assertion = authorization.credential
as? ASAuthorizationPlatformPublicKeyCredentialAssertion else { return }
// Send credentialID, rawClientDataJSON, rawAuthenticatorData, signature
// and userID to the server, which verifies them against the stored public key.
completion?(.success(assertion))
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithError error: Error) {
completion?(.failure(error))
}
}The provider class shipped in iOS 15, but performRequests(options:) and performAutoFillAssistedRequests(), which suggests saved passkeys when the user taps a username field, are iOS 16 APIs, so iOS 16 is the practical floor. On iOS 18 and later, a registration request with requestStyle = .conditional can add a passkey quietly right after a successful password sign-in, which is the cheapest way to grow adoption.
On Android the equivalent is Credential Manager (androidx.credentials), which supports passkeys on Android 9 and later. Registration passes your server’s WebAuthn options JSON to a CreatePublicKeyCredentialRequest; sign-in wraps a GetPublicKeyCredentialOption in a GetCredentialRequest. The domain must publish a Digital Asset Links file with the delegate_permission/common.get_login_creds relation, served with a 200 and no redirect, and Google’s docs list it as required for passkeys.
Both platforms talk to the same relying-party endpoints, so the server work is shared: issue a random single-use challenge, require userVerification: "required" so a passkey cannot be used without the device’s biometric or passcode, check the origin and RP ID hash, and store credential IDs per user so a user can see and remove passkeys from a settings screen. Use a maintained WebAuthn library for verification rather than parsing CBOR yourself.
Biometrics Should Release a Secret
The classic finding in a banking app review is a call to evaluatePolicy whose success callback sets isAuthenticated = true. Anyone with a hooking tool can call that callback directly. MASVS-AUTH-2 and its weaknesses (MASWE-0020, local authentication can be bypassed; MASWE-0022, keys not invalidated on new biometric enrolment) describe exactly this.
The fix is to make the biometric match a condition of reading something the server needs. The broader checklist shows the write side with .biometryCurrentSet; the read side is where the guarantee comes from, because the Keychain shows the prompt and returns the data only on a match:
import Foundation
import LocalAuthentication
import Security
enum DeviceSecretError: Error {
case accessControl
case keychain(OSStatus)
}
// Write once, after a full sign-in. The item is only readable after Face ID or Touch ID
// from the biometrics enrolled today; adding a new face or finger destroys it.
func saveDeviceSecret(_ secret: Data) throws {
guard let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, nil
) else { throw DeviceSecretError.accessControl }
let base: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: "com.example.bank.device-secret",
kSecAttrAccount: "primary",
]
SecItemDelete(base as CFDictionary)
var add = base
add[kSecAttrAccessControl] = access
add[kSecValueData] = secret
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw DeviceSecretError.keychain(status) }
}
// Read on app open. The biometric prompt is shown by the Keychain itself, and the
// secret only exists in memory if the match succeeded.
func readDeviceSecret() throws -> Data {
let context = LAContext()
context.localizedReason = "Sign in to your account"
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: "com.example.bank.device-secret",
kSecAttrAccount: "primary",
kSecReturnData: true,
kSecUseAuthenticationContext: context,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
// errSecItemNotFound after a biometric change: send the user through full sign-in
throw DeviceSecretError.keychain(status)
}
return data
}kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly keeps the item out of backups and deletes it if the user removes the device passcode. What the secret is matters less than the fact that the server checks it: a refresh credential, or the blob that restores the device key in the next section. On Android the same pattern is a Keystore key created with setUserAuthenticationRequired(true), used through BiometricPrompt with a CryptoObject. New biometric enrolment invalidates such keys by default, and the next cipher init throws KeyPermanentlyInvalidatedException, which you treat the same way as the missing Keychain item: full sign-in, then re-enrol.
Device Binding With a Secure Enclave Key
A bearer token proves only that someone has the token. Device binding adds proof that the request came from the phone that enrolled. At enrolment the app creates a P-256 key inside the Secure Enclave, sends the public key to the server, and from then on signs each request. The private key cannot be exported, so a token copied off the device is useless without it.
import CryptoKit
import Foundation
import Security
enum DeviceKeyError: Error {
case noSecureEnclave
}
struct DeviceKey {
let key: SecureEnclave.P256.Signing.PrivateKey
// Called once at enrolment. Store key.dataRepresentation in the Keychain
// (it is an encrypted blob only this device's Secure Enclave can use) and send
// key.publicKey.pemRepresentation to the server with the device ID.
static func create() throws -> DeviceKey {
guard SecureEnclave.isAvailable else { throw DeviceKeyError.noSecureEnclave }
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, &error
) else { throw error!.takeRetainedValue() as Error }
return DeviceKey(key: try SecureEnclave.P256.Signing.PrivateKey(accessControl: access))
}
static func restore(from blob: Data) throws -> DeviceKey {
DeviceKey(key: try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: blob))
}
// Sign method, path, timestamp, nonce and body hash so a captured request
// cannot be replayed or edited, and a copied token is useless on another device.
func sign(_ request: inout URLRequest, deviceID: String) throws {
let body = request.httpBody ?? Data()
let bodyHash = SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()
let timestamp = String(Int(Date().timeIntervalSince1970))
let nonce = UUID().uuidString
let canonical = [
request.httpMethod ?? "GET",
request.url?.path ?? "/",
timestamp,
nonce,
bodyHash,
].joined(separator: "\n")
let signature = try key.signature(for: Data(canonical.utf8))
request.setValue(deviceID, forHTTPHeaderField: "X-Device-ID")
request.setValue(timestamp, forHTTPHeaderField: "X-Timestamp")
request.setValue(nonce, forHTTPHeaderField: "X-Nonce")
request.setValue(signature.derRepresentation.base64EncodedString(),
forHTTPHeaderField: "X-Signature")
}
}.privateKeyUsage alone lets the app sign silently in the background, which is what you want for ordinary requests. For the step-up key in the next section, create a second enclave key whose access control also includes .biometryCurrentSet, so each signature needs a fresh match. Android’s equivalent is an EC key generated in the Keystore, with setIsStrongBoxBacked(true) where the device has StrongBox and setAttestationChallenge() if you want the server to check the key really lives in secure hardware.
The server side is a middleware that rebuilds the same canonical string and verifies it:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
class VerifyDeviceSignature
{
public function handle(Request $request, Closure $next): Response
{
$device = $request->user()?->devices()
->where('device_id', $request->header('X-Device-ID'))
->whereNull('revoked_at')
->first();
$timestamp = (int) $request->header('X-Timestamp');
$nonce = (string) $request->header('X-Nonce');
$signature = base64_decode((string) $request->header('X-Signature'), true);
abort_if(! $device || $signature === false, 401, 'Unknown device');
abort_if(abs(time() - $timestamp) > 300, 401, 'Stale request');
// Cache::add only succeeds once per key, so a replayed nonce fails here
abort_unless(Cache::add("nonce:{$device->id}:{$nonce}", true, 600), 401, 'Replayed request');
$canonical = implode("\n", [
$request->method(),
'/'.ltrim($request->path(), '/'),
(string) $timestamp,
$nonce,
hash('sha256', $request->getContent()),
]);
// The iOS client sends a DER-encoded ECDSA P-256 signature over SHA-256
$ok = openssl_verify($canonical, $signature, $device->public_key_pem, OPENSSL_ALGO_SHA256);
abort_unless($ok === 1, 401, 'Bad signature');
return $next($request);
}
}Keep paths plain ASCII so both sides agree on encoding, and keep the nonce cache longer than the timestamp window. A devices table with the public key, platform, enrolment time and revoked_at also gives the user a real “devices signed in” screen.
Step-Up for Transfers and New Payees
MASVS-AUTH-3 asks that sensitive operations get additional authentication, and two of its weaknesses are worth reading as requirements: MASWE-0023, step-up not implemented for sensitive actions, and MASWE-0025, lack of non-repudiation for critical actions. A session that is ten seconds old is still not enough to move money to an account the user has never paid before.
The flow I build for a transfer, a new payee, or a change of phone number or email:
- The app sends the intended action to the server, which stores it and returns a single-use challenge that encodes the action ID, amount and payee.
- The app shows the user those details, then signs the challenge with the biometric-gated enclave key, or requests a passkey assertion over it.
- The server verifies the signature, checks the challenge matches the stored action and has not expired, executes exactly that action, and records the signature as evidence.
Because the signature covers the amount and payee, malware that changes the destination after approval breaks verification. Device passcode fallback is the judgment call: MASWE-0021 flags non-biometric fallback for sensitive transactions, so for transfers I require the biometric or a passkey and send users without either through a slower path the server controls. Adding a payee is also a good place for a server-side hold or a lower first-transfer limit, which costs nothing on the client.
Tokens and Sessions on the Server
Everything above ends in a token the API trusts, so the token rules decide how long a stolen one stays useful. One token per device, an expiry on every token, rotation before expiry, and revocation for one device, every other device, or all devices after a password change or recovery. The Sanctum vs Passport comparison covers which Laravel package fits a first-party app and how to rotate Sanctum tokens without refresh tokens. I packaged that pattern as laravel-device-tokens, an open-source package that issues one Sanctum token per device name with expiry, a transactional rotation endpoint, and sign-out per device or everywhere.
For timeouts, NIST SP 800-63B-4 says reauthentication at AAL2 should happen at least every 24 hours and after no more than an hour of inactivity. A banking app usually locks much sooner on the client; the server limits are what protect a token that escaped. Revoke on the server at logout, and bind the token to the device ID so the signature middleware can reject it from anywhere else.
Account Recovery as Strong as Sign-In
Passkeys and device keys push attackers toward the reset flow, so a “forgot password” path that ends in an SMS code quietly undoes the work. NIST SP 800-63B-4 recognises four classes of recovery: saved recovery codes, issued recovery codes, recovery contacts, and repeated identity proofing. A few of its specifics translate directly to a fintech app:
- Saved recovery codes carry at least 64 bits from an approved random generator, are stored hashed, are throttled, and are replaced after each use.
- An issued recovery code sent by text message or voice is valid for at most 10 minutes; by email, 24 hours.
- Every recovery sends a notification to the subscriber, and services should encourage users to keep at least two authenticators so recovery is rare.
For a regulated app, repeated identity proofing usually means re-running the KYC checks used at account opening, which is why recovery belongs with the registration and KYC teams rather than as a screen bolted onto login. After any recovery, revoke every token and device key, enrol a new device key, notify every address on file, and keep transfers to new payees on hold for a period your risk team sets.
Where SMS OTP Still Fits
NIST SP 800-63B-4 lists out-of-band authentication over the public phone network as its one restricted authenticator. A service that accepts it must offer at least one unrestricted alternative, give users meaningful notice of the risk, and keep a migration plan; it should also check risk indicators such as a device swap, SIM change or number porting before sending a code.
In practice SMS fails two ways in fintech: SIM swap hands the code to the attacker, and a phishing page relays it in real time. I keep it for contact-detail verification and as a fallback for users without a passkey, with a short expiry, a per-number and per-account rate limit, and no ability to add a payee or change the phone number on SMS alone.
Mapping the Controls to MASVS-AUTH
| Control | What it says | Covered by |
|---|---|---|
| MASVS-AUTH-1 | Secure authentication and authorization protocols, enforced at the remote endpoint | Passkeys, per-device tokens with expiry, device-signed requests |
| MASVS-AUTH-2 | Local authentication follows platform best practices | Keychain item with .biometryCurrentSet; Keystore key with BiometricPrompt and CryptoObject |
| MASVS-AUTH-3 | Sensitive operations get additional authentication | Transaction-bound step-up challenge, recovery that revokes and re-binds |
Decision Table
| Moment | Use | Avoid |
|---|---|---|
| First sign-in on a device | Passkey; password plus a second factor until passkey adoption is high | Password plus SMS as the only path |
| Reopening the app | Biometric read of a Keychain or Keystore secret, then a signed token refresh | A local boolean set by an evaluatePolicy callback |
| Every API call | Short-lived per-device token plus a Secure Enclave or Keystore signature | A long-lived bearer token on its own |
| Transfer or new payee | Biometric-gated signature or passkey assertion over the transaction details | Relying on the session being recent |
| Lost phone or locked out | Synced passkey, saved recovery code, or repeated identity proofing; revoke all devices | An SMS code that resets everything |
| Changing phone or email | Step-up first, then verify the new address, and notify the old one | Verifying only the new number |
A Rollout Plan That Does Not Lock Anyone Out
- Ship the server first: WebAuthn endpoints, the devices table, the signature middleware in log-only mode, and per-device tokens with expiry.
- Publish the
apple-app-site-associationandassetlinks.jsonentries a release ahead, since Apple’s CDN picks up changes on its own schedule. - Release the app with device key enrolment and biometric secret storage after the existing login. Watch enrolment failures by OS version and device model.
- Offer passkey creation after a successful sign-in, and use conditional registration on iOS 18 and later. Keep the old login available.
- Turn on step-up for transfers and new payees, then switch the signature middleware from logging to enforcing for the devices that have a key.
- Rebuild recovery last, with revocation and re-binding built in, and only then start restricting SMS to fallback use.
Sources
- OWASP: MASVS-AUTH-1, MASVS-AUTH-2, MASVS-AUTH-3
- NIST: SP 800-63B-4, Authentication and Authenticator Management
- Apple: Supporting passkeys, ASAuthorizationPlatformPublicKeyCredentialProvider, Supporting associated domains, SecureEnclave.P256.Signing.PrivateKey
- Android: Passkeys with Credential Manager, Credential Manager prerequisites, KeyGenParameterSpec.Builder
- FIDO Alliance: Passkeys
Where This Fits
The rest of the client-side controls for a banking app are in the mobile banking security checklist, and the API side in the Laravel API security checklist. If you are building or rebuilding sign-in for a finance product, see fintech app development.
