Passwordless Authentication in Node.js with Passkeys and WebAuthn
Lukas Schneider
DevOps Engineer · Leapcell

Introduction
In today's interconnected digital world, user authentication stands as a critical barrier protecting sensitive information. For decades, passwords have been the ubiquitous gatekeepers, yet they also remain a significant vulnerability. Weak, reused, or compromised passwords are a leading cause of data breaches, placing a substantial burden on both users and developers. Users grapple with remembering complex credentials, while developers strive to implement robust password policies and secure storage mechanisms.
The advent of Passkeys, built upon the foundation of WebAuthn (Web Authentication), offers a compelling solution to this long-standing dilemma. By leveraging strong cryptographic techniques and device-specific security features, Passkeys promise a future where password-related anxieties are a thing of the past. This article delves into how we can integrate this cutting-edge, passwordless authentication method into Node.js applications, enhancing security, improving user experience, and simplifying development.
Unpacking the Fundamentals
Before diving into the implementation details, it's crucial to understand the core concepts that underpin Passkeys and WebAuthn.
-
WebAuthn (Web Authentication API): This is a W3C standard that defines an API for creating strong, attested, scoped, and public key-based credentials. It enables web applications to interact with authenticators (like biometric sensors, hardware security keys, or integrated software authenticators) for user verification. Instead of transmitting passwords, WebAuthn exchanges cryptographic keys, making it inherently resistant to phishing and other credential-based attacks.
-
Authenticator: An authenticator is a device or software component responsible for generating and storing cryptographic keys and performing cryptographic operations. Examples include a laptop's TPM (Trusted Platform Module), a phone's secure enclave, facial recognition systems, fingerprint scanners, or external FIDO2 security keys.
-
Relying Party (RP): In the WebAuthn context, the Relying Party is your web application or service that wants to authenticate a user. Your Node.js backend will act as the Relying Party server.
-
Attestation: This is an optional but powerful feature where the authenticator provides proof of its authenticity and characteristics to the Relying Party during credential creation. It helps the RP verify that the authenticator is genuine and meets certain security standards.
-
Passkey: A Passkey is a user-friendly abstraction built on top of WebAuthn. It's essentially a cryptographically secure credential that allows users to sign in to websites and applications without a password. Passkeys are typically synchronized across a user's devices (e.g., via iCloud Keychain, Google Password Manager, or Microsoft Authenticator), making them widely accessible and convenient. When you use a Passkey, you're using a public/private key pair. The private key never leaves your device, and only the public key is sent to the server.
The fundamental principle revolves around public-key cryptography. During registration, the user's device (authenticator) generates a unique public/private key pair for your application. The public key is securely sent to your Node.js server and stored. The private key remains on the user's device, protected by a local authentication mechanism (like a PIN, fingerprint, or face scan). For subsequent logins, the server challenges the client to prove ownership of the private key by signing a random nonce. If the signature is valid, the user is authenticated.
Building Passwordless Authentication
Let's walk through the implementation of Passkey-based authentication in a Node.js application. We'll use the popular npm @simplewebauthn
libraries, which abstract away much of the underlying WebAuthn complexity.
First, install the necessary packages:
npm install @simplewebauthn/server @simplewebauthn/browser express body-parser cookie-parser
Server-Side Implementation (Node.js)
Our Node.js server will handle generating registration challenges, verifying registration responses, generating authentication challenges, and verifying authentication responses.
// server.js const express = require('express'); const bodyParser = require('body-parser'); const cookieParser = require('cookie-parser'); const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } = require('@simplewebauthn/server'); const app = express(); const port = 3000; app.use(bodyParser.json()); app.use(cookieParser()); // In a real application, you'd store this in a database const users = new Map(); // Map to store user details const authenticators = new Map(); // Map to store user's registered authenticators // Relying Party (RP) configuration const rpName = 'My Awesome App'; const rpID = 'localhost'; // Should be your domain in production (e.g., 'example.com') const origin = `http://localhost:${port}`; // User session (for simplicity, we'll use a basic session object) const sessions = new Map(); // userId -> { challenge: string } // 1. Registration Challenge Generation app.post('/register/start', async (req, res) => { const { username } = req.body; if (!username) { return res.status(400).json({ message: 'Username is required' }); } let user = users.get(username); if (!user) { user = { id: username, // For simplicity, using username as ID username: username, authenticators: [], // Store this user's registered authenticators }; users.set(username, user); authenticators.set(username, []); // Initialize authenticators for this user } try { const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, attestationType: 'none', // 'none' is good for most cases authenticatorSelection: { residentKey: 'required', // Make sure the authenticator creates a discoverable credential (Passkey) userVerification: 'preferred', // Prefer user verification (PIN, biometrics) }, // Exclude existing authenticators to prevent re-registration excludeCredentials: authenticators.get(username).map((auth) => ({ id: auth.credentialID, type: 'public-key', transports: auth.transports, })), }); // Store the challenge in the session for verification later sessions.set(user.id, { challenge: options.challenge }); return res.json(options); } catch (error) { console.error('Error generating registration options:', error); return res.status(500).json({ message: 'Failed to start registration' }); } }); // 2. Registration Verification app.post('/register/finish', async (req, res) => { const { username, attResp } = req.body; if (!username || !attResp) { return res.status(400).json({ message: 'Username and attestation response are required' }); } const user = users.get(username); if (!user) { return res.status(404).json({ message: 'User not found' }); } const session = sessions.get(user.id); if (!session || !session.challenge) { return res.status(400).json({ message: 'No active registration session found' }); } try { const verification = await verifyRegistrationResponse({ response: attResp, expectedChallenge: session.challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, // Set to true if you always require biometrics/PIN }); const { verified, registrationInfo } = verification; if (verified && registrationInfo) { const { credentialPublicKey, credentialID, counter } = registrationInfo; const newAuthenticator = { credentialID: credentialID, credentialPublicKey: credentialPublicKey, counter: counter, // These are important for excludeCredentials in future registrations transports: attResp.response.transports, }; const userAuthenticators = authenticators.get(username); userAuthenticators.push(newAuthenticator); authenticators.set(username, userAuthenticators); sessions.delete(user.id); // Clear the challenge return res.json({ verified: true, message: 'Registration successful!' }); } else { return res.status(400).json({ verified: false, message: 'Registration failed' }); } } catch (error) { console.error('Error verifying registration response:', error); return res.status(500).json({ message: 'Failed to finish registration' }); } }); // 3. Authentication Challenge Generation app.post('/login/start', async (req, res) => { const { username } = req.body; // Can be omitted if Passkeys are discoverable let userAuthenticators = []; if (username) { userAuthenticators = authenticators.get(username) || []; } // If username is not provided, the browser can prompt for it // Or, if discoverable credentials are used, the user picks from a list try { const options = await generateAuthenticationOptions({ rpID, // If username is provided, match against known credentials allowCredentials: userAuthenticators.map((auth) => ({ id: auth.credentialID, type: 'public-key', transports: auth.transports, })), userVerification: 'preferred', timeout: 60000, }); // Store the challenge for verification sessions.set(username || 'anonymous_login', { challenge: options.challenge }); return res.json(options); } catch (error) { console.error('Error generating authentication options:', error); return res.status(500).json({ message: 'Failed to start login' }); } }); // 4. Authentication Verification app.post('/login/finish', async (req, res) => { const { username, authnResp } = req.body; if (!authentnResp) { return res.status(400).json({ message: 'Authentication response is required' }); } // For discoverable credentials, the authenticator might send back the credential ID // which allows us to find the user. let user; let registeredAuthenticator; // Find the authenticator used for login let foundAuth = false; for (const [key, auths] of authenticators.entries()) { registeredAuthenticator = auths.find( (auth) => auth.credentialID.toString('base64url') === authnResp.rawId ); if (registeredAuthenticator) { user = users.get(key); foundAuth = true; break; } } if (!foundAuth || !user) { return res.status(400).json({ verified: false, message: 'Authenticator not found for user' }); } const sessionKey = user.id; // Use user ID for session key const session = sessions.get(sessionKey); if (!session || !session.challenge) { return res.status(400).json({ message: 'No active authentication session found' }); } try { const verification = await verifyAuthenticationResponse({ response: authnResp, expectedChallenge: session.challenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: registeredAuthenticator, requireUserVerification: false, }); const { verified, authenticationInfo } = verification; if (verified) { // Update authenticator's counter to prevent replay attacks registeredAuthenticator.counter = authenticationInfo.newAuthenticatorInfo.counter; // In a real app, you'd update this in your database sessions.delete(sessionKey); // Clear the challenge // Establish user session (e.g., set a cookie) res.cookie('loggedInUser', user.id, { httpOnly: true, secure: false }); // Use secure: true in production return res.json({ verified: true, message: 'Authentication successful!', userId: user.id }); } else { return res.status(400).json({ verified: false, message: 'Authentication failed' }); } } catch (error) { console.error('Error verifying authentication response:', error); return res.status(500).json({ message: 'Failed to finish login' }); } }); app.get('/dashboard', (req, res) => { const userId = req.cookies.loggedInUser; if (userId) { return res.send(`Welcome to your dashboard, ${userId}!`); } res.status(401).send('Unauthorized'); }); // Serve a basic HTML page for client-side interaction app.get('/', (req, res) => { res.sendFile(__dirname + '/index.html'); }); app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });
Client-Side Implementation (HTML/JavaScript)
The client-side code will interact with the browser's WebAuthn API using @simplewebauthn/browser
.
<!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Passkey Demo</title> <style> body { font-family: sans-serif; margin: 20px; } div { margin-bottom: 10px; } input { padding: 8px; margin-right: 5px; } button { padding: 10px 15px; cursor: pointer; } </style> </head> <body> <h1>Passkey Authentication Demo</h1> <div> <h2>Register (Create a Passkey)</h2> <input type="text" id="regUsername" placeholder="Enter username"> <button onclick="startRegistration()">Register</button> <p id="regMessage"></p> </div> <div> <h2>Login (Use your Passkey)</h2> <input type="text" id="loginUsername" placeholder="Enter username (optional for discoverable passkeys)"> <button onclick="startLogin()">Login</button> <p id="loginMessage"></p> </div> <script type="module"> import { startRegistration, startAuthentication } from 'https://unpkg.com/@simplewebauthn/browser@latest/dist/bundle/index.mjs'; const regUsernameInput = document.getElementById('regUsername'); const regMessage = document.getElementById('regMessage'); const loginUsernameInput = document.getElementById('loginUsername'); const loginMessage = document.getElementById('loginMessage'); window.startRegistration = async () => { const username = regUsernameInput.value; if (!username) { regMessage.textContent = 'Please enter a username for registration.'; return; } try { // 1. Get registration options from the server const resp = await fetch('/register/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), }); const options = await resp.json(); if (resp.status !== 200) { regMessage.textContent = `Error: ${options.message}`; return; } // 2. Ask the browser to create a credential const attestationResponse = await startRegistration(options); // 3. Send the credential back to the server for verification const verificationResp = await fetch('/register/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, attResp: attestationResponse }), }); const result = await verificationResp.json(); if (result.verified) { regMessage.textContent = 'Registration successful! You can now log in.'; regUsernameInput.value = ''; } else { regMessage.textContent = `Registration failed: ${result.message}`; } } catch (error) { console.error('Registration error:', error); regMessage.textContent = `Registration failed: ${error.message}`; } }; window.startLogin = async () => { const username = loginUsernameInput.value; // Optional for discoverable passkeys try { // 1. Get authentication options from the server const resp = await fetch('/login/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), // Send username if provided }); const options = await resp.json(); if (resp.status !== 200) { loginMessage.textContent = `Error: ${options.message}`; return; } // 2. Ask the browser to authenticate using a credential const assertionResponse = await startAuthentication(options); // 3. Send the assertion back to the server for verification const verificationResp = await fetch('/login/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, authnResp: assertionResponse }), }); const result = await verificationResp.json(); if (result.verified) { loginMessage.textContent = `Login successful! Welcome, ${result.userId}!`; loginUsernameInput.value = ''; // Redirect or update UI to show logged-in state window.location.href = '/dashboard'; } else { loginMessage.textContent = `Login failed: ${result.message}`; } } catch (error) { console.error('Login error:', error); loginMessage.textContent = `Login failed: ${error.message}`; } }; </script> </body> </html>
Running the Demo
- Save the Node.js code as
server.js
and the HTML code asindex.html
in the same directory. - Run
node server.js
from your terminal. - Open
http://localhost:3000
in your web browser.
You can now try registering a new user with a Passkey (which might use your device's biometrics or PIN) and then logging in using that Passkey.
Key Aspects and Considerations:
- Discoverable Credentials (Resident Keys): By setting
residentKey: 'required'
during registration, we instruct the authenticator to create a Passkey that can be discovered by the Relying Party without needing a username hint. This enables true passwordless login experiences where the user just clicks "Login" and selects their account. - User Verification:
userVerification: 'preferred'
(or'required'
) ensures that the user's presence and consent are verified locally on the authenticator (e.g., via fingerprint, facial recognition, or PIN) before the private key is used. This adds another strong layer of security. - Credential Storage: In a production environment, the
users
andauthenticators
maps would be replaced by a secure database. Each authenticator'scredentialID
,credentialPublicKey
, andcounter
are crucial and must be stored reliably. - Counter Increment: The
counter
value returned during authentication must be verified by the server to be strictly greater than the previously storedcounter
for that authenticator. This prevents replay attacks. - Cross-Device Synchronization: Passkeys automatically synchronize across a user's devices if they use a compatible password manager (e.g., Apple Keychain, Google Password Manager), providing a seamless experience across their ecosystem.
- Security Best Practices:
- Always use HTTPS in production for all WebAuthn communication.
- Ensure your
rpID
andorigin
match your production domain. - Protect your server's private keys and secrets.
- Implement robust session management after successful authentication.
Conclusion
Passkeys, powered by WebAuthn, represent a monumental leap forward in user authentication. By eliminating the weakest link—passwords—they offer superior security against phishing, credential stuffing, and other common attacks, while simultaneously providing a significantly smoother, passwordless experience for users. Integrating Passkeys into Node.js applications is now a streamlined process, thanks to libraries like @simplewebauthn
, paving the way for a more secure and user-friendly digital future. Embrace Passkeys to build applications that are not only robustly secure but also a joy to use.