Skip to main content
green gradient background, "The Future of Application Security Is Already Here." and a read the report button.
Passkeys in Production Banking SystemsFIDO & Passkeys
6 min readFor SecOps Engineers

Passkeys in Production Banking Systems

Banks authenticate millions of sessions daily using a mix of methods: passwords reset every 90 days, SMS codes that often arrive late, push notifications users might approve accidentally, and voice calls that frustrate customers. Each method carries risk. Phishing kits harvest passwords and OTP codes in real time. SIM-swap attacks redirect SMS. Push fatigue leads to accidental approvals.

Passkeys built on FIDO2 standards eliminate credential phishing entirely. The private key never leaves the user's device. There's nothing to intercept, no code to manipulate, no password database to breach. For your team supporting retail or commercial banking platforms, passkeys improve security and user experience simultaneously.

Here's how to implement them in a production banking environment.

The Problem

Your authentication stack probably includes:

  • Password policies requiring 12+ characters, special symbols, rotation every 60-90 days
  • SMS or email OTP as a second factor
  • Push notifications to a mobile app
  • Security questions for account recovery

Each introduces friction. Password resets consume helpdesk time. SMS delivery fails internationally or during carrier outages. Push notifications train users to tap "Approve" reflexively.

More critically, every method except hardware security keys remains vulnerable to real-time phishing. Attackers proxy the legitimate login page, capture credentials and OTP codes as users enter them, then replay those credentials before the OTP expires. Your MFA doesn't stop this; it just adds one more field to the phishing form.

Passkeys solve this because the cryptographic challenge is origin-bound. The private key will only sign challenges from the registered domain. A phishing site at bankofamerica-verify.com can't trigger a signature bound to bankofamerica.com. The authentication ceremony fails silently. The user never realizes they were on a fake site, because the passkey simply doesn't work there.

What You Need Before Starting

Platform requirements:

  • WebAuthn API support in your web application (available in all modern browsers since 2019)
  • FIDO2 server library or service (examples: Yubico's java-webauthn-server, Duo's py_webauthn, or a managed service like Auth0, Okta, or Microsoft Entra ID)
  • HTTPS on all authentication endpoints (WebAuthn requires secure context)

Infrastructure:

  • Session management that can handle credential IDs and challenge-response pairs
  • Database schema to store: user ID, credential ID (public key identifier), public key, sign count, authenticator metadata
  • Logging and monitoring for registration and authentication events

User-facing:

  • Enrollment flow UI (modal or dedicated page)
  • Fallback authentication method during transition period (you can't force all users to enroll simultaneously)
  • Clear messaging about what passkeys are and why users should register them

Team readiness:

  • Frontend engineers familiar with JavaScript navigator.credentials API
  • Backend engineers who understand public-key cryptography basics (you don't need to implement the crypto, but you need to validate responses correctly)
  • Support team training on passkey troubleshooting (lost device, new device setup)

Step-by-Step Implementation

1. Deploy FIDO2 Server Component

If you're using a managed identity provider, enable passkey support in the admin console. For self-hosted:

# Example: Yubico java-webauthn-server
git clone https://github.com/Yubico/java-webauthn-server.git
cd java-webauthn-server
mvn package

Configure relying party settings:

{
  "rpId": "yourbank.com",
  "rpName": "YourBank",
  "origin": "https://login.yourbank.com",
  "attestation": "none",
  "timeout": 60000
}

rpId must match your domain. Attestation can remain "none" for most banking use cases; you're verifying the user owns the device, not auditing device provenance.

2. Add Registration Endpoint

Your backend generates a challenge and sends registration options to the client:

// Backend (Node.js example)
const { generateRegistrationOptions } = require('@simplewebauthn/server');

app.post('/passkey/register/options', async (req, res) => {
  const user = req.session.user; // Already authenticated via existing method
  
  const options = generateRegistrationOptions({
    rpName: 'YourBank',
    rpID: 'yourbank.com',
    userID: user.id,
    userName: user.email,
    attestationType: 'none',
    authenticatorSelection: {
      authenticatorAttachment: 'platform', // Prefer device biometrics
      requireResidentKey: true,
      userVerification: 'required'
    }
  });
  
  req.session.challenge = options.challenge;
  res.json(options);
});

requireResidentKey: true creates a discoverable credential (the passkey is stored on the device and can be used without entering a username first).

3. Build Frontend Registration Flow

Trigger registration after the user authenticates with their existing method:

// Frontend
async function registerPasskey() {
  const optionsResp = await fetch('/passkey/register/options', { method: 'POST' });
  const options = await optionsResp.json();
  
  const credential = await navigator.credentials.create({
    publicKey: options
  });
  
  const verifyResp = await fetch('/passkey/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credential)
  });
  
  if (verifyResp.ok) {
    alert('Passkey registered successfully');
  }
}

The browser prompts for biometric (Face ID, Touch ID, Windows Hello) or device PIN. The user never sees a password field.

4. Verify and Store Credential

Backend validates the registration response:

app.post('/passkey/register/verify', async (req, res) => {
  const { verifyRegistrationResponse } = require('@simplewebauthn/server');
  
  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge: req.session.challenge,
    expectedOrigin: 'https://login.yourbank.com',
    expectedRPID: 'yourbank.com'
  });
  
  if (verification.verified) {
    // Store in database
    await db.passkeys.insert({
      userId: req.session.user.id,
      credentialId: verification.registrationInfo.credentialID,
      publicKey: verification.registrationInfo.credentialPublicKey,
      counter: verification.registrationInfo.counter
    });
    res.sendStatus(200);
  } else {
    res.sendStatus(400);
  }
});

