Beyond JWTs: A Practical Guide to Implementing Passkeys with WebAuthn

The Future is Passwordless: Why Your Next Auth System Should Use Passkeys

For decades, we've treated passwords as a necessary evil. We store hashed versions in our databases, enforce complex rotation policies, and layer on multi-factor authentication, yet they remain the web's single largest attack vector. Even modern token-based systems using JWTs, while excellent for authorizing API access, can't protect a user who is tricked into entering their credentials on a convincing phishing site. The fundamental problem is the 'shared secret'—something the user knows and the server knows. As long as that secret can be stolen, your system is vulnerable.

This is where Passkeys enter the picture. Backed by a powerful consortium of tech leaders including Apple, Google, and Microsoft under the FIDO Alliance umbrella, Passkeys represent a fundamental shift in digital identity. They replace vulnerable, shared secrets with secure, phishing-resistant public-key cryptography, offering a login experience that is simultaneously more secure and dramatically more seamless for the user.

This article is a developer-focused, practical roadmap to this new frontier. We will demystify the core technologies, walk through the implementation flow of the WebAuthn API step-by-step, and provide a clear strategy for integrating a Passkey-first authentication flow into your existing JWT-based application infrastructure. It's time to stop patching a broken model and start building the future of authentication.

Understanding the Passwordless Landscape: Passkeys, FIDO2, and WebAuthn

Demystifying the Terminology

The world of passwordless authentication is filled with acronyms. Let's clarify the key players and how they relate to one another to build a solid foundation.

  • WebAuthn: This is the core technology you will interact with as a web developer. WebAuthn (Web Authentication) is a W3C standard and browser API that exposes public-key cryptography to web applications. It allows a website to register and authenticate users with built-in authenticators (like Windows Hello or Face ID) or external security keys (like a YubiKey) without ever using a password.
  • FIDO2: FIDO2 is the larger, overarching project from the FIDO (Fast Identity Online) Alliance. It's the umbrella term that encompasses both WebAuthn (the browser API component) and CTAP (Client to Authenticator Protocol). CTAP is the protocol that allows browsers and operating systems to communicate with the authenticators. You won't interact with CTAP directly, but it's the magic that makes everything work under the hood.
  • Passkeys: This is the user-friendly, consumer-facing brand for the credentials created by the WebAuthn/FIDO2 standards. When a user creates a 'passkey' for your site on their iPhone, it's a discoverable FIDO credential that syncs across their iCloud Keychain. This allows them to sign in on their MacBook or iPad without re-registering. The term 'Passkey' simplifies the concept for end-users, hiding the complex cryptography behind a friendly and understandable name.

Why Passkeys Are a Game-Changer for Security and UX

