<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ #webauthn - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ #webauthn - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 03 Nov 2026 09:34:27 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/webauthn/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build Passkey Authentication with WebAuthn, Next.js and NestJS ]]>
                </title>
                <description>
                    <![CDATA[ Passwords are a pain for both users and service providers․ Users often find it difficult to remember passwords, and the risk of phishing and password reuse across multiple websites is high. Developers ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-passkey-authentication-with-webauthn-next-js-and-nestjs/</link>
                <guid isPermaLink="false">6ae0e2ec42a07b949a9f1206</guid>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ passwordless authentication  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ passkeys ]]>
                    </category>
                
                    <category>
                        <![CDATA[ nestjs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #webauthn ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Roland Mweze ]]>
                </dc:creator>
                <pubDate>Tue, 27 Oct 2026 17:44:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/416b0a8e-1b49-4945-9072-37d69280b6af.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Passwords are a pain for both users and service providers․ Users often find it difficult to remember passwords, and the risk of phishing and password reuse across multiple websites is high.</p>
<p>Developers have to hash passwords, salt them, rate-limit login attempts, and build "forgot password" flows that nobody enjoys using.</p>
<p>Fortunately, passkeys address many of these issues․ Instead of providing a password‚ users can confirm their identity with a different type of authenticator they use on other devices‚ like fingerprints, facial recognition, or a PIN․ The authenticator signs a challenge using a cryptographic key stored on the device and is never shared with others․</p>
<p>This tutorial shows you how to build an app that doesn't use traditional passwords. The backend is a NestJS API that stores users and their passkeys in PostgreSQL. The frontend is a Next.js app where users sign up and sign in with nothing but an email address and a passkey.</p>
<p>You'll use <a href="https://simplewebauthn.dev/docs/">SimpleWebAuthn</a>, a well-maintained TypeScript library to simplify tasks involving WebAuthn cryptography and data parsing․</p>
<p>By the end of this article, you should have what you need to implement passkey registration and authentication for your website and validate passkeys on your server securely. You'll also learn about potential risks and problems with passkeys that could impact your app or user accounts so you can take steps to prevent them.<br><br>You can find the complete source code on <a href="https://github.com/RolandM99/passwordless-auth">GitHub</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><a href="#heading-the-problems-with-password-based-authentication">The Problems with Password-Based Authentication</a></li>
<li><a href="#heading-how-passkeys-solve-these-problems">How Passkeys Solve These Problems</a></li>
<li><a href="#heading-prerequisites">Prerequisites</a></li>
<li><a href="#heading-what-are-passkeys">What Are Passkeys?</a></li>
<li><a href="#heading-how-passkey-authentication-works">How Passkey Authentication Works</a></li>
<li><a href="#heading-project-structure">Project Structure</a></li>
<li><a href="#heading-how-to-set-up-the-nestjs-backend">How to Set Up the NestJS Backend</a><ul>
<li><a href="#heading-create-the-database">Create the Database</a></li>
<li><a href="#heading-add-environment-variables">Add Environment Variables</a></li>
<li><a href="#heading-connect-to-postgresql">Connect to PostgreSQL</a></li>
</ul>
</li>
<li><a href="#heading-how-to-define-the-database-entities">How to Define the Database Entities</a></li>
<li><a href="#heading-how-to-register-a-passkey-on-the-server">How to Register a Passkey on the Server</a><ul>
<li><a href="#heading-generate-registration-options">Generate Registration Options</a></li>
<li><a href="#heading-verify-the-registration-response">Verify the Registration Response</a></li>
</ul>
</li>
<li><a href="#heading-how-to-sign-in-with-a-passkey-on-the-server">How to Sign In with a Passkey on the Server</a><ul>
<li><a href="#heading-generate-authentication-options">Generate Authentication Options</a></li>
<li><a href="#heading-verify-the-authentication-response">Verify the Authentication Response</a></li>
</ul>
</li>
<li><a href="#heading-how-to-expose-the-auth-endpoints">How to Expose the Auth Endpoints</a><ul>
<li><a href="#heading-how-to-protect-routes-with-a-jwt">How to Protect Routes with a JWT</a></li>
<li><a href="#heading-how-to-register-the-auth-module">How to Register the Auth Module</a></li>
</ul>
</li>
<li><a href="#heading-how-to-set-up-the-nextjs-frontend">How to Set Up the Next.js Frontend</a></li>
<li><a href="#heading-how-to-build-the-sign-up-page">How to Build the Sign Up Page</a></li>
<li><a href="#heading-how-to-build-the-sign-in-page">How to Build the Sign In Page</a></li>
<li><a href="#heading-how-to-show-helpful-error-messages">How to Show Helpful Error Messages</a></li>
<li><a href="#heading-how-to-test-your-app">How to Test Your App</a></li>
<li><a href="#heading-security-mistakes-to-avoid">Security Mistakes to Avoid</a><ul>
<li><a href="#heading-encoding-the-credential-id-twice">Encoding the Credential ID Twice</a></li>
<li><a href="#heading-clearing-the-challenge-with-undefined">Clearing the Challenge with undefined</a></li>
<li><a href="#heading-looking-up-a-credential-without-checking-its-owner">Looking Up a Credential Without Checking Its Owner</a></li>
<li><a href="#heading-letting-sign-up-add-passkeys-to-existing-accounts">Letting Sign Up Add Passkeys to Existing Accounts</a></li>
<li><a href="#heading-asking-for-verification-without-requiring-it">Asking for Verification Without Requiring It</a></li>
</ul>
</li>
<li><a href="#heading-where-to-go-from-here">Where to Go From Here</a></li>
<li><a href="#heading-conclusion">Conclusion</a></li>
</ul>
<h1 id="heading-the-problems-with-password-based-authentication">The Problems with Password-Based Authentication</h1>
<p>Before we write a single line of code, we need to clearly define what we're replacing.</p>
<p>For decades, password-based authentication has been the default login method. And it has proven its effectiveness, which is why it became the standard. But the model it relies on&nbsp;(<em>a secret that a person must memorize, enter, and keep to themselves</em>) has significant limitations. Every aspect of this model is problematic, as we'll now see.</p>
<h3 id="heading-1-phishing-attacks">1. Phishing Attacks</h3>
<p>A password will work anywhere it's entered. If an attacker builds a page that's identical to your bank's login page, then the password will work just as well on that page. The only thing stopping the user from being compromised is the fact that they might notice that the URL looks a little odd.<br><br>That's a lot to ask. Phishing pages are often very close to being pixel-perfect, and they come in the form of urgent emails or text messages designed to get you to act immediately. Even a very careful, technically minded user can be fooled.</p>
<p>One-time passwords sent via SMS or from an authenticator app are of course useful, but people can still be tricked into typing those in on a fake page.</p>
<h3 id="heading-2-password-reuse">2. Password Reuse</h3>
<p>Most people have dozens, if not hundreds of online accounts. You can't possibly remember a unique, strong password for each one, so you'll invariably reuse the same few passwords across the board.<br><br>Attackers know this. If one site is breached, they'll take the email and password pairs that have been leaked and try them automatically against other sites. That's <strong>credential stuffing</strong>. A breach at a little forum you've long since forgotten can result in someone getting into your email or your bank account.</p>
<h3 id="heading-3-data-breaches">3. Data Breaches</h3>
<p>Every site that uses passwords has to store something that can be used to verify the password. The ideal situation is for the site to store salted hashes rather than the passwords themselves. But not all sites do this well, and hashes of weak passwords can still be cracked offline once an attacker has a copy of the database.<br><br>This is clearly the developers' burden. You have to choose the right hashing algorithm, tune it, protect the database, and still treat a leak as a disaster for your users.</p>
<h3 id="heading-4-lack-of-privacy">4. Lack of Privacy</h3>
<p>With passwords, users pass a shared secret to every site they sign up with. They have to trust that site to protect it, and they have no control over what happens to it once it's been breached.</p>
<p>As people reuse the same email and password combinations, it's possible for leaked credentials to be matched across breaches. That, in turn, makes it much easier to link accounts across services and build up a profile of a person's online behaviour.</p>
<h3 id="heading-5-credential-fatigue">5. Credential Fatigue</h3>
<p>Then there's the day-to-day frustration. You're constantly juggling password policies (at least one symbol, no more than 16 characters, and so on), forced resets every few months, security questions, verification codes...the list goes on. Every new account you create just adds to the pile.</p>
<p>This fatigue leads people straight into the behaviour that makes passwords so insecure: short passwords, small variations of the same password, and passwords written on sticky notes. It also costs the business. People drop off during the sign-up process, get locked out, and then contact support to reset passwords they've forgotten.</p>
<h1 id="heading-how-passkeys-solve-these-problems">How Passkeys Solve These Problems</h1>
<p>Passkeys remove the shared secret entirely. <a href="https://passkeys.dev/docs/intro/what-are-passkeys/">passkeys.dev</a> describes them as intuitive, automatically unique, breach-resistant, and phishing-resistant.</p>
<p>Here's how that lines up with each problem:</p>
<table>
<thead>
<tr>
<th>Problem</th>
<th>How passkeys solve it</th>
</tr>
</thead>
<tbody><tr>
<td>Phishing attacks</td>
<td>The browser and operating system only use a passkey on the site that created it. A fake domain doesn't get anywhere, no matter how convincing it looks.</td>
</tr>
<tr>
<td>Password reuse</td>
<td>Every passkey is a new key pair created for one site, so there's nothing to reuse.</td>
</tr>
<tr>
<td>Data breaches</td>
<td>The server only stores a public key. A leaked database gives attackers nothing they can sign in with.</td>
</tr>
<tr>
<td>Lack of privacy</td>
<td>Nothing secret is shared with the site, and keys are different for every site. Fingerprint and face data don't leave the device.</td>
</tr>
<tr>
<td>Credential fatigue</td>
<td>There's nothing to create or remember. Users confirm with the same fingerprint, face, or screen lock they use multiple times a day.</td>
</tr>
</tbody></table>
<p>The fundamental shift concerns where trust lives. With passwords, security relies on users’ ability to spot fraudulent sites and choose strong credentials. With passkeys, it's the browser and the operating system that enforce the rules, while the user just confirms their identity.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be comfortable with TypeScript and have some React experience. You don't need to know NestJS well, but it helps to know what a controller and a service are.</p>
<p>You'll also need these tools installed:</p>
<ul>
<li><a href="https://nodejs.org/">Node.js</a> version 22 or later</li>
<li><a href="https://www.postgresql.org/download/">PostgreSQL</a>. On macOS, <a href="https://postgresapp.com/">Postgres.app</a> is the easiest option. <a href="https://www.pgadmin.org/">pgAdmin</a> is handy if you prefer a graphical tool.</li>
<li>A device that supports passkeys. Almost any modern laptop or phone works: a Mac with Touch ID, a Windows PC with Windows Hello, or an Android phone or iPhone.</li>
</ul>
<h2 id="heading-what-are-passkeys">What Are Passkeys?</h2>
<p>A passkey is a credential based on public key cryptography. When you create one, your device generates a key pair. The private key stays on your device, locked behind your fingerprint, face, or screen lock. The public key goes to the website, which stores it next to your account.</p>
<p>Passkeys are built on the <a href="https://www.w3.org/TR/webauthn-3/">Web Authentication API (WebAuthn)</a>, a W3C standard supported by all major browsers. The term "passkey" is the friendly name the industry settled on for WebAuthn credentials that can sync between devices. If you want a deeper introduction, <a href="https://passkeys.dev/docs/intro/what-are-passkeys/">passkeys.dev</a> is an excellent resource.</p>
<p>One detail often surprises developers: your app can't choose between a fingerprint, facial recognition, or a PIN code. It's the operating system that decides, based on the device's capabilities and the settings configured by the user.</p>
<p>Here's what users will typically see:</p>
<table>
<thead>
<tr>
<th>Device</th>
<th>Verification method</th>
</tr>
</thead>
<tbody><tr>
<td>Mac</td>
<td>Touch ID, or the Mac password</td>
</tr>
<tr>
<td>iPhone or iPad</td>
<td>Face ID, Touch ID, or the passcode</td>
</tr>
<tr>
<td>Android</td>
<td>Fingerprint, face unlock, or PIN, pattern, or password</td>
</tr>
<tr>
<td>Windows</td>
<td>Windows Hello face, fingerprint, or PIN</td>
</tr>
<tr>
<td>Any other computer</td>
<td>Scan a QR code and confirm on your phone</td>
</tr>
</tbody></table>
<p>What your server can do is insist that some local check happened. You'll see how in a moment.</p>
<h2 id="heading-how-passkey-authentication-works">How Passkey Authentication Works</h2>
<p>Both sign-up and sign-in follow the same three-step pattern:</p>
<ol>
<li>The server generates a random challenge and sends it to the browser along with some options.</li>
<li>The browser asks the device to verify the user and sign the challenge.</li>
<li>The browser sends the signed response back, and the server validates it.</li>
</ol>
<p>During sign-up (WebAuthn refers to this as "registration"), the device creates a new key pair and returns the public key. The server then validates the response and stores the public key.</p>
<p>During sign-in (authentication), the device signs the new challenge with the existing private key. The server then validates the signature using the public key it had previously saved.</p>
<p>Two more concepts come up throughout the code:</p>
<p>The <strong>relying party ID</strong> (RP ID) is the domain your passkeys belong to, such as <code>example.com</code>. In development, it's <code>localhost</code>. The browser will simply refuse to use a passkey on any other domain, which is what gives passkeys their resistance to phishing.</p>
<p>The <strong>user verification (UV) flag</strong> is a bit within the signed response. It's only set by the device when the user has actually completed the fingerprint, face, or screen lock check. Your server looks at this flag to ensure that it wasn't just some random user who tapped a key or clicked a button.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>The project has two separate apps in one repository:</p>
<pre><code class="language-text">passwordless-auth/
├── client/   # Next.js frontend, runs on port 3000
└── server/   # NestJS API, runs on port 4000
</code></pre>
<p>You'll build the server first, since the frontend depends on its endpoints.</p>
<h2 id="heading-how-to-set-up-the-nestjs-backend">How to Set Up the NestJS Backend</h2>
<p>Start by creating a new NestJS project with the Nest CLI:</p>
<pre><code class="language-bash">npx @nestjs/cli new server
cd server
</code></pre>
<p>Then install the dependencies for WebAuthn, the database, configuration, and JSON Web Tokens (JWTs):</p>
<pre><code class="language-bash">npm install @simplewebauthn/server @nestjs/typeorm typeorm pg @nestjs/config @nestjs/jwt @nestjs/passport passport passport-jwt
</code></pre>
<h3 id="heading-create-the-database">Create the Database</h3>
<p>Create an empty database called <code>auth</code>. From the terminal, run:</p>
<pre><code class="language-bash">createdb -h localhost -U postgres auth
</code></pre>
<p>If you prefer pgAdmin, right-click <strong>Databases</strong>, choose <strong>Create</strong>, then <strong>Database</strong>, and name it <code>auth</code>.</p>
<p>If you skip this step, NestJS will fail on startup with <code>error: database "auth" does not exist</code>. TypeORM can create tables for you, but it won't create the database itself.</p>
<h3 id="heading-add-environment-variables">Add Environment Variables</h3>
<p>Create a <code>.env</code> file in the <code>server</code> folder:</p>
<pre><code class="language-bash">JWT_SECRET=replace-with-a-long-random-string

WEBAUTHN_RP_ID=localhost
WEBAUTHN_ORIGIN=http://localhost:3000

DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=your-database-password
DB_NAME=auth
</code></pre>
<p><code>WEBAUTHN_RP_ID</code> is the domain only, without a protocol or port. <code>WEBAUTHN_ORIGIN</code> is the exact URL of your frontend. The server will reject any response that comes from a different origin.</p>
<p>Browsers only allow WebAuthn on HTTPS sites or on <code>localhost</code>, so this setup works in development without certificates.</p>
<h3 id="heading-connect-to-postgresql">Connect to PostgreSQL</h3>
<p>Create <code>src/auth/config/database.config.ts</code> to group the database settings:</p>
<pre><code class="language-typescript">import { registerAs } from '@nestjs/config';

export default registerAs('database', () =&gt; ({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT, 10),
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  entities: [__dirname + '/../**/*.entity{.ts,.js}'],
  synchronize: process.env.NODE_ENV !== 'production',
}));
</code></pre>
<p>While you're in the <code>config</code> folder, add <code>src/auth/config/jwt.config.ts</code> for the token settings:</p>
<pre><code class="language-typescript">import { registerAs } from '@nestjs/config';

export default registerAs('jwt', () =&gt; ({
  secret: process.env.JWT_SECRET,
  expiresIn: '1h',
}));
</code></pre>
<p>The <code>synchronize</code> option tells TypeORM to create and update tables based on your entities. It's convenient in development, but you should switch to migrations before going to production.</p>
<p>Then wire everything up in <code>src/app.module.ts</code>:</p>
<pre><code class="language-typescript">import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import databaseConfig from './auth/config/database.config';
import jwtConfig from './auth/config/jwt.config';
import { User, Authenticator } from './auth/entities/auth.entity';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [databaseConfig, jwtConfig],
    }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) =&gt; ({
        type: 'postgres',
        host: configService.get('database.host'),
        port: configService.get('database.port'),
        username: configService.get('database.username'),
        password: configService.get('database.password'),
        database: configService.get('database.database'),
        entities: [User, Authenticator],
        synchronize: configService.get('database.synchronize'),
      }),
      inject: [ConfigService],
    }),
    AuthModule,
  ],
})
export class AppModule {}
</code></pre>
<p>Finally, enable CORS in <code>src/main.ts</code> so the frontend on port 3000 can call the API on port 4000:</p>
<pre><code class="language-typescript">import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  app.enableCors({
    origin: true,
    credentials: true,
    allowedHeaders: ['Content-Type', 'Authorization'],
  });
  await app.listen(4000);
  console.log('App running on port 4000');
}
bootstrap();
</code></pre>
<p><code>origin: true</code> accepts requests from any origin, which keeps local development simple. Before you deploy, replace it with your frontend's URL.</p>
<h2 id="heading-how-to-define-the-database-entities">How to Define the Database Entities</h2>
<p>You need two tables here. The User table contains the account, and the Authenticator table contains the passkeys. One user can have multiple passkeys (for example, one on their laptop and one on their phone).</p>
<p>Create <code>src/auth/entities/auth.entity.ts</code>:</p>
<pre><code class="language-typescript">import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  ManyToOne,
  OneToMany,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';
