<?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[ Roland Mweze - 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[ Roland Mweze - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 28 Oct 2026 06:32:50 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/rolandm/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>
        
    </channel>
</rss>