Passkeys aren't just an incremental improvement; they represent a paradigm shift in how we approach digital security and user experience.

  • Phishing Resistance by Design: A passkey is cryptographically bound to the origin (i.e., your website's domain) where it was created. When a user attempts to log in, the browser checks this origin. A user on a phishing site like your-bank.scam.com cannot use a passkey created for your-bank.com. The browser simply won't present the passkey as an option. This mechanism, combined with public-key cryptography, makes it mathematically impossible to phish a user's credential because no secret is ever transmitted.
  • Server-Side Security: A major benefit is what you don't have to store. With passkeys, your database contains only a user's public key, a credential ID, and some metadata. There are no password hashes or shared secrets. If your database is breached, attackers gain nothing that can be used to impersonate your users on your site or any other site. The risk of credential stuffing and password reuse attacks is completely eliminated.
  • Frictionless User Experience: The user flow is remarkably simple. Registration and login are typically a single tap or click, followed by a familiar biometric prompt (Face ID, Touch ID, fingerprint scan) or the device's PIN. Because major platform vendors (Apple, Google, Microsoft) sync these passkeys through their cloud services, a user who registers on their phone can seamlessly log in on their laptop without any extra steps. This removes the friction of forgotten passwords and clumsy MFA codes, leading to higher conversion and retention rates.

The Core Implementation Flow: Registration and Authentication

Part 1: The Registration Ceremony (Creating a Passkey)

Creating a passkey is a multi-step dance between your server (the Relying Party or RP), the client's browser, and the authenticator. We call this the 'registration ceremony.'

  1. Server-Side: Generate Options: The process begins when the user indicates they want to create a passkey. Your server generates a set of registration options. Critically, this object includes a unique, randomly generated, and single-use challenge string, along with information about your service (the Relying Party), and the user's identity (their user ID and name).
  2. Client-Side: Trigger the API: Your frontend receives these options from the server and passes them to the navigator.credentials.create({ publicKey: options }) method. This JavaScript call invokes the browser's WebAuthn API, which in turn prompts the user to create a new passkey using their device's platform authenticator (e.g., Face ID, Windows Hello).
  3. Authenticator: Generate Keys: The user's authenticator (e.g., the Secure Enclave in an iPhone) performs the core cryptographic work. It creates a new, unique public/private key pair. The private key is stored securely on the device and never leaves it. The authenticator then returns the newly created public key, a unique Credential ID, and attestation data (proof of where and how the key was created) to the browser.
  4. Server-Side Validation: Store the Credential: The frontend sends this entire payload back to your server. Your server's job is to perform a series of critical validations: verify that the challenge matches the one you originally sent, check that the origin is correct, and parse the attestation data. If everything is valid, you store the Credential ID, the public key, and a signature counter in your database, associating them with the user's account.

Part 2: The Authentication Ceremony (Logging In)

Once a user has a passkey registered, logging in is a similar but distinct process called the 'authentication ceremony.'

  1. Server-Side: Generate a Challenge: When a user wants to log in, your server again generates a unique, single-use challenge. You also look up the Credential IDs associated with that user in your database and send them to the client as a hint.
  2. Client-Side: Request Assertion: The frontend receives the options and calls navigator.credentials.get({ publicKey: options }). This prompts the browser to look for passkeys associated with your website's domain. If it finds one (or more), it will ask the user to verify their identity.
  3. Authenticator: Sign the Challenge: The user authenticates with their biometric or PIN. The authenticator then uses the securely stored private key to create a cryptographic signature over the challenge and other client data. This signature is proof that the user possesses the private key corresponding to the public key you have on file.
  4. Server-Side Validation: Verify the Signature: The frontend sends the authenticator's response, including the signature, back to your server. Your server retrieves the user's stored public key from the database. It then uses this public key to verify that the signature is valid for the challenge you sent. It also checks that the signature counter has incremented to prevent replay attacks. If the signature is valid, the user is successfully authenticated, and you can establish a session for them.

Practical Implementation: Code Snippets and Server-Side Logic

Setting Up Your Server (The Relying Party)

While you could implement the WebAuthn specification from scratch, it is complex and fraught with security pitfalls. It is highly recommended to use a well-maintained, server-side library to handle the heavy lifting.

  • Choosing a Library: Strong choices exist for most major platforms. For Node.js, @simplewebauthn/server is a fantastic, feature-rich option. Python developers often turn to py_webauthn, and similar libraries exist for Go, Rust, Java, and PHP. These libraries simplify generating challenges, verifying attestation, and validating assertions.
  • Database Schema: You'll need a new table to store passkey credentials. A typical schema would look something like this:
CREATE TABLE user_credentials (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  -- The credential ID is stored as raw bytes, often represented as Base64URL
  credential_id VARBINARY(255) NOT NULL UNIQUE,
  -- The public key in COSE format, stored as raw bytes
  public_key VARBINARY(255) NOT NULL,
  -- The signature counter, used to prevent replay attacks
  sign_count BIGINT UNSIGNED NOT NULL,
  -- List of transports (e.g., 'internal', 'usb', 'hybrid') for the authenticator
  transports VARCHAR(255),
  FOREIGN KEY (user_id) REFERENCES users(id)
);
  • Backend Example (Generating Registration Options): Here is a conceptual Node.js example using simplewebauthn to generate the options you'd send to the client.
import { generateRegistrationOptions } from '@simplewebauthn/server';

// --- In your Express route handler ---
async function getRegistrationOptions(req, res) {
  const user = await User.findById(req.session.userId);

  const options = await generateRegistrationOptions({
    rpName: 'ToolShelf Blog',
    rpID: 'toolshelf.dev', // Your domain
    userID: user.id,
    userName: user.username,
    // Don't prompt for resident keys for this example
    authenticatorSelection: {
      residentKey: 'discouraged',
    },
    // Exclude credentials the user has already registered
    excludeCredentials: user.credentials.map(cred => ({
      id: cred.credentialID,
      type: 'public-key',
      transports: cred.transports,
    })),
  });

  // Store the challenge in the user's session to verify it later
  req.session.currentChallenge = options.challenge;

  res.json(options);
}

Wiring Up the Frontend with JavaScript

The frontend is responsible for calling the navigator.credentials API and communicating with your backend. One crucial detail is handling the data formats.

  • Handling Array Buffers: The WebAuthn API works with ArrayBuffer objects for binary data like IDs and public keys. However, JSON, the standard for web APIs, cannot represent ArrayBuffers directly. The common practice is to convert these buffers to Base64URL strings before sending them to the server, and have the server decode them back into buffers for processing. Helper functions are essential for this.
  • Frontend Example (Registration): This snippet shows how to call create(), handle the ArrayBuffers, and send the result to your server.
// Helper function to convert ArrayBuffer to Base64URL string
function bufferToBase64URL(buffer) {
  const str = window.btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
  return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

async function registerPasskey() {
  // 1. Fetch registration options from the server
  const resp = await fetch('/generate-registration-options');
  const options = await resp.json();

  // Note: The server sends challenges as strings. The API needs ArrayBuffers.
  // A good client-side library like @simplewebauthn/browser handles this for you.
  // For simplicity, we assume options are correctly formatted here.

  // 2. Call the WebAuthn API
  let attestation;
  try {
    attestation = await navigator.credentials.create({ publicKey: options });
  } catch (error) {
    console.error('Passkey creation failed:', error);
    return;
  }

  // 3. Convert binary data to Base64URL and send to server for verification
  const verificationJSON = {
    id: attestation.id,
    rawId: bufferToBase64URL(attestation.rawId),
    response: {
      clientDataJSON: bufferToBase64URL(attestation.response.clientDataJSON),
      attestationObject: bufferToBase64URL(attestation.response.attestationObject),
    },
    type: attestation.type,
  };

  await fetch('/verify-registration', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(verificationJSON),
  });
}
  • Frontend Example (Login): The login flow is very similar, but calls get() instead of create().
async function loginWithPasskey() {
  // 1. Fetch assertion options from the server
  const resp = await fetch('/generate-authentication-options');
  const options = await resp.json();

  // 2. Call the WebAuthn API
  let assertion;
  try {
    assertion = await navigator.credentials.get({ publicKey: options });
  } catch (error) {
    console.error('Passkey authentication failed:', error);
    return;
  }

  // 3. Convert to JSON-friendly format and send for verification
  const verificationJSON = {
    id: assertion.id,
    rawId: bufferToBase64URL(assertion.rawId),
    response: {
      clientDataJSON: bufferToBase64URL(assertion.response.clientDataJSON),
      authenticatorData: bufferToBase64URL(assertion.response.authenticatorData),
      signature: bufferToBase64URL(assertion.response.signature),
      userHandle: bufferToBase64URL(assertion.response.userHandle),
    },
    type: assertion.type,
  };

  await fetch('/verify-authentication', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(verificationJSON),
  });
}