import type {
  CredentialDeviceType,
  Base64URLString,
} from '@simplewebauthn/server';

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column({ unique: true })
  webauthnId: Base64URLString;

  @Column({ type: 'varchar', nullable: true })
  currentChallenge?: string | null;

  @OneToMany(() =&gt; Authenticator, (authenticator) =&gt; authenticator.user)
  authenticators: Authenticator[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

@Entity()
export class Authenticator {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  credentialId: Base64URLString;

  @Column({ type: 'bytea' })
  credentialPublicKey: Uint8Array;

  @ManyToOne(() =&gt; User, (user) =&gt; user.authenticators, { onDelete: 'CASCADE' })
  user: User;

  @Column()
  webauthnUserId: Base64URLString;

  @Column({ type: 'bigint' })
  counter: number;

  @Column({
    type: 'varchar',
    length: 32,
  })
  deviceType: CredentialDeviceType;

  @Column()
  backedUp: boolean;

  @Column('simple-array', { nullable: true })
  transports?: string[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}
</code></pre>
<p>Let's take a closer look at a couple of these fields.</p>
<p>First, <code>webauthnId</code> is the user handle that gets stored on the device together with the passkey. The WebAuthn spec says it shouldn't contain personal information, so you'll fill it with random bytes instead of the email.</p>
<p>Next, <code>currentChallenge</code> holds the challenge the server is waiting for. It's set when you generate options and cleared after a successful verification. Notice the <code>string | null</code> type. That matters, and you'll see why in the security section.</p>
<p><code>counter</code> is a signature counter. Some authenticators will increment it on every use, which allows the server to detect a cloned device. Synced passkeys will typically leave the counter at zero, and SimpleWebAuthn supports both scenarios.</p>
<p><code>deviceType</code> and <code>backedUp</code> tell you whether the passkey can sync across devices and whether it's currently backed up (for example, to iCloud Keychain).</p>
<p>And finally, <code>transports</code> records how the browser can get to the authenticator (built into the device, over USB, over Bluetooth, or via a phone).</p>
<h2 id="heading-how-to-register-a-passkey-on-the-server">How to Register a Passkey on the Server</h2>
<p>Create <code>src/auth/auth.service.ts</code>. Start with the class setup:</p>
<pre><code class="language-typescript">import {
  Injectable,
  UnauthorizedException,
  ConflictException,
  Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomBytes } from 'crypto';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User, Authenticator } from './entities/auth.entity';
import {
  generateRegistrationOptions,
  generateAuthenticationOptions,
  verifyRegistrationResponse,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
  AuthenticationResponseJSON,
  PublicKeyCredentialCreationOptionsJSON,
  PublicKeyCredentialRequestOptionsJSON,
  RegistrationResponseJSON,
} from '@simplewebauthn/server';

@Injectable()
export class AuthService {
  private readonly logger = new Logger(AuthService.name);
  private readonly rpName = 'Passwordless Auth';
  private readonly rpID: string;
  private readonly origin: string;

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository&lt;User&gt;,
    @InjectRepository(Authenticator)
    private readonly authenticatorRepository: Repository&lt;Authenticator&gt;,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
  ) {
    this.rpID = this.configService.get&lt;string&gt;('WEBAUTHN_RP_ID');
    this.origin = this.configService.get&lt;string&gt;('WEBAUTHN_ORIGIN');
  }
</code></pre>
<p><code>rpName</code> is the human-readable name of your app. Users will see it in the operating system's passkey prompt and later in their password manager.</p>
<h3 id="heading-generate-registration-options">Generate Registration Options</h3>
<p>The first method finds or creates the user, then asks SimpleWebAuthn for registration options. Add it inside the class:</p>
<pre><code class="language-typescript">async generateRegistrationOptions(
    email: string,
): Promise&lt;PublicKeyCredentialCreationOptionsJSON&gt; {
  let user = await this.userRepository.findOne({ where: { email } });

  if (!user) {
    user = this.userRepository.create({
      email,
      webauthnId: randomBytes(32).toString('base64url'),
    });
    await this.userRepository.save(user);
  }

  const authenticators = await this.authenticatorRepository.find({
    where: { user: { id: user.id } },
  });

  if (authenticators.length &gt; 0) {
    throw new ConflictException(
      'An account with this email already exists. Please sign in.',
    );
  }

  const options = await generateRegistrationOptions({
    rpName: this.rpName,
    rpID: this.rpID,
    userID: Buffer.from(user.webauthnId, 'base64url'),
    userName: user.email,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'required',
    },
    excludeCredentials: authenticators.map((authenticator) =&gt; ({
      id: authenticator.credentialId,
      transports: authenticator.transports,
    })),
  });

  // Save the current challenge
  user.currentChallenge = options.challenge;
  await this.userRepository.save(user);

  return options;
}
</code></pre>
<p>The <code>authenticatorSelection</code> block is where you decide what kind of passkey you want.</p>
<p><code>residentKey: 'required'</code> asks for a discoverable credential. That's what makes it a true passkey (the credential is stored on the device or in the user's password manager and can then sync to their other devices).</p>
<p><code>userVerification: 'required'</code> tells the device that it needs to verify the user (that is, check their fingerprint, face, or screen lock). Without that, some of the authenticators would just do a presence check and not actually verify the user.</p>
<p>What's missing is an authenticator attachment option. If you set it to <code>'platform'</code>, that would effectively lock users into using the sensor on their current device. If you leave it out, then they can use their phone via QR code or a hardware security key (which is what <a href="http://passkeys.dev">passkeys.dev</a> recommends).</p>
<p><code>attestationType: 'none'</code> means you're not asking the device to attest to its make and model. Most consumer apps don't need that at all, and asking for it can in fact trigger additional privacy prompts.</p>
<p><code>excludeCredentials</code> lists the passkeys the user already has so the device won't create a duplicate on an authenticator that's already registered. Given the check immediately above it, that list is always empty in this case. It becomes useful later on when you allow signed-in users to add further passkeys.</p>
<p>The check for existing authenticators is also important. This endpoint doesn't require the user to be signed in, so you can't just allow it to add a passkey to an account that already has one. Otherwise, anyone who knows your email address could attach their own passkey to your account.</p>
<h3 id="heading-verify-the-registration-response">Verify the Registration Response</h3>
<p>After the user confirms on their device, the browser sends back a response. The second method verifies it and stores the new passkey:</p>
<pre><code class="language-typescript">async verifyRegistration(email: string, response: RegistrationResponseJSON) {
  const user = await this.userRepository.findOne({
    where: { email },
    select: ['id', 'email', 'currentChallenge', 'webauthnId'],
  });

  if (!user) {
    throw new UnauthorizedException('User not found');
  }

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: user.currentChallenge,
    expectedOrigin: this.origin,
    expectedRPID: this.rpID,
    requireUserVerification: true,
  });

  const { verified, registrationInfo } = verification;

  if (verified &amp;&amp; registrationInfo) {
    const { credential, credentialDeviceType, credentialBackedUp } =
      registrationInfo;

    const newAuthenticator = this.authenticatorRepository.create({
      credentialId: credential.id,
      credentialPublicKey: credential.publicKey,
      counter: credential.counter,
      user: user,
      webauthnUserId: user.webauthnId,
      deviceType: credentialDeviceType,
      backedUp: credentialBackedUp,
      transports: response.response.transports,
    });

    await this.authenticatorRepository.save(newAuthenticator);

    user.currentChallenge = null;
    await this.userRepository.save(user);

    return {
      verified,
      user: { id: user.id, email: user.email },
    };
  }

  throw new UnauthorizedException('Registration verification failed');
}
</code></pre>
<p><code>verifyRegistrationResponse</code> is what does the hard work in this code. It checks that the challenge matches the one you saved, that the response came from your origin and RP ID, and that the signature is valid. With <code>requireUserVerification: true</code> it'll also reject the response if the UV flag isn't set. That flag is your proof that a fingerprint, face, or screen lock check took place.</p>
<p>If all goes well, you'll save the credential ID and the public key and then clear the challenge so it can't be reused.</p>
<h2 id="heading-how-to-sign-in-with-a-passkey-on-the-server">How to Sign In with a Passkey on the Server</h2>
<p>Sign-in works the same way: generate options, then verify the response.</p>
<h3 id="heading-generate-authentication-options">Generate Authentication Options</h3>
<pre><code class="language-typescript">async generateAuthenticationOptions(
    email: string,
): Promise&lt;PublicKeyCredentialRequestOptionsJSON&gt; {
  const user = await this.userRepository.findOne({ where: { email } });

  if (!user) {
    throw new UnauthorizedException('User not found');
  }

  const authenticators = await this.authenticatorRepository.find({
    where: { user: { id: user.id } },
  });

  if (authenticators.length === 0) {
    throw new UnauthorizedException(
      'No passkey registered for this account. Please sign up.',
    );
  }

  const options = await generateAuthenticationOptions({
    rpID: this.rpID,
    allowCredentials: authenticators.map((authenticator) =&gt; ({
      id: authenticator.credentialId,
      transports: authenticator.transports,
    })),
    userVerification: 'required',
  });

  // Save the current challenge
  user.currentChallenge = options.challenge;
  await this.userRepository.save(user);

  return options;
}
</code></pre>
<p><code>allowCredentials</code> lists the passkeys this user owns. The browser uses that list to find the right one on the device, and the <code>transports</code> hint helps it decide whether to look locally, on a security key, or on a phone.</p>
<h3 id="heading-verify-the-authentication-response">Verify the Authentication Response</h3>
<pre><code class="language-typescript">async verifyAuthentication(
  email: string,
  response: AuthenticationResponseJSON,
) {
  const user = await this.userRepository.findOne({
    where: { email },
    select: ['id', 'email', 'currentChallenge'],
  });

  if (!user) {
    throw new UnauthorizedException('User not found');
  }

  const authenticator = await this.authenticatorRepository.findOne({
    where: { credentialId: response.id, user: { id: user.id } },
  });

  if (!authenticator) {
    throw new UnauthorizedException('Authenticator not found');
  }

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: user.currentChallenge,
    expectedOrigin: this.origin,
    expectedRPID: this.rpID,
    credential: {
      id: authenticator.credentialId,
      publicKey: new Uint8Array(authenticator.credentialPublicKey),
      counter: Number(authenticator.counter),
      transports: authenticator.transports,
    },
    requireUserVerification: true,
  });

  const { verified } = verification;

  if (verified) {
    authenticator.counter = verification.authenticationInfo.newCounter;
    authenticator.backedUp =
      verification.authenticationInfo.credentialBackedUp;
    await this.authenticatorRepository.save(authenticator);

    user.currentChallenge = null;
    await this.userRepository.save(user);

    // Generate JWT token
    const payload = { sub: user.id, email: user.email };
    return {
      accessToken: this.jwtService.sign(payload),
      user: { id: user.id, email: user.email },
    };
  }

  throw new UnauthorizedException('Authentication verification failed');
}
</code></pre>
<p>The lookup uses both the credential ID and the user ID. Don't drop the user condition. I'll explain why in the security section.</p>
<p>The <code>Number()</code> call around the counter is there because the <code>pg</code> driver returns <code>bigint</code> columns as strings. SimpleWebAuthn expects a number.</p>
<p>Once the signature has been verified, you increment the counter, clear the challenge, and issue a JWT. From that point on, the app treats the user exactly as it would any user who has signed in with a password. The only difference is that a password wasn't used.</p>
<h2 id="heading-how-to-expose-the-auth-endpoints">How to Expose the Auth Endpoints</h2>
<p>Create <code>src/auth/auth.controller.ts</code>. It has one route for each step of the sign-up and sign-in flows, plus a protected route that lists a user's passkeys:</p>
<pre><code class="language-typescript">import { Controller, Post, Body, Get, Query, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('register/options')
  async getRegistrationOptions(
    @Body() body: { email: string },
  ): Promise&lt;PublicKeyCredentialCreationOptionsJSON&gt; {
    return this.authService.generateRegistrationOptions(body.email);
  }

  @Post('register/verify')
  async verifyRegistration(
    @Body() body: { email: string; response: any },
  ) {
    return this.authService.verifyRegistration(body.email, body.response);
  }

  @Post('login/options')
  async getAuthenticationOptions(
    @Body() body: { email: string },
  ): Promise&lt;PublicKeyCredentialRequestOptionsJSON&gt; {
    return this.authService.generateAuthenticationOptions(body.email);
  }

  @Post('login/verify')
  async verifyAuthentication(
    @Body() body: { email: string; response: any },
  ) {
    return this.authService.verifyAuthentication(body.email, body.response);
  }

  @Get('authenticators')
  @UseGuards(JwtAuthGuard)
  async getAuthenticators(@Query('email') email: string) {
    return this.authService.getAuthenticators(email);
  }
}
</code></pre>
<p>The <code>response</code> body is typed as <code>any</code> here because the service methods already declare the exact SimpleWebAuthn types they expect.</p>
<h3 id="heading-how-to-protect-routes-with-a-jwt">How to Protect Routes with a JWT</h3>
<p>The last route uses<code>JwtAuthGuard</code>, so only signed-in users can call it. It's backed by a small service method. Add it to <code>AuthService</code>:</p>
<pre><code class="language-typescript">async getAuthenticators(email: string) {
  const user = await this.userRepository.findOne({ where: { email } });
  if (!user) {
    throw new UnauthorizedException('User not found');
  }
  const authenticators = await this.authenticatorRepository.find({
    where: { user: { id: user.id } },
  });
  return authenticators;
}
</code></pre>
<p>Next, create the Passport strategy that reads and validates the token in <code>src/auth/strategies/jwt.strategy.ts</code>:</p>
<pre><code class="language-typescript">import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, email: payload.email };
  }
}
</code></pre>
<p>Then create the guard in <code>src/auth/guards/jwt-auth.guard.ts</code>. It wraps Passport's built-in guard and returns clearer error messages:</p>
<pre><code class="language-typescript">import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor() {
    super();
  }

  handleRequest(err: any, user: any, info: any) {
    if (info instanceof TokenExpiredError) {
      throw new UnauthorizedException('Token has expired');
    }

    if (info instanceof JsonWebTokenError) {
      throw new UnauthorizedException('Invalid token');
    }

    if (err || !user) {
      throw new UnauthorizedException('Authentication failed');
    }

    return user;
  }

  // Optional: Override canActivate if you need custom logic before JWT validation
  async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
    const request = context.switchToHttp().getRequest();

    const authHeader = request.headers.authorization;
    if (!authHeader) {
      throw new UnauthorizedException('No token provided');
    }

    const [type] = authHeader.split(' ');
    if (type !== 'Bearer') {
      throw new UnauthorizedException('Invalid token format');
    }
    return super.canActivate(context) as Promise&lt;boolean&gt;;
  }
}
</code></pre>
<h3 id="heading-how-to-register-the-auth-module">How to Register the Auth Module</h3>
<p>Finally, bring everything together in <code>src/auth/auth.module.ts</code>:</p>
<pre><code class="language-typescript">import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User, Authenticator } from './entities/auth.entity';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    TypeOrmModule.forFeature([User, Authenticator]),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) =&gt; ({
        secret: configService.get('JWT_SECRET'),
        signOptions: {
          expiresIn: '1d',
        },
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
</code></pre>
<p>The <code>signOptions</code> here control the tokens the service issues, so each sign-in is valid for one day.</p>
<p>Start the server:</p>
<pre><code class="language-bash">npm run start:dev
</code></pre>
<p>TypeORM will create the <code>user</code> and <code>authenticator</code> tables in your <code>auth</code> database. You can open pgAdmin and refresh the <strong>Tables</strong> node to see them.</p>
<h2 id="heading-how-to-set-up-the-nextjs-frontend">How to Set Up the Next.js Frontend</h2>
<p>From the root of the project, create a Next.js app with TypeScript, Tailwind CSS, the App Router, and a <code>src</code> folder:</p>
<pre><code class="language-bash">npx create-next-app@latest client --typescript --tailwind --app --src-dir
cd client
npm install @simplewebauthn/browser axios
</code></pre>
<p>Add a <code>.env</code> file in the <code>client</code> folder that points to the API:</p>
<pre><code class="language-bash">NEXT_PUBLIC_API_URL=http://localhost:4000
</code></pre>
<p>Then create a small API client in <code>client/src/services/api.ts</code>:</p>
<pre><code class="language-typescript">/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from 'axios';

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

export const webAuthnApi = {
  getRegistrationOptions: async (email: string) =&gt; {
    const response = await api.post('/auth/register/options', { email });
    return response.data;
  },

  verifyRegistration: async (email: string, response: any) =&gt; {
    const res = await api.post('/auth/register/verify', { email, response });
    return res.data;
  },

  getAuthenticationOptions: async (email: string) =&gt; {
    const response = await api.post('/auth/login/options', { email });
    return response.data;
  },

  verifyAuthentication: async (email: string, response: any) =&gt; {
    const res = await api.post('/auth/login/verify', { email, response });
    return res.data;
  },
};

// Add request interceptor to include JWT token
api.interceptors.request.use((config) =&gt; {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default api;
</code></pre>
<p>The interceptor at the bottom attaches the JWT to every request once the user has signed in, which is what the protected <code>/auth/authenticators</code> route expects.</p>
<h2 id="heading-how-to-build-the-sign-up-page">How to Build the Sign Up Page</h2>
<p>The frontend side of WebAuthn is surprisingly short. SimpleWebAuthn's <code>startRegistration</code> function takes the options from your server, calls the browser's WebAuthn API, and returns a response in a JSON format your server understands.</p>
<p>Create <code>client/src/app/signup/page.tsx</code>:</p>
<pre><code class="language-tsx">/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';

import React, { useState } from 'react';
import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser';
import { webAuthnApi } from '@/services/api';
import { getPasskeyErrorMessage } from '@/services/passkey';
import Link from 'next/link';
import { useRouter } from 'next/navigation';

export default function SignupPage() {
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const router = useRouter();

  const handleRegistration = async (e: React.FormEvent) =&gt; {
    e.preventDefault();
    setLoading(true);
    setError('');

    if (!browserSupportsWebAuthn()) {
      setError('This browser does not support passkeys.');
      setLoading(false);
      return;
    }

    try {
      const options = await webAuthnApi.getRegistrationOptions(email);

      const regResult = await startRegistration({ optionsJSON: options });

      const verificationResult = await webAuthnApi.verifyRegistration(email, regResult);

      if (verificationResult.verified) {
        router.push('/welcome');
      }
    } catch (err: any) {
      console.error('Registration error:', err);
      setError(getPasskeyErrorMessage(err, 'Registration failed. Please try again.'));
    } finally {
      setLoading(false);
    }
  };

  return (
    &lt;main className="min-h-screen flex items-center justify-center bg-gray-300"&gt;
      &lt;div className="bg-white p-8 rounded-lg shadow-sm w-full max-w-md"&gt;
        &lt;div className="space-y-6"&gt;
          &lt;div className="text-center"&gt;
            &lt;h1 className="text-3xl font-bold tracking-tight"&gt;Sign up&lt;/h1&gt;
            &lt;p className="text-sm text-gray-600 mt-2"&gt;Create your account&lt;/p&gt;
          &lt;/div&gt;

          &lt;form onSubmit={handleRegistration} className="space-y-4"&gt;
            &lt;div className="space-y-2"&gt;
              &lt;label htmlFor="email" className="block text-sm font-medium text-gray-700"&gt;
                Email
              &lt;/label&gt;
              &lt;input
                id="email"
                type="email"
                required
                value={email}
                onChange={(e) =&gt; setEmail(e.target.value)}
                className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                placeholder="Type your email"
              /&gt;
            &lt;/div&gt;

            {error &amp;&amp; (
              &lt;div className="text-sm text-red-600"&gt;
                {error}
              &lt;/div&gt;
            )}

            &lt;button
              type="submit"
              disabled={loading}
              className="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#3B97B6] hover:bg-[#2d7591] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
            &gt;
              {loading ? 'Creating passkey...' : 'Create a passkey'}
            &lt;/button&gt;
            &lt;p className="text-xs text-center text-gray-500"&gt;
              You'll confirm with your fingerprint, face, or screen lock.
            &lt;/p&gt;
          &lt;/form&gt;

          &lt;div className="text-center text-sm"&gt;
            &lt;span className="text-gray-600"&gt;Already have an account?&lt;/span&gt;{' '}
            &lt;Link
              href="/signin"
              className="text-[#3B97B6] hover:text-[#2d7591] font-medium transition-colors"
            &gt;
              Sign in
            &lt;/Link&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>The <code>handleRegistration</code> function follows the three steps from earlier. You fetch options from the server, hand them to<code>startRegistration</code>, and send the result back for verification.</p>
<p>The call to <code>startRegistration</code> is where the magic happens. The browser opens the operating system's passkey dialog, and the user confirms with Touch ID, Face ID, Windows Hello, or their phone. Your code just waits for the promise to resolve.</p>
<p>Note the <code>{ optionsJSON: options }</code> argument. Older versions of SimpleWebAuthn accepted the options directly, and many tutorials still show it that way. Version 13 still supports the old style but logs a warning, so use the object form.</p>
<img src="https://cdn.hashnode.com/uploads/covers/679f7daff98e1199453b8c1b/812528f7-1e10-42b9-b628-143bcb715627.png" alt="Sign up page with an email field and a &quot;Create a passkey&quot; button" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-how-to-build-the-sign-in-page">How to Build the Sign In Page</h2>
<p>The <code>Sign in</code> page mirrors the <code>Sign up</code> page. Create <code>client/src/app/signin/page.tsx</code>, copy the sign up page into it, and change the headings and the link at the bottom so they point to <code>/signup</code>. Then import <code>startAuthentication</code> instead of <code>startRegistration</code>, and replace the submit handler with this one:</p>
<pre><code class="language-tsx">const handleAuthentication = async (e: React.FormEvent) =&gt; {
  e.preventDefault();
  setLoading(true);
  setError('');

  if (!browserSupportsWebAuthn()) {
    setError('This browser does not support passkeys.');
    setLoading(false);
    return;
  }

  try {
    const options = await webAuthnApi.getAuthenticationOptions(email);

    const authResult = await startAuthentication({ optionsJSON: options });

    const verificationResult = await webAuthnApi.verifyAuthentication(email, authResult);

    if (verificationResult.accessToken) {
      localStorage.setItem('token', verificationResult.accessToken);
      router.push('/welcome');
    }
  } catch (err: any) {
    console.error('Authentication error:', err);
    setError(getPasskeyErrorMessage(err, 'Authentication failed. Please try again.'));
  } finally {
    setLoading(false);
  }
};
</code></pre>
<p>Remember to pass <code>handleAuthentication</code> to the form's <code>onSubmit</code>. You can see the finished page in the <a href="https://github.com/RolandM99/passwordless-auth/blob/main/client/src/app/signin/page.tsx">GitHub repository</a>.</p>
<p>When verification succeeds, the server returns a JWT, and the page stores it before redirecting. Storing tokens in <code>localStorage</code> keeps this demo simple. In a production app, a <code>httpOnly</code> cookie set by the server is a safer choice because JavaScript running on the page can't read it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/679f7daff98e1199453b8c1b/f1df9bed-16c9-48d8-a7bb-071e9ddc607a.png" alt="Operating system passkey prompt asking the user to confirm with Touch ID" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-how-to-show-helpful-error-messages">How to Show Helpful Error Messages</h2>
<p>When something goes wrong during a passkey ceremony, the browser throws a <code>DOMException</code> with a <code>name</code> that tells you what happened. The raw messages aren't very friendly, so it's a good idea to translate the common ones.</p>
<p>Create <code>client/src/services/passkey.ts</code>:</p>
<pre><code class="language-typescript">/* eslint-disable @typescript-eslint/no-explicit-any */

// Turn errors from the browser's WebAuthn API and our backend into readable messages
export function getPasskeyErrorMessage(err: any, fallback: string): string {
  if (err?.response?.data?.message) {
    return err.response.data.message;
  }
  switch (err?.name) {
    case 'NotAllowedError':
      // User cancelled the fingerprint/face/screen-lock prompt, or it timed out
      return 'The passkey prompt was cancelled or timed out. Please try again.';
    case 'InvalidStateError':
      // A passkey for this account already exists on this authenticator
      return 'This device already has a passkey for this account. Please sign in instead.';
    case 'NotSupportedError':
      return 'This device does not support passkeys.';
    case 'SecurityError':
      return 'Passkeys are not allowed on this domain. Check the WebAuthn RP ID and origin settings.';
    default:
      return fallback;
  }
}
</code></pre>
<p><code>NotAllowedError</code> is the one you'll see most. It fires when the user closes the prompt, waits too long, or fails the biometric check. <code>SecurityError</code> usually means your RP ID doesn't match the domain the page is running on, which is a configuration problem rather than a user problem.</p>
<h2 id="heading-how-to-test-your-app">How to Test Your App</h2>
<p>Start both apps in separate terminals:</p>
<pre><code class="language-bash"># Terminal 1
cd server
npm run start:dev
</code></pre>
<pre><code class="language-bash"># Terminal 2
cd client
npm run dev
</code></pre>
<p>Go to <code>http://localhost:3000/signup</code>, enter your email, and click the button. Your device will show the prompt about the passkey. On a Mac with Touch ID, you'll be asked to touch the sensor. Otherwise, you'll need to fill the form with your MacBook user password. On Windows, Windows Hello will ask for your face, fingerprint, or PIN. If you choose to use another device, the browser shows a QR code that you can scan with your phone.</p>
<p>After you confirm, you land on the welcome page. Now go to <code>/signin</code>, enter the same email, and confirm again. This time the server returns a JWT.</p>
<p>If you don't have a passkey-enabled device available, Chrome has a built-in virtual authenticator. Open DevTools, click the three-dot menu, then go to <strong>More tools</strong> and choose <strong>WebAuthn</strong>. Enable the virtual authenticator environment and add an authenticator with <strong>"Supports user verification"</strong> checked. Chrome will then simulate a passkey device for you.</p>
<p>You can also open pgAdmin and look at the <code>authenticator</code> table. You'll see the credential ID, the public key stored as bytes, and the device type. There's no password and nothing secret in there.</p>
<h2 id="heading-security-mistakes-to-avoid">Security Mistakes to Avoid</h2>
<p>Passkeys are secure by design, but the server code around them still has to be right. I ran into each of the issues below while building this project. Some of them don't throw any errors, which is what makes them dangerous.</p>
<h3 id="heading-encoding-the-credential-id-twice">Encoding the Credential ID Twice</h3>
<p>In recent versions of <strong>SimpleWebAuthn</strong>, including version 14 used here, <code>credential.id</code> is already a base64url string. My first version of the code did this:</p>
<pre><code class="language-typescript">// Wrong: encodes an already-encoded string
credentialId: Buffer.from(credential.id).toString('base64url'),
</code></pre>
<p><code>Buffer.from</code> treats the string as UTF-8 text, so the result is a base64url encoding of a base64url string. Registration succeeds, but sign-in can never find the credential because the browser sends the original ID. Store <code>credential.id</code> as it is.</p>
<h3 id="heading-clearing-the-challenge-with-undefined">Clearing the Challenge with <code>undefined</code></h3>
<p>Every challenge should be used only once. This line looks like it clears it:</p>
<pre><code class="language-typescript">user.currentChallenge = undefined;
</code></pre>
<p>But TypeORM skips <code>undefined</code> properties when it saves an entity, so the old challenge stays in the database. Someone who captured a valid response could replay it. Set the value to <code>null</code> instead, and make sure the column type allows it.</p>
<h3 id="heading-looking-up-a-credential-without-checking-its-owner">Looking Up a Credential Without Checking Its Owner</h3>
<p>It's tempting to find the authenticator by credential ID alone:</p>
<pre><code class="language-typescript">// Wrong: any user's passkey matches
where: { credentialId: response.id }
</code></pre>
<p><strong>But here's the problem:</strong> the attacker asks for sign-in options for your email, signs your challenge with their own passkey, and the server sees their passkey, validates the signature against their public key, and then issues a token for your account. Always include the user in the lookup as per the earlier example.</p>
<h3 id="heading-letting-sign-up-add-passkeys-to-existing-accounts">Letting Sign Up Add Passkeys to Existing Accounts</h3>
<p>The registration endpoint is public. If it's going to add a passkey to an account that matches the email, then anyone who knows your email can register their own passkey and sign in as you.</p>
<p>Don't allow registration if the account already has a passkey. If you want people to be able to add more passkeys, then that can be a separate feature which requires them to be signed in.</p>
<h3 id="heading-asking-for-verification-without-requiring-it">Asking for Verification Without Requiring It</h3>
<p>If you request <code>userVerification: 'preferred'</code>, the device is allowed to skip the fingerprint or PIN check. If you then verify with <code>requireUserVerification: true</code>, which is SimpleWebAuthn's default, users with those devices will get confusing failures. Pick <code>'required'</code> on both sides so the options and the verification agree.</p>
<h2 id="heading-where-to-go-from-here">Where to Go From Here</h2>
<p>The app you've built covers the fundamental passkey flows. But there are a couple of enhancements you could make if you wanted to go further.</p>
<p>Passkey autofill (also known as conditional UI) will cause the browser to suggest the saved passkey directly in the email field, just as it will suggest a saved password. SimpleWebAuthn supports this via the <code>useBrowserAutofill</code> option of <code>startAuthentication</code>.</p>
<p>Users will also want to add passkeys for their other devices and remove old ones. You'd build that as a pair of endpoints behind your JWT guard.</p>
<p>Finally, challenges should expire. A simple way to do this is to store a timestamp alongside <code>currentChallenge</code> and then reject anything older than a few minutes. In a larger app, you'd probably store the challenges in a session or a cache (for example, Redis) rather than on the user record.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you've built a complete passwordless authentication system using passkeys. You've got a full NestJS API that can generate WebAuthn challenges, verify the signed responses using SimpleWebAuthn, and store the public keys in PostgreSQL. You've also got a Next.js frontend that allows users to sign up and sign in using their fingerprint, their face, or their device's screen lock.<br><br>You've now learned how the challenge and signature flow works, why the user verification flag is important, and why the server can't decide which biometric method the user will see. You've also seen a number of rather subtle bugs that can result in a sign-in failure or even an account takeover, and how to avoid them.<br><br>Passwords aren't going away any time soon, but passkeys are already supported on pretty much every device that your users have. You now know how to add them to your own apps.</p>
<p>The full source code is available on <a href="https://github.com/RolandM99/passwordless-auth">GitHub</a>. Go ahead and clone it, break it, and make it your own. If you found this tutorial useful in any way, then please do pass it on to someone who’s about to have the same question.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Set Up WebAuthn in Node.js for Passwordless Biometric Login ]]>
                </title>
                <description>
                    <![CDATA[ JWT auth feels clean until a stolen token still looks valid to your server. That's the real problem: a bearer token proves possession of a token, but it doesn't prove possession of a trusted device. I ]]>
                </description>
                <link>https://www.freecodecamp.org/news/set-up-webauthn-in-node-js-for-passwordless-biometric-login/</link>
                <guid isPermaLink="false">69bc51d6b238fd45a32f1cb0</guid>
                
                    <category>
                        <![CDATA[ #webauthn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ biometric authentication ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Sumit Saha ]]>
                </dc:creator>
                <pubDate>Thu, 19 Mar 2026 19:43:18 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8cc356c5-b011-4316-8236-a0443eb2cc03.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>JWT auth feels clean until a stolen token still looks valid to your server. That's the real problem: a bearer token proves possession of a token, but it doesn't prove possession of a trusted device. If an attacker gets a reusable token, replay starts to look like a normal login.</p>
<p>WebAuthn changes the shape of the system. The private key stays on the user's device. Your server stores a public key, a credential ID, and a counter. Each registration or login signs a fresh challenge. The browser, the authenticator, and your backend all take part in the ceremony.</p>
<p>This guide walks you through the full path in Node.js. You'll set up the backend, wire registration and login, store passkeys correctly, replace long-lived bearer auth with short server sessions, support backup devices, and add step-up verification for risky actions.</p>
<p><strong>Warning</strong>: WebAuthn works in secure contexts. Use localhost for local development, and use HTTPS everywhere else.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-jwt-alone-falls-short">Why JWT Alone Falls Short</a></p>
</li>
<li><p><a href="#heading-what-webauthn-changes">What WebAuthn Changes</a></p>
</li>
<li><p><a href="#heading-initialize-the-project">Initialize the Project</a></p>
</li>
<li><p><a href="#heading-install-dependencies">Install Dependencies</a></p>
</li>
<li><p><a href="#heading-define-the-data-model">Define the Data Model</a></p>
</li>
<li><p><a href="#heading-build-the-server-foundation">Build the Server Foundation</a></p>
</li>
<li><p><a href="#heading-registration-ceremony">Registration Ceremony</a></p>
</li>
<li><p><a href="#heading-authentication-ceremony">Authentication Ceremony</a></p>
</li>
<li><p><a href="#heading-what-replaces-the-long-lived-jwt">What Replaces the Long-Lived JWT</a></p>
</li>
<li><p><a href="#heading-multi-device-and-recovery-logic">Multi-Device and Recovery Logic</a></p>
</li>
<li><p><a href="#heading-step-up-authentication-for-sensitive-actions">Step-Up Authentication for Sensitive Actions</a></p>
</li>
<li><p><a href="#heading-recap">Recap</a></p>
</li>
<li><p><a href="#heading-try-it-yourself">Try it Yourself</a></p>
</li>
<li><p><a href="#heading-final-words">Final Words</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along and get the most out of this guide, you should have:</p>
<ul>
<li><p>Basic knowledge of JavaScript and Node.js.</p>
</li>
<li><p>Basic knowledge of TypeScript and Express.</p>
</li>
<li><p>Basic frontend to backend flow. You should be able to follow <code>fetch()</code> requests from the browser to the server and back.</p>
</li>
<li><p>A modern browser and a passkey-capable authenticator, such as Touch ID, Face ID, Windows Hello, Android biometrics, or a security key.</p>
</li>
<li><p>Local testing on <code>localhost</code>. The demo uses <code>localhost</code> as the relying party ID and origin during development.</p>
</li>
<li><p>No prior WebAuthn knowledge required. The article explains the registration and authentication flow step by step.</p>
</li>
</ul>
<h2 id="heading-why-jwt-alone-falls-short">Why JWT Alone Falls Short</h2>
<p>JWTs are not the villain.</p>
<p>The weak point is the usual deployment pattern around JWTs. Teams often place long-lived tokens in places attackers love, then trust those tokens for too long.</p>
<p>The failure path usually looks like this:</p>
<ul>
<li><p>Your server issues a reusable bearer token.</p>
</li>
<li><p>The browser stores it.</p>
</li>
<li><p>Malware, XSS, session theft, or a fake login flow grabs it.</p>
</li>
<li><p>The attacker replays it.</p>
</li>
<li><p>Your backend sees a valid token and treats the request as real.</p>
</li>
</ul>
<p>That pattern falls apart fast on high-risk routes. Admin actions, money movement, payout approval, email change, API key creation, or destructive writes deserve stronger proof.</p>
<p>WebAuthn gives you stronger proof because the secret never leaves the authenticator.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4cecbde6-69be-4790-8c1c-68fb28d3cc00.png" alt="JWT replay vs WebAuthn challenge flow diagram" style="display: block;" width="1200" height="760" loading="lazy">

<p>In the above diagram, the left side (reusable JWT path) shows the risk of reusable tokens. A server issues a JWT after login. The browser stores the token. An attacker steals the token and sends requests. The backend accepts the token until expiration. Replay becomes possible.</p>
<p>On the right side (WebAuthn path), the flow changes. The server sends a fresh challenge for each login. Your device signs the challenge using a private key stored inside the authenticator. The server verifies the signature before creating a short session.</p>
<p>The key point is simple: JWTs rely on a stored bearer secret, while WebAuthn relies on device-bound cryptographic proof created for a single challenge.</p>
<h2 id="heading-what-webauthn-changes">What WebAuthn Changes</h2>
<p>WebAuthn uses asymmetric cryptography.</p>
<p>The authenticator creates a key pair. The private key stays on the device. Your backend stores the public key and uses it later to verify signatures. During login, your server sends a fresh challenge. The device signs it, and your backend verifies the result against the stored public key.</p>
<p>That changes three things at once:</p>
<ul>
<li><p>The browser never receives a reusable password secret.</p>
</li>
<li><p>A stolen public key is useless for login.</p>
</li>
<li><p>Each ceremony depends on a fresh server challenge.</p>
</li>
</ul>
<p>On the web, passkeys ride on top of WebAuthn. A passkey might live on the local device, a synced platform account, or a physical security key. In practice, your app still deals with the same core objects: credential ID, public key, transports, counter, device type, and backup state.</p>
<h2 id="heading-initialize-the-project">Initialize the Project</h2>
<p>So far, we've talked about why WebAuthn matters and how it changes the login experience. Now it's time to build a small project so you can see the whole flow working end to end.</p>
<p>In this demo, you'll create a simple Node.js app where a user can register a passkey, sign in with that passkey, and then access protected routes through a short-lived session. The idea here is not to build a polished full-stack product. The idea is to build the core backend flow clearly, so you can understand how registration, login, sessions, and step-up verification connect to each other.</p>
<p>Before we start, make sure Node.js and npm are installed on your machine. You can install Node.js from the official website, or use nvm if you prefer managing multiple Node versions.</p>
<pre><code class="language-shell">node -v
npm -v
</code></pre>
<p>The expected output is a Node LTS version and an npm version number.</p>
<p>Next, create a new project folder and initialize the basic structure:</p>
<pre><code class="language-shell">mkdir webauthn-node-demo
cd webauthn-node-demo
npm init -y
npx tsc --init
mkdir src
</code></pre>
<h2 id="heading-install-dependencies">Install Dependencies</h2>
<p>Now that your project is initialized, install the required packages.</p>
<ol>
<li><strong>TypeScript and tsx:</strong> TypeScript types the backend while <code>tsx</code> runs TypeScript files during development.</li>
</ol>
<pre><code class="language-shell">npm install -D typescript tsx @types/node
npx tsc -v
npx tsx --version
</code></pre>
<ol>
<li><strong>Express and session management:</strong> Express handles the HTTP routes, and <code>express-session</code> stores short-lived server session state.</li>
</ol>
<pre><code class="language-shell">npm install express express-session @types/express @types/express-session
</code></pre>
<ol>
<li><strong>SimpleWebAuthn:</strong><code>@simplewebauthn/server</code> generates registration options and verifies responses. <code>@simplewebauthn/browser</code> starts browser-side flows.</li>
</ol>
<pre><code class="language-shell">npm install @simplewebauthn/server @simplewebauthn/browser
</code></pre>
<p>Open your <code>package.json</code> and update the "scripts" block to include these commands:</p>
<pre><code class="language-json">{
    "scripts": {
        "dev": "tsx watch src/app.ts",
        "build": "tsc",
        "start": "node dist/app.js"
    }
}
</code></pre>
<h2 id="heading-define-the-data-model">Define the Data Model</h2>
<p>Before we write the routes, we need to decide how this app will store passkeys.</p>
<p>With password-based login, you usually store a password hash. With WebAuthn, you store something different. After a user registers a passkey, your server needs to keep the credential data required to verify future login attempts. That includes things like the credential ID, public key, counter, and some metadata about the authenticator.</p>
<p>This is why the data model matters from the beginning. Registration and authentication both depend on this stored data, so it's worth making the structure clear before we move deeper into the flow.</p>
<p>A good way to think about it is this. A user can have one or more passkeys, and each passkey should be stored as its own record linked to that user.</p>
<p>Create a new file named <code>src/app.ts</code>. We'll build the backend in this file, and we'll start by defining the data model at the top.</p>
<pre><code class="language-typescript">// src/app.ts
type Passkey = {
    id: string;
    publicKey: Uint8Array;
    counter: number;
    deviceType: "singleDevice" | "multiDevice";
    backedUp: boolean;
    transports?: string[];
};

type User = {
    id: string;
    email: string;
    webAuthnUserID: Uint8Array;
    passkeys: Passkey[];
};

const users = new Map&lt;string, User&gt;();

function findUserByEmail(email: string) {
    return [...users.values()].find((user) =&gt; user.email === email);
}
</code></pre>
<p>What matters here:</p>
<ul>
<li><p><code>id</code> identifies the credential later.</p>
</li>
<li><p><code>publicKey</code> verifies future signatures.</p>
</li>
<li><p><code>counter</code> helps detect cloned or misbehaving authenticators.</p>
</li>
<li><p><code>deviceType</code> and <code>backedUp</code> give you useful recovery signals.</p>
</li>
<li><p><code>webAuthnUserID</code> should be a stable binary value, stored once per user.</p>
</li>
</ul>
<p>Tip: If your database returns <code>Buffer</code> or another binary wrapper for <code>publicKey</code>, convert it back to <code>Uint8Array</code> before verification.</p>
<h2 id="heading-build-the-server-foundation">Build the Server Foundation</h2>
<p>Next, append the core Express app and relying party settings to <code>src/app.ts</code>:</p>
<pre><code class="language-typescript">// src/app.ts
import express from "express";
import session from "express-session";
import { randomBytes, randomUUID } from "node:crypto";
import {
    generateAuthenticationOptions,
    generateRegistrationOptions,
    verifyAuthenticationResponse,
    verifyRegistrationResponse,
    type WebAuthnCredential,
} from "@simplewebauthn/server";

const rpName = "Node Auth Lab";
const rpID = "localhost";
const origin = "http://localhost:3000";

declare module "express-session" {
    interface SessionData {
        currentChallenge?: string;
        pendingUserId?: string;
        userId?: string;
        stepUpUntil?: number;
    }
}

const app = express();

app.use(express.json());

app.use(
    session({
        secret: "replace-this-in-production",
        resave: false,
        saveUninitialized: false,
        cookie: {
            httpOnly: true,
            sameSite: "lax",
            secure: false,
            maxAge: 10 * 60 * 1000,
        },
    }),
);
</code></pre>
<p>This gives you the shared state you need for:</p>
<ul>
<li><p>registration challenge tracking</p>
</li>
<li><p>authentication challenge tracking</p>
</li>
<li><p>logged-in session state</p>
</li>
<li><p>short step-up windows for risky actions</p>
</li>
</ul>
<h2 id="heading-registration-ceremony">Registration Ceremony</h2>
<p>Registration is the part where a user's device creates a new passkey and connects it to their account.</p>
<p>You will often see the word ceremony in WebAuthn documentation. In simple terms, it refers to the full exchange between your server, the browser, and the authenticator during registration or login. So when we say registration ceremony, we simply mean the full process of creating and verifying a new passkey.</p>
<p>There are three parts involved here:</p>
<ul>
<li><p>Your backend prepares the registration options and challenge.</p>
</li>
<li><p>The browser starts the WebAuthn request.</p>
</li>
<li><p>Then the authenticator, such as a phone, laptop, or security key, creates the key pair and returns the result.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d1c87944-a244-4b82-b7ac-d10f109b1024.png" alt="WebAuthn registration flow" style="display: block;" width="1200" height="760" loading="lazy">

<p>In the above diagram, the registration ceremony links a new passkey to your account. The browser asks the server for registration options. The server generates a challenge and returns a JSON configuration. Next, the browser starts the WebAuthn ceremony. Your authenticator creates a new key pair after biometric or security key verification. The private key stays inside the device. Then the browser sends the attestation response to the server.</p>
<p>Verification happens next. The server checks challenge, origin, and relying party ID. After validation, the server stores credential ID, public key, counter value, device type, and backup state. The account now holds a passkey.</p>
<p>Now that the overall flow is clear, let's build it step by step. We'll start by returning registration options from the backend.</p>
<h3 id="heading-1-return-registration-options-from-the-backend">1. Return Registration Options from the Backend</h3>
<p>This endpoint creates a new user if needed, generates the registration options, and stores the challenge server-side.</p>
<p>Append the following route to your <code>src/app.ts</code> file:</p>
<pre><code class="language-typescript">// src/app.ts
app.post("/auth/register/options", async (req, res) =&gt; {
    const { email } = req.body;

    if (!email) {
        return res.status(400).json({ error: "Email is required" });
    }

    let user = findUserByEmail(email);

    if (!user) {
        user = {
            id: randomUUID(),
            email,
            webAuthnUserID: randomBytes(32),
            passkeys: [],
        };

        users.set(user.id, user);
    }

    const options = await generateRegistrationOptions({
        rpName,
        rpID,
        userName: user.email,
        userDisplayName: user.email,
        userID: user.webAuthnUserID,
        attestationType: "none",
        excludeCredentials: user.passkeys.map((passkey) =&gt; ({
            id: passkey.id,
            transports: passkey.transports,
        })),
        authenticatorSelection: {
            residentKey: "preferred",
            userVerification: "preferred",
        },
    });

    req.session.currentChallenge = options.challenge;
    req.session.pendingUserId = user.id;

    res.json(options);
});
</code></pre>
<p>A few decisions here matter:</p>
<ul>
<li><p><code>attestationType: 'none'</code> keeps the flow lighter unless you need richer device provenance.</p>
</li>
<li><p><code>excludeCredentials</code> stops duplicate registration of the same authenticator.</p>
</li>
<li><p><code>userVerification: 'preferred'</code> lets the browser lean toward biometrics or local device unlock.</p>
</li>
</ul>
<h3 id="heading-2-start-registration-in-the-browser">2. Start Registration in the Browser</h3>
<p>On the browser side, you ask your backend for options, then pass them into <code>startRegistration()</code>.</p>
<p>Now, create a new file at <code>src/browser.ts</code> to handle the client-side WebAuthn interactions. Add the following function:</p>
<pre><code class="language-typescript">// src/browser.ts
import { startRegistration } from "@simplewebauthn/browser";

export async function registerPasskey(email: string) {
    const optionsResp = await fetch("/auth/register/options", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({ email }),
    });

    const optionsJSON = await optionsResp.json();

    const registrationResponse = await startRegistration({ optionsJSON });

    const verifyResp = await fetch("/auth/register/verify", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify(registrationResponse),
    });

    return verifyResp.json();
}
</code></pre>
<p><strong>Note:</strong> Browsers can't run TypeScript or bare npm imports directly. In a real application, you would import <code>src/browser.ts</code> into your frontend framework (React, Vue, and so on) or bundle it using a tool like Vite, Webpack, or esbuild before serving it to the client.</p>
<p>Under the hood, the browser now speaks to the authenticator. That might trigger Face ID, Touch ID, Windows Hello, Android biometrics, or a physical security key prompt.</p>
<h3 id="heading-3-verify-the-registration-response-and-save-the-passkey">3. Verify the Registration Response and Save the Passkey</h3>
<p>Once the browser sends the response back, verify it against the challenge and relying party details you stored earlier.</p>
<p>Append this verification route to <code>src/app.ts</code>:</p>
<pre><code class="language-typescript">// src/app.ts
app.post("/auth/register/verify", async (req, res) =&gt; {
    const user = users.get(req.session.pendingUserId ?? "");

    if (!user || !req.session.currentChallenge) {
        return res.status(400).json({ verified: false });
    }

    let verification;

    try {
        verification = await verifyRegistrationResponse({
            response: req.body,
            expectedChallenge: req.session.currentChallenge,
            expectedOrigin: origin,
            expectedRPID: rpID,
        });
    } catch (error) {
        return res.status(400).json({
            verified: false,
            error:
                error instanceof Error ? error.message : "Registration failed",
        });
    }

    if (!verification.verified || !verification.registrationInfo) {
        return res.status(400).json({ verified: false });
    }

    const { credential, credentialDeviceType, credentialBackedUp } =
        verification.registrationInfo;

    user.passkeys.push({
        id: credential.id,
        publicKey: credential.publicKey,
        counter: credential.counter,
        transports: credential.transports,
        deviceType: credentialDeviceType,
        backedUp: credentialBackedUp,
    });

    req.session.currentChallenge = undefined;
    req.session.pendingUserId = undefined;

    res.json({ verified: true });
});
</code></pre>
<p>At this point, the user has no password hash in the hot path. The authenticator now holds the private key. Your server stores only what it needs for later verification.</p>
<h2 id="heading-authentication-ceremony">Authentication Ceremony</h2>
<p>Authentication is the part where the user proves they still have the passkey they registered earlier. Just like registration, this process is also called a ceremony in WebAuthn. Here, the goal is not to create a new credential. The goal is to verify an existing one in a secure way.</p>
<p>There are four parts involved here:</p>
<ul>
<li><p>Your server creates a fresh challenge.</p>
</li>
<li><p>The browser passes that challenge to the authenticator.</p>
</li>
<li><p>The authenticator signs it using the private key stored on the device.</p>
</li>
<li><p>Then the server verifies the response using the public key it stored during registration.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0296f66d-47c7-4ca5-ab0c-c5a6f9a8eef8.png" alt="WebAuthn authentication flow" style="display: block;" width="1200" height="760" loading="lazy">

<p>In the above diagram, login occurs through a challenge-response process. The browser first asks the server for authentication options. The server generates a new challenge and returns the allowed credential list. Your browser then triggers the authenticator. The authenticator signs the challenge using the private key stored on the device. The browser sends the signed assertion to the backend.</p>
<p>Verification follows. The server validates signature, challenge, origin, and credential ID. The stored counter updates. A short session is issued after verification. Login depends on device proof instead of reusable credentials.</p>
<p>With that flow in mind, let's move into the implementation. We'll begin by returning authentication options from the backend.</p>
<h3 id="heading-1-return-authentication-options">1. Return Authentication Options</h3>
<p>Fetch the user, list allowed credentials, and store the new challenge.</p>
<pre><code class="language-typescript">// src/app.ts
app.post("/auth/login/options", async (req, res) =&gt; {
    const { email } = req.body;
    const user = findUserByEmail(email);

    if (!user) {
        return res.status(404).json({ error: "User not found" });
    }

    const options = await generateAuthenticationOptions({
        rpID,
        allowCredentials: user.passkeys.map((passkey) =&gt; ({
            id: passkey.id,
            transports: passkey.transports,
        })),
        userVerification: "preferred",
    });

    req.session.currentChallenge = options.challenge;
    req.session.pendingUserId = user.id;

    res.json(options);
});
</code></pre>
<h3 id="heading-2-start-authentication-in-the-browser">2. Start Authentication in the Browser</h3>
<p>The browser receives the options, then starts the ceremony. Add the login function to your <code>src/browser.ts</code> file:</p>
<pre><code class="language-typescript">// src/browser.ts
import { startAuthentication } from "@simplewebauthn/browser";

export async function loginWithPasskey(email: string) {
    const optionsResp = await fetch("/auth/login/options", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({ email }),
    });

    const optionsJSON = await optionsResp.json();

    const authenticationResponse = await startAuthentication({ optionsJSON });

    const verifyResp = await fetch("/auth/login/verify", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify(authenticationResponse),
    });

    return verifyResp.json();
}
</code></pre>
<h3 id="heading-3-verify-the-assertion-and-update-the-counter">3. Verify the Assertion and Update the Counter</h3>
<p>This is the moment where the backend decides whether the login is real.</p>
<pre><code class="language-typescript">// src/app.ts
app.post("/auth/login/verify", async (req, res) =&gt; {
    const user = users.get(req.session.pendingUserId ?? "");

    if (!user || !req.session.currentChallenge) {
        return res.status(400).json({ verified: false });
    }

    const passkey = user.passkeys.find((item) =&gt; item.id === req.body.id);

    if (!passkey) {
        return res
            .status(400)
            .json({ verified: false, error: "Passkey not found" });
    }

    const credential: WebAuthnCredential = {
        id: passkey.id,
        publicKey: passkey.publicKey,
        counter: passkey.counter,
        transports: passkey.transports,
    };

    let verification;

    try {
        verification = await verifyAuthenticationResponse({
            response: req.body,
            expectedChallenge: req.session.currentChallenge,
            expectedOrigin: origin,
            expectedRPID: rpID,
            credential,
            requireUserVerification: true,
        });
    } catch (error) {
        return res.status(400).json({
            verified: false,
            error:
                error instanceof Error
                    ? error.message
                    : "Authentication failed",
        });
    }

    if (!verification.verified) {
        return res.status(400).json({ verified: false });
    }

    passkey.counter = verification.authenticationInfo.newCounter;

    req.session.userId = user.id;
    req.session.currentChallenge = undefined;
    req.session.pendingUserId = undefined;

    res.json({ verified: true });
});
</code></pre>
<p>Two details here matter more than they first appear:</p>
<ul>
<li><p><code>requireUserVerification: true</code> forces a stronger ceremony for the verification step.</p>
</li>
<li><p><code>newCounter</code> should overwrite your stored counter after each successful login.</p>
</li>
</ul>
<p>That counter update is one of the few signals you have for spotting cloned or broken authenticators.</p>
<h2 id="heading-what-replaces-the-long-lived-jwt">What Replaces the Long-lived JWT</h2>
<p>Don't run this full WebAuthn flow, then issue a week-long bearer token and call the job done. That throws away the best part of the design.</p>
<p>A better model is:</p>
<ul>
<li><p>WebAuthn proves identity</p>
</li>
<li><p>the server creates a short session</p>
</li>
<li><p>the browser receives only an HTTP-only session cookie</p>
</li>
<li><p>risky actions ask for a fresh WebAuthn assertion again</p>
</li>
</ul>
<p>A tiny route guard shows the idea:</p>
<pre><code class="language-typescript">// src/app.ts
function requireSession(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction,
) {
    if (!req.session.userId) {
        return res.status(401).json({ error: "Unauthorized" });
    }

    next();
}

app.get("/me", requireSession, (req, res) =&gt; {
    const user = users.get(req.session.userId ?? "");

    if (!user) {
        return res.status(404).json({ error: "User not found" });
    }

    res.json({
        id: user.id,
        email: user.email,
        passkeys: user.passkeys.length,
    });
});
</code></pre>
<p>This keeps the post-login browser state smaller and less reusable. The browser carries only a session cookie. The server owns the session state and its lifetime.</p>
<p>Tip: Short sessions plus fresh WebAuthn for risky actions is a stronger shape than one long bearer token with broad scope.</p>
<h2 id="heading-multi-device-and-recovery-logic">Multi-Device and Recovery Logic</h2>
<p>Strong auth fails fast if the first lost phone locks the user out forever. You need a real backup story from day one.</p>
<p>The clean pattern looks like this:</p>
<ul>
<li><p>register one platform passkey on the user's main device</p>
</li>
<li><p>register one extra credential, such as a security key or another trusted device</p>
</li>
<li><p>verify a contact channel during account setup</p>
</li>
<li><p>expose passkey management in account settings</p>
</li>
<li><p>store device metadata so users see what is registered</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/7c8d8867-38d1-403f-a409-54e0988221d8.png" alt="Multi-device recovery and step-up auth" style="display: block;" width="1200" height="760" loading="lazy">

<p>In the above diagram, the left side shows the recovery strategy. You start with a primary passkey on your main device. Then you add another trusted authenticator such as a second device or hardware security key. A recovery channel should exist. Device inventory helps track active credentials, and lost devices must be revoked quickly.</p>
<p>The right side focuses on step up authentication. Sensitive actions require fresh verification. Examples include payouts, email change, API key generation, or destructive operations. When such an action begins, the server issues a new challenge. Your authenticator signs again. Access lasts for a short step up window before new verification becomes required.</p>
<p>A simple product rule helps here. Don't hide the second passkey flow inside a deep settings page. Put "Add another passkey" right after the first successful registration.</p>
<p>Passkeys also sync across major platform ecosystems. That helps the user experience, but your backend should still treat each registered credential as a first-class record with its own ID, public key, counter, device type, and backup state.</p>
<p>For account recovery, keep the bar high. Recovery should not become the weakest path in the whole system. Emailed magic links, recovery codes, and support-driven recovery all need rate limits, audit trails, and strict checks.</p>
<h2 id="heading-step-up-authentication-for-sensitive-actions">Step-up Authentication for Sensitive Actions</h2>
<p>Logging in once should not grant silent permission for every dangerous route.</p>
<p>Step-up auth means this:</p>
<ul>
<li><p>the user is already signed in</p>
</li>
<li><p>they try a sensitive action</p>
</li>
<li><p>the server demands a fresh WebAuthn ceremony</p>
</li>
<li><p>the app grants a short window for that one class of actions</p>
</li>
</ul>
<p>You can use step-up auth for:</p>
<ul>
<li><p>payout approval</p>
</li>
<li><p>credential management</p>
</li>
<li><p>email or phone change</p>
</li>
<li><p>API key creation</p>
</li>
<li><p>organization deletion</p>
</li>
<li><p>role elevation</p>
</li>
<li><p>access to billing controls</p>
</li>
</ul>
<p>Start by issuing new authentication options with strict user verification.</p>
<pre><code class="language-typescript">// src/app.ts
app.post("/auth/step-up/options", requireSession, async (req, res) =&gt; {
    const user = users.get(req.session.userId ?? "");

    if (!user) {
        return res.status(404).json({ error: "User not found" });
    }

    const options = await generateAuthenticationOptions({
        rpID,
        allowCredentials: user.passkeys.map((passkey) =&gt; ({
            id: passkey.id,
            transports: passkey.transports,
        })),
        userVerification: "required",
    });

    req.session.currentChallenge = options.challenge;

    res.json(options);
});
</code></pre>
<p>Then verify the response and issue a short step-up window.</p>
<pre><code class="language-typescript">// src/app.ts
app.post("/auth/step-up/verify", requireSession, async (req, res) =&gt; {
    const user = users.get(req.session.userId ?? "");
    const passkey = user?.passkeys.find((item) =&gt; item.id === req.body.id);

    if (!user || !passkey || !req.session.currentChallenge) {
        return res.status(400).json({ verified: false });
    }

    let verification;
    try {
        verification = await verifyAuthenticationResponse({
            response: req.body,
            expectedChallenge: req.session.currentChallenge,
            expectedOrigin: origin,
            expectedRPID: rpID,
            credential: {
                id: passkey.id,
                publicKey: passkey.publicKey,
                counter: passkey.counter,
                transports: passkey.transports,
            },
            requireUserVerification: true,
        });
    } catch (error) {
        return res.status(400).json({
            verified: false,
            error: error instanceof Error ? error.message : "Step-up failed",
        });
    }

    if (!verification.verified) {
        return res.status(400).json({ verified: false });
    }

    passkey.counter = verification.authenticationInfo.newCounter;
    req.session.stepUpUntil = Date.now() + 5 * 60 * 1000;
    req.session.currentChallenge = undefined;

    res.json({ verified: true });
});
</code></pre>
<p>A tiny guard handles the rest.</p>
<pre><code class="language-typescript">// src/app.ts
function requireRecentStepUp(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction,
) {
    if (!req.session.stepUpUntil || req.session.stepUpUntil &lt; Date.now()) {
        return res.status(403).json({ error: "Fresh verification required" });
    }

    next();
}

app.post("/billing/payout", requireSession, requireRecentStepUp, (req, res) =&gt; {
    res.json({ ok: true });
});
</code></pre>
<p>This is where WebAuthn stops being a login feature and starts becoming part of your authorization model.</p>
<p>Now that all your routes and guards are built, append the server start command to the very bottom of <code>src/app.ts</code> to bring the backend to life:</p>
<pre><code class="language-typescript">// src/app.ts
const PORT = 3000;
app.listen(PORT, () =&gt; {
    console.log(`Server listening on http://localhost:${PORT}`);
});
</code></pre>
<h2 id="heading-recap">Recap</h2>
<p>You started with a common weak pattern: a reusable token trusted for too long. Then you replaced the core proof model:</p>
<ul>
<li><p>the device keeps the private key</p>
</li>
<li><p>the server stores the public key and counter</p>
</li>
<li><p>each ceremony signs a fresh challenge</p>
</li>
<li><p>the app uses short server sessions after verification</p>
</li>
<li><p>risky routes trigger fresh step-up authentication</p>
</li>
</ul>
<p>That's the real shift.</p>
<p>WebAuthn is not a cosmetic login upgrade. It changes where trust lives. Once you move from reusable bearer proof to device-bound cryptographic proof, your Node.js auth stack starts to behave like a modern security system instead of a thin session wrapper.</p>
<h2 id="heading-try-it-yourself">Try it Yourself</h2>
<p>The full source code is available on GitHub. <a href="https://github.com/logicbaselabs/web-authn">Clone the repository</a> here and follow the setup guide in the <code>README</code> to test the biometric login flow locally.</p>
<h2 id="heading-final-words">Final Words</h2>
<p>If you found the information here valuable, feel free to share it with others who might benefit from it.</p>
<p>I’d really appreciate your thoughts – mention me on X <a href="https://x.com/sumit_analyzen">@sumit_analyzen</a> or on Facebook <a href="https://facebook.com/sumit.analyzen">@sumit.analyzen</a>, <a href="https://youtube.com/@logicBaseLabs">watch my coding tutorials</a>, or simply <a href="https://www.linkedin.com/in/sumitanalyzen/">connect with me on LinkedIn</a>.</p>
<p>You can also checkout my official website <a href="https://www.sumitsaha.me">www.sumitsaha.me</a> for more details about me.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
