Practical FIDO: Implementing Passwordless Login

Introduction

Managing passwords for different sites is not only easy to forget, but also vulnerable to theft or reuse. If you can complete registration and login by scanning a fingerprint or using facial recognition — that’s the vision promoted by FIDO (Fast IDentity Online).

With FIDO you get the following benefits:

  • The server doesn’t store passwords, so there’s nothing to steal
  • Attackers can’t intercept credentials over the network because the private key is never transmitted
  • Binding to the site’s Origin and enforcing HTTPS ensures secure connections. Even if a user lands on a phishing site, the browser won’t allow it.
  • Biometrics can be used to strengthen the “unlock private key” step

FIDO Overview

Use asymmetric cryptography to authenticate, abandoning usernames and passwords entirely
  • Challenge: a one-time random string for each request, used for signing with the private key
  • Signature: the operation or result of signing the challenge with the private key
  • FIDO Registration: the user generates a public/private key pair; the site stores the public key and the user keeps the private key
  • FIDO Login: verify the user possesses the private key and that the response was generated for the current challenge (prevents replay attacks)
    • Server generates a challenge
    • The user’s device signs the challenge with the private key
    • Server verifies the signature using the public key
  • Biometrics: a way for the user to unlock the private key locally, e.g., fingerprint
  • Passkey: a mechanism to manage private keys. It’s a keychain system built on top of private key technology that makes keys understandable, manageable, and less likely to be lost

How is FIDO possible?

FIDO is possible because browsers provide the Web Authentication API🔗 to interact with private keys securely, perform user verification, bind to Origin, and devices.

  • User → navigator.credentials.create()

    • OS / hardware help generate the key
    • Private key is locked on the device
    • Public key is given to the server
  • User → navigator.credentials.get()

    • Use private key to sign the challenge
    • Return the signature
    • Server verifies using the public key

FIDO Flow Diagrams

Registration

RP ServerFIDO Client(e.g. browser, app)Authenticator(e.g. phone, security key)UserRP ServerFIDO Client(e.g. browser, app)Authenticator(e.g. phone, security key)UserAuthenticator generates a brand-newKey Pair for this RP IDAuthenticator securely seals the Private Key in hardware chipand signs the challenge and related data to create an AttestationServer verifies the signature and checks the challenge matchesUpon success, binds the user's Public Key to the account and stores it in the databasealt[Registration Successful][Registration Failed]Click Register / Bind Security Device (Passkey)1Send registration initialization request2Generate and return Challenge,RP ID, and user info3Invoke WebAuthn API, pass challenge and registration parameters4Prompt user to authorize credential creation5Perform local verification (e.g. fingerprint, face recognition, PIN)6Return attestation object (containing Public Key and signature)7Forward attestation object to server to complete registration8Registration complete9Display device binding success message10Reject registration (e.g. verification error or timeout)11Display registration failure message12

Login

RP ServerFIDO Client(e.g. browser, app)Authenticator(e.g. phone fingerprint, security key)UserRP ServerFIDO Client(e.g. browser, app)Authenticator(e.g. phone fingerprint, security key)UserUpon successful verification, authenticator retrievesthe registered Private Key and uses it to digitally sign the ChallengeServer retrieves the Public Key bound to the accountverifies the signature and confirms the challenge has not been tampered withalt[Verification Successful][Verification Failed]Click Login (enter username or passwordless login)1Send login request2Generate and return Challenge3Pass challenge and RP ID to authenticator4Prompt user to perform local verification5Perform biometric verification (e.g. fingerprint / face) or enter PIN6Return signature result (Assertion)7Send assertion and verification data back to server8Allow login, issue Session / Token9Login successful, enter the system10Reject login11Display login failure message12

Implementation

Registration

  • Phase 1: Begin

    1. The user, after signing in or registering, requests to bind a Passkey.
    2. Frontend calls: POST /auth/passkey/register/begin.
    3. Backend returns:
      • challenge: a one-time random string.
      • user info: includes id, name, displayName.
      • relying party (RP): site domain or rpId.
      • pubKeyCredParams: supported crypto algorithms.
    4. Frontend runs navigator.credentials.create():
      • Generates public/private keys and performs Attestation (proves key origin and legitimacy)
  • Phase 2: Finish

    1. Frontend sends the result back to backend: POST /auth/passkey/register/finish.
    2. Backend performs multiple verifications:
      • Consistency: whether the challenge matches the one previously issued (prevent replay attacks).
      • Legitimacy: whether the Attestation is valid (confirm device and key are trusted).
      • Origin: whether Origin / rpId is correct (prevent phishing).
    3. On successful verification, store:
      • public key, credential id, counter, associated with the user.
    4. Completion: the user has successfully bound a Passkey.

Login

  • Phase 1: Begin

    1. Use FIDO to log in
    2. Frontend calls: POST /auth/passkey/login/begin.
    3. Backend returns:
      • challenge: the challenge string.
      • allowCredentials: the user’s registered Credential IDs (leave empty to use Discoverable Credentials mode).
    4. Frontend runs navigator.credentials.get():
      • User verification: unlock the private key via fingerprint, Face ID, or PIN.
      • Signing: sign the challenge with the private key.
  • Phase 2: Finish

    1. Frontend sends the signature result back to backend: POST /auth/passkey/login/finish.
    2. Backend verification logic:
      • Challenge: whether the challenge matches.
      • Signature: verify the signature with the public key from the database.
      • Counter: ensure the counter is greater than the previous value (prevent replay or cloned devices).
      • Origin: whether Origin / rpId is correct.
    3. On successful verification:
      • Update the counter in the database.
      • Establish login state (issue Session or JWT).
    4. Completion: achieve secure passwordless login.

Discoverable Credentials (Resident Keys)

With traditional Non-Discoverable credentials, the server must know who you are first (you enter an account) so it can look up the Credential ID and send it to the authenticator. The authenticator decrypts and then signs with the private key.

Modern authenticators have enough space. During registration, they not only generate key pairs but also store the private key, user ID, account name, and the site’s domain (RP ID) on the authenticator.

At login, the server doesn’t need to know who you are. It can ask the authenticator: “Do you have credentials for this site (RP ID)?” The authenticator will locally “discover” those credentials and present a chooser letting you pick which account to use, without entering an account name.

Registration

navigator.credentials.create({
publicKey: {
// ... Other settings
authenticatorSelection: {
authenticatorAttachment: "platform",
// Requires the creation of discoverable credentials
residentKey: "required",
userVerification: "required"
}
}
});
  • residentKey
    • required: must create a discoverable credential. If unable, the system returns NotSupportedError.
    • preferred: RP prefers a discoverable credential but accepts non-discoverable.
    • discouraged: RP prefers non-discoverable credentials but accepts discoverable ones.
  • requireResidentKey: For backward compatibility with the older WebAuthn Level 1 specification, this property is retained. If residentKey is required, set this to true; otherwise set it to false.

Login

When initiating an authentication request, you don’t need to include specific Credential IDs in allowCredentials.

navigator.credentials.get({
publicKey: {
challenge: serverGeneratedChallenge,
rpId: "yourdomain.com",
// Leave blank or do not provide an allowCredentials array
// The authenticator will automatically "discover" all available accounts locally based on the rpId.
allowCredentials: [],
userVerification: "required"
}
});

Conclusion

The FIDO specification🔗 defines clear verification steps to avoid security pitfalls and accounts for differences across browsers and platforms. In practice it’s best to rely on existing libraries to handle the nuances — which is why many FIDO libraries provide functions like verifyRegistrationResponse() and verifyAuthenticationResponse() ready to use.

Further reading