Multi-Factor Authentication (MFA) is a method of verifying a user’s identity by requiring more than one form of evidence, such as something they know, something they have, and something they are. In this guide, we’ll dive into implementing two popular MFA methods: Time-Based One-Time Passwords (TOTP) and Web Authentication (WebAuthn).

What is Time-Based One-Time Password (TOTP)?

Time-Based One-Time Password (TOTP) is a type of one-time password algorithm that generates a unique passcode every 30 seconds based on a shared secret key between the authentication server and the user’s device. TOTP is widely used in applications like Google Authenticator, Authy, and many others.

What is Web Authentication (WebAuthn)?

Web Authentication (WebAuthn) is a W3C standard that enables strong, phishing-resistant authentication using public key cryptography. Unlike TOTP, which relies on a shared secret, WebAuthn uses asymmetric keys generated by the authenticator (such as a hardware security key or built-in authenticator in devices like smartphones and laptops).

Why choose TOTP and WebAuthn for MFA?

TOTP and WebAuthn offer different strengths:

  • TOTP: Easy to implement, widely supported, and doesn’t require special hardware.
  • WebAuthn: More secure, resistant to phishing, and supports biometric authentication methods.

Setting up TOTP

Let’s start by setting up TOTP for MFA.

Step-by-step Guide

Install a TOTP library

Choose a library that suits your programming language. For Node.js, `speakeasy` is a good choice.

Generate a secret key

Create a secret key that will be shared between the server and the user's device.

Display the QR code

Encode the secret key into a QR code that the user can scan with their TOTP app.

Verify the TOTP code

Check the TOTP code provided by the user against the expected value generated by the server.

Code Example

Here’s how you can set up TOTP using the speakeasy library in Node.js.

Install the library

npm install speakeasy qrcode

Generate a secret key and QR code

const speakeasy = require('speakeasy');
const qr = require('qrcode');

// Generate a secret key
const secret = speakeasy.generateSecret({ length: 20 });

// Display the QR code URL
qr.toDataURL(secret.otpauth_url, function(err, image_data) {
    console.log(image_data); // This is the QR code URL
});

Verify the TOTP code

// Assume `token` is the code entered by the user
const token = '123456';

// Verify the token
const verified = speakeasy.totp.verify({
    secret: secret.base32,
    encoding: 'base32',
    token: token
});

console.log(verified ? 'Token is valid' : 'Invalid token');

Security Considerations

⚠️ Warning: Never store the secret key in plain text. Use a secure method to store it, such as environment variables or a secure vault.

Setting up WebAuthn

Next, let’s integrate WebAuthn into your application.

Step-by-step Guide

Register the user

Create a registration ceremony where the user's authenticator generates a public/private key pair.

Store the public key

Save the public key generated during registration for later verification.

Authenticate the user

Initiate an authentication ceremony where the user proves possession of the private key.

Verify the signature

Check the signature provided by the user's authenticator to ensure it's valid.

Code Example

Here’s a basic example using the simple-webauthn-server and simple-webauthn-browser libraries.

Install the libraries

npm install @simplewebauthn/server @simplewebauthn/browser

Register the user

Server-side

const { generateRegistrationOptions, verifyRegistrationResponse } = require('@simplewebauthn/server');

// Generate registration options
const registrationOptions = generateRegistrationOptions({
    rpName: 'Example Corp.',
    rpID: 'example.com',
    userID: 'unique-user-id',
    userName: '[email protected]',
    userDisplayName: 'John Doe',
    attestationType: 'none',
    supportedAlgorithmIDs: [-7, -257],
});

// Send `registrationOptions` to the client

Client-side

import { startRegistration } from '@simplewebauthn/browser';

// Assume `registrationOptions` is received from the server
const credential = await startRegistration(registrationOptions);

// Send `credential` back to the server

Server-side (verify)

const expectedChallenge = 'expected-challenge'; // Store this securely during registration

const verification = await verifyRegistrationResponse({
    credential: credential,
    expectedChallenge: expectedChallenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com',
});

const { verified, registrationInfo } = verification;

if (verified) {
    const { credentialPublicKey, credentialID, counter } = registrationInfo;
    // Save `credentialPublicKey`, `credentialID`, and `counter` for future authentication
}

Authenticate the user

Server-side

const { generateAuthenticationOptions, verifyAuthenticationResponse } = require('@simplewebauthn/server');

// Generate authentication options
const authenticationOptions = generateAuthenticationOptions({
    allowCredentials: [
        {
            id: Buffer.from('credentialID', 'base64url'),
            type: 'public-key',
            transports: ['usb', 'nfc', 'ble', 'internal'],
        },
    ],
    userVerification: 'preferred',
});

// Send `authenticationOptions` to the client

Client-side

import { startAuthentication } from '@simplewebauthn/browser';

// Assume `authenticationOptions` is received from the server
const assertion = await startAuthentication(authenticationOptions);

// Send `assertion` back to the server

Server-side (verify)

const expectedChallenge = 'expected-challenge'; // Store this securely during authentication

const verification = await verifyAuthenticationResponse({
    credential: assertion,
    expectedChallenge: expectedChallenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com',
    authenticator: {
        credentialPublicKey: Buffer.from('credentialPublicKey', 'base64url'),
        credentialID: Buffer.from('credentialID', 'base64url'),
        counter: 1, // The counter value from the previous authentication
    },
});

const { verified, authenticationInfo } = verification;

if (verified) {
    const { newCounter } = authenticationInfo;
    // Update the counter value in your database
}

Security Considerations

🚨 Security Alert: Ensure that challenges are unique and unpredictable to prevent replay attacks.

Comparison: TOTP vs WebAuthn

ApproachProsConsUse When
TOTPEasy to implement, widely supportedLess secure, vulnerable to phishingBasic MFA requirement, no special hardware
WebAuthnMore secure, phishing-resistant, supports biometricsRequires user consent, some devices may not support itStrong security, high assurance required

Quick Reference

📋 Quick Reference

  • speakeasy.generateSecret() - Generates a secret key for TOTP
  • speakeasy.totp.verify() - Verifies a TOTP code
  • generateRegistrationOptions() - Generates options for WebAuthn registration
  • verifyRegistrationResponse() - Verifies WebAuthn registration response
  • generateAuthenticationOptions() - Generates options for WebAuthn authentication
  • verifyAuthenticationResponse() - Verifies WebAuthn authentication response

Troubleshooting Common Issues

Error: Invalid TOTP code

Ensure that:

  • The secret key is correctly shared between the server and the user’s device.
  • The server’s clock is synchronized with NTP.

Error: Registration failed

Check that:

  • The user’s authenticator supports the required algorithms.
  • The challenge is unique and unpredictable.

Error: Authentication failed

Verify that:

  • The public key and counter are correctly stored and retrieved.
  • The challenge matches the expected value.

Key Takeaways

🎯 Key Takeaways

  • TOTP is easy to implement but less secure compared to WebAuthn.
  • WebAuthn offers stronger security and supports biometric authentication.
  • Both methods require careful handling of secrets and challenges to prevent security vulnerabilities.

Implementing TOTP and WebAuthn in your application can significantly enhance security by adding an additional layer of authentication. Choose the method that best fits your security requirements and user base. Happy coding!