Integrating Passkeys with Your Existing JWT-Based System

Passkeys for Authentication, JWTs for Authorization

A common point of confusion is whether Passkeys replace JWTs. They do not. They are complementary technologies that solve different problems.

  • Clarifying the roles: Think of it this way: Passkeys and WebAuthn are responsible for Authentication. They answer the question, 'Is this person really who they claim to be?'. JWTs (JSON Web Tokens) are typically used for Authorization. They answer the question, 'Now that we know who this person is, what are they allowed to do?'.
  • The Modern Auth Flow: The ideal integration combines the strengths of both. The user logs in with a passkey, which is a stateful, challenge-response interaction with your server. Once your backend has successfully verified the passkey signature, it confirms the user's identity. At that point, the server generates and signs a short-lived JWT containing the user's ID, roles, and permissions, and sends it back to the client. The client can then include this JWT as a bearer token in the Authorization header for all subsequent, stateless API requests.
  • Best of Both Worlds: This model is incredibly powerful. You get the phishing-proof, frictionless login experience of Passkeys at the entry point, and the stateless, scalable, and widely-supported API authorization of JWTs for your application's ongoing operations. Your protected API endpoints don't need to know anything about WebAuthn; they just need to validate a JWT, as they likely already do.

Strategies for a Hybrid System

You don't have to switch your entire user base to Passkeys overnight. A gradual, hybrid approach is the most practical path forward.

  • Add Passkeys to Existing Accounts: Start by treating a passkey as an additional authentication method, much like you would add an authenticator app for 2FA. In the user's account settings page, provide an option to 'Add a passkey'. This allows your most engaged and security-conscious users to opt-in first. They can still sign in with their password if they lose access to their devices.
  • Graceful Login UI: Design your login form to accommodate both methods. Instead of a single username/password form, present a username field first. Once the user enters their email or username and leaves the field, you can make a quick API call to check if that user has a passkey registered. If they do, you can prominently display a 'Sign in with a passkey' button, making it the primary, recommended action, while still offering a 'Use password instead' link as a fallback.
  • Conditional UI (Autofill): For the most seamless experience, implement Conditional UI. This is enabled by setting mediation: 'conditional' in your navigator.credentials.get() call. This tells the browser to attach the passkey prompt directly to your username input field. When the user clicks on the field, their passkey(s) will appear in the browser's autofill suggestions, right alongside saved passwords. This allows for a true one-tap login experience directly from the form field they're already used to, creating a powerful and intuitive flow.

Conclusion: Embrace the Passwordless Future

Passkeys are not a distant, futuristic concept; they are a production-ready technology available in all major browsers today, offering a monumental leap forward for both web security and user experience. By replacing shared secrets with public-key cryptography, they effectively solve the phishing problem that has plagued the web for decades, while providing users with the simple, one-tap login they expect.

As we've seen, the key takeaway for developers is that WebAuthn is the mechanism for robust authentication, and it pairs perfectly with your existing JWT-based systems for session management and authorization. You don't need to throw away your current infrastructure; you can enhance it by layering the superior security of Passkeys on top.

The path forward is clear. Start experimenting with a server-side WebAuthn library in a test environment today. Map out a plan to introduce Passkeys as an optional sign-in method for your users. By embracing this technology now, you can put your application and your users at the forefront of a more secure, seamless, and passwordless future.

Stay secure & happy coding,
— ToolShelf Team