I’ve been writing software long enough to remember when “biometric authentication” meant a sysadmin squinting at a grainy CCTV feed. Twenty years later, I’ve shipped everything from password-on-paper to WebAuthn, and I still got nervous the first time I wired Face ID into a Turbo Native app.
Why nervous? Because biometrics aren’t a feature. They’re a promise. A promise that you, the developer, will treat a user’s face or fingerprint with the same care as a bank vault combination. And in Turbo Native—where your Rails backend lives miles away from a LAContext or BiometricPrompt—the gap between promise and execution can swallow you whole.
This is the story of how I learned to bridge that gap. Not with libraries and copy-paste, but with a deliberate, almost architectural art. Senior full-stack folks who’ve wrestled OAuth flows and slept through JWT debates: this one’s for you.
The Naive Approach That Almost Got Us Sued
Let me paint a picture. First version of our Turbo Native banking app (yes, banking). Product said: “Just use the native biometric API to unlock the app. No big deal.”
So we did the obvious:
// iOS: Show Face ID, then just… load the web view
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, ...) { success, error in
if success {
webView.load(URLRequest(url: dashboardURL))
}
}
Seems fine, right? User authenticates, web view loads. Except the web view had its own session cookie from a previous password login. And the backend had no idea the user just used biometrics. So when the web view made an API call to /transfer_funds, the backend saw an old session—valid, but not “biometrically re-verified” for a high-value action.
We shipped. A week later, a user’s roommate unlocked the phone with Face ID (because they looked vaguely similar) and transferred money from the sleeping user’s account. The backend saw a valid session and said “ok.”
The user sued. (We settled.)
That’s when I learned: biometric authentication in Turbo Native isn’t about unlocking the app. It’s about creating a cryptographic handshake that your Rails backend can trust.
The Mental Model: A Two-Factor Bridge
Think of it this way. Your native biometrics are like a key that never leaves the device. Your Rails backend has a lock that expects a signed message saying “a human just proved their presence with a biometric.”
The web view is just a messenger. It cannot be trusted. So you must:
-
Prompt biometrics natively – Using
LAContext(iOS) orBiometricPrompt(Android). - Generate a short-lived, signed token – On the native side, after biometric success.
- Inject that token into the web view – Via JavaScript or custom URL scheme.
- Validate the token in Rails – Without ever storing the biometric data itself.
The artwork is in step 2. You’re not just passing a boolean. You’re passing evidence.
Building the Handshake (What Actually Survived Production)
Here’s the architecture that replaced our lawsuit-waiting-to-happen:
Step 1: Native Biometric Challenge
On app launch or sensitive action, native code requests a challenge from the Rails backend before prompting biometrics.
# Rails: GET /api/biometric/challenge
def challenge
challenge = SecureRandom.hex(32)
redis.setex("biometric_challenge:#{current_user.id}", 300, challenge)
render json: { challenge: challenge, expires_in: 300 }
end
Why a challenge? Prevents replay attacks. The native app must sign this exact nonce with a device-specific key.
Step 2: Biometric Prompt + Signing
In the native app, after biometric success, we generate an asymmetric key pair (stored in the Secure Enclave / Keystore) on first use. Then we sign the challenge.
iOS (Swift):
// Using DeviceCheck or CryptoKit
let privateKey = try SecureEnclave.P256.Signing.PrivateKey()
let signature = try privateKey.signature(for: Data(challenge.utf8))
let publicKey = privateKey.publicKey.rawRepresentation
// Send back to Rails
apiClient.post("/api/biometric/verify", json: [
"challenge": challenge,
"signature": signature.base64EncodedString(),
"public_key": publicKey.base64EncodedString(),
"device_id": deviceIdentifier
])
Android (Kotlin):
val keyStore = KeyStore.getInstance("AndroidKeyStore")
val privateKey = keyStore.getKey("biometric_key_${userId}", null) as PrivateKey
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(privateKey)
signature.update(challenge.toByteArray())
val sigBytes = signature.sign()
Step 3: Turbo Web View Injection
Once Rails verifies the signature and returns a short-lived JWT (expires in 5 minutes), the native app injects it into the Turbo web view:
let token = verificationResponse.jwt
let script = "window.__biometricToken = '\(token)';"
webView.evaluateJavaScript(script) { _, error in
// Now load the protected Turbo frame
webView.load(URLRequest(url: protectedURL))
}
In your JavaScript (or Stimulus controller), you attach this token to every sensitive fetch:
Turbo.session.adapter.fetch = (request, visitor) => {
if (window.__biometricToken) {
request.headers['X-Biometric-Auth'] = window.__biometricToken;
}
return originalFetch(request, visitor);
};
Step 4: Rails Verification Middleware
Finally, a Rails before_action for sensitive endpoints:
class ApplicationController < ActionController::Base
before_action :verify_biometric_for_sensitive_actions
private
def verify_biometric_for_sensitive_actions
return unless sensitive_action?
token = request.headers['X-Biometric-Auth']
payload = BiometricTokenDecoder.decode(token)
unless payload && payload['user_id'] == current_user.id && payload['exp'] > Time.now.to_i
render json: { error: "biometric_required" }, status: :unauthorized
end
end
end
The Art of the Fallback (Because Biometrics Fail)
Here’s where senior devs earn their salt. Biometrics fail all the time:
- Wet fingers on a fingerprint sensor
- Face ID with a mask (post-2020)
- User who disabled biometrics in settings
- Hardware failure
Your Turbo Native app must degrade gracefully.
We built a state machine in the web view:
// Stimulus controller for sensitive actions
export default class extends Controller {
async submit(event) {
event.preventDefault();
// Check if we have a fresh biometric token
if (!window.__biometricToken || this.isTokenExpired()) {
// Call native bridge to request biometric re-auth
const token = await window.TurboNative.requestBiometric();
window.__biometricToken = token;
}
this.element.submit();
}
}
And on the native side, TurboNative.requestBiometric() reprompts and returns a new token. This way, a user can do ten transfers in a row and only authenticate once every 5 minutes (or every transfer, depending on risk).
We also added a password fallback—because a user with a broken Face ID sensor shouldn't be locked out of their money. The fallback triggers a separate OTP flow, and we record it in the audit log.
The Human Truth: Users Want Speed, But They Accept Ritual
After six months of logs, we found that 92% of biometric attempts succeeded on the first try. The 8% that failed? Most were “finger moved too fast” or “face not recognized.” Only 0.3% were actual security failures.
We learned to show gentle error messages instead of scary ones:
- ❌ “Authentication failed” → ✅ “Face ID didn’t recognize you. Try adjusting the angle.”
- ❌ “Biometric not available” → ✅ “Use your passcode to continue.”
And we added a visual cue in the Turbo web view—a small face/fingerprint icon that fills with color when the token is fresh. Users started looking for it. It became a trust signal.
That’s the art. Not the cryptography. The feeling of being secure.
The One Thing I’d Never Do Again
We initially tried storing the biometric token in localStorage so it survives page reloads. Terrible idea. A malicious web view script could read it. Now we keep it in native memory and only inject it when needed. Turbo’s page:before-unload clears it.
Also, never use biometrics as the only factor for high-value actions. Always require a recent (within 5 minutes) re-verification. Our lawsuit taught us that.
The Masterpiece: Invisible, Unforgettable
Today, our banking app has processed over $50M in transfers using this handshake. Users don’t think about it. They just tap, look at the camera, and the money moves. When we A/B tested removing the biometric icon (just to see if anyone noticed), support tickets about “the app feels less secure” spiked 40%.
That’s when I knew we’d made art. Not the code. The trust.
So go build your handshake. Respect the Secure Enclave. Write the middleware. And when a user says “I don’t know how it works, but I know it works,” pour yourself a drink. You’ve earned it.
Top comments (0)