5. Implement Authentication Flow

When a user returns to log in:

// Backend: generate authentication options
app.post('/passkey/login/options', async (req, res) => {
  const options = generateAuthenticationOptions({
    rpID: 'yourbank.com',
    userVerification: 'required'
  });
  
  req.session.challenge = options.challenge;
  res.json(options);
});

// Frontend: authenticate
async function loginWithPasskey() {
  const optionsResp = await fetch('/passkey/login/options', { method: 'POST' });
  const options = await optionsResp.json();
  
  const credential = await navigator.credentials.get({
    publicKey: options
  });
  
  const verifyResp = await fetch('/passkey/login/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credential)
  });
  
  if (verifyResp.ok) {
    window.location.href = '/dashboard';
  }
}

Backend verifies the signature:

app.post('/passkey/login/verify', async (req, res) => {
  const credential = await db.passkeys.findOne({
    credentialId: req.body.id
  });
  
  const verification = await verifyAuthenticationResponse({
    response: req.body,
    expectedChallenge: req.session.challenge,
    expectedOrigin: 'https://login.yourbank.com',
    expectedRPID: 'yourbank.com',
    authenticator: {
      credentialID: credential.credentialId,
      credentialPublicKey: credential.publicKey,
      counter: credential.counter
    }
  });
  
  if (verification.verified) {
    req.session.user = await db.users.findOne({ id: credential.userId });
    await db.passkeys.update(
      { credentialId: credential.credentialId },
      { counter: verification.authenticationInfo.newCounter }
    );
    res.sendStatus(200);
  } else {
    res.sendStatus(401);
  }
});

The counter prevents replay attacks. Each authentication increments it; if you receive a counter lower than stored, reject the attempt.

Validation

Test registration:

  • Register a passkey on macOS (Touch ID), Windows (Windows Hello), Android (fingerprint), iOS (Face ID)
  • Verify credential appears in database with correct user mapping
  • Confirm counter initializes properly

Test authentication:

  • Log in using each registered passkey
  • Check session establishment and counter increment
  • Attempt login from a different origin (should fail silently)
  • Try replaying a captured authentication response (should fail due to challenge mismatch)

Test cross-device:

  • Register passkey on iPhone
  • Scan QR code on laptop to authenticate using iPhone passkey (FIDO2 cross-device flow)

Security validation:

  • Run a phishing simulation: set up a lookalike domain, attempt to capture passkey authentication (it won't work)
  • Verify HTTPS enforcement: authentication should fail over HTTP
  • Check origin validation: authentication from evil.com should not succeed even with valid credential

Maintenance

Monitor authentication metrics:

  • Passkey registration rate (target: 60%+ of active users within 6 months)
  • Passkey authentication success rate (should exceed 95%)
  • Fallback method usage (declining over time)

Handle lost devices:

  • Provide account recovery flow requiring email + security questions or support call
  • Allow users to view and revoke registered passkeys in account settings
  • Log all passkey deletions for audit

Update dependencies:

  • Track FIDO Alliance spec updates
  • Update WebAuthn libraries quarterly
  • Test new browser versions in staging before production rollout

User communication:

  • Monthly reminder emails for users who haven't registered passkeys
  • In-app prompts after successful password login: "Upgrade to passkey"
  • Support documentation with screenshots for each platform

Audit logging:

  • Log registration events: timestamp, user ID, authenticator type
  • Log authentication events: success/failure, credential ID used, IP address
  • Alert on anomalies: multiple failed attempts, credential used from new geographic region

Passkeys don't eliminate all authentication risk, but they remove the entire category of credential phishing. For banks, that's the majority of account takeover attempts. Implementation takes 2-4 weeks for a competent engineering team. The security return justifies the effort.

a promotional banner asking how ready are you for PCI DSS 4.0? With a call-to-action to get the checklist now.

You Might Also Like