ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeImplementing Passkey Authentication in NestJS and PostgreSQL

Implementing Passkey Authentication in NestJS and PostgreSQL

A practical, code-first guide to adding WebAuthn passkey registration and login to NestJS with PostgreSQL, covering credential storage, challenge handling, security, and production hardening.

Sanju Singh
Sanju Singh
Senior Developer
August 27, 2026
10 min read
Implementing Passkey Authentication in NestJS and PostgreSQL
#webauthn#authentication#postgresql#security#NestJS
๐Ÿ‘2

Why now

In May 2026 the FIDO Alliance put a number on something that had been a trend for a few years: 5 billion passkeys are now in active use worldwide. The same report found 90 percent consumer awareness, 75 percent of people with a passkey enabled on at least one account, and 49 percent who use one regularly when it is offered.

That adoption is happening while the threat model underneath it is shifting. Verizon's 2026 Data Breach Investigations Report found that vulnerability exploitation, at 31 percent of breaches, overtook stolen credentials as the leading breach entry point for the first time in the report's 19-year history. Credential theft did not go away; it was simply overtaken.

Passwords are still doing plenty of damage in the meantime. SpyCloud's 2026 Identity Exposure Report counted 5.3 billion credential pairs circulating in criminal marketplaces over the previous year, and found that roughly four in ten corporate users had reused an exposed password. None of that risk disappears just because exploitation edged ahead in the rankings.

None of this makes passkeys a silver bullet. It makes them the highest-leverage fix available for the credential half of that risk, and one a small backend team can ship in a weekend rather than a quarter. The rest of this post builds real passkey support into a NestJS and PostgreSQL API, using the current release of SimpleWebAuthn (v13.3.3) and TypeScript 7.

What a passkey actually is

Strip away the marketing and a passkey is a discoverable, resident public-key credential defined by the WebAuthn specification. Registration generates a key pair on the authenticator; the private key never leaves it and the server only ever stores the public half. There is no shared secret to phish, leak, or reuse across sites, which is the entire security argument in one sentence.

Authenticators come in two flavors that matter for how you design an app. A device-bound passkey has a private key locked inside one piece of hardware, a security key or a TPM, and it never leaves. A syncable passkey has its private key replicated across a user's devices through a platform's encrypted sync fabric, such as iCloud Keychain or Google Password Manager, trading a little assurance for a lot fewer lockouts.

That distinction is not academic if you sell into regulated customers. NIST's SP 800-63B-4 puts phishing-resistant authentication into the baseline for Authenticator Assurance Level 2, and it explicitly recognizes synced passkeys at AAL2. Device-bound passkeys can reach the stricter AAL3, but the specification is direct about the tradeoff: syncable authenticators "SHALL NOT be used at AAL3" because AAL3 requires a key that can never be exported.

Assurance level

Syncable passkey

Device-bound passkey

AAL2 (phishing-resistant baseline)

Allowed

Allowed

AAL3 (hardware-isolated key required)

Not allowed

Allowed

Two ceremonies, one round trip each

Both registration and login are two-step ceremonies, and the two steps happen in separate HTTP requests. The server issues a random challenge and a set of options; the browser calls the WebAuthn API with those options and gets back a signed response; the server verifies that response against the challenge it issued. Nothing about the second step can be trusted unless the first step's challenge is still provably the one the server generated.

That means the challenge has to live somewhere between the two requests, and it cannot simply be a value the client hands back to you: an attacker intercepting the flow could hand back whatever challenge you gave them too. This implementation stores each challenge in Redis, keyed by user ID and ceremony type, with a two-minute TTL, which keeps the API itself fully stateless.

import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';

const CHALLENGE_TTL_SECONDS = 120;

// Registration and authentication are two-step ceremonies: generate options
// (which include a fresh challenge), then verify a response against that
// same challenge. The challenge has to live somewhere between those two
// requests, and it must not be trusted if the client hands it back to you.
// A short-lived Redis key keyed by user ID does the job without adding a
// stateful session cookie for a service that is otherwise stateless.
@Injectable()
export class WebAuthnChallengeStore {
  constructor(private readonly redis: Redis) {}

  private key(userId: string, purpose: 'reg' | 'auth'): string {
    return `webauthn:challenge:${purpose}:${userId}`;
  }

  async save(
    userId: string,
    purpose: 'reg' | 'auth',
    challenge: string,
  ): Promise<void> {
    await this.redis.set(
      this.key(userId, purpose),
      challenge,
      'EX',
      CHALLENGE_TTL_SECONDS,
    );
  }

  async consume(
    userId: string,
    purpose: 'reg' | 'auth',
  ): Promise<string | null> {
    const key = this.key(userId, purpose);
    const value = await this.redis.get(key);
    if (value) {
      await this.redis.del(key);
    }
    return value;
  }
}

Nothing here is passkey-specific; it is a short-lived key-value pair with a purpose in the key so registration and login challenges cannot collide for a user doing both in the same window. If your app does not already run Redis, a signed, encrypted cookie with the same TTL works too.

The schema

The schema needs to capture more than just the public key. The counter and the device and backed-up flags are what let you detect a cloned credential later; skipping them now means you cannot add that check without a migration and a gap in your audit trail.

CREATE TABLE webauthn_credentials (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  credential_id TEXT NOT NULL UNIQUE,
  public_key BYTEA NOT NULL,
  counter BIGINT NOT NULL DEFAULT 0,
  transports TEXT[] NOT NULL DEFAULT '{}',
  device_type TEXT NOT NULL,
  backed_up BOOLEAN NOT NULL DEFAULT FALSE,
  aaguid TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at TIMESTAMPTZ
);

CREATE INDEX idx_webauthn_credentials_user_id ON webauthn_credentials (user_id);

credential_id is unique across the whole table, not just per user, because a login lookup only has the credential ID the browser returned to work with. public_key is stored as raw bytes rather than a base64 string: encoding it yourself just adds a lossy round trip that verification code then has to undo.

import {
  Column,
  CreateDateColumn,
  Entity,
  Index,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from './user.entity';

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

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user: User;

  @Column({ name: 'user_id' })
  userId: string;

  // Base64url credential ID from the authenticator. Unique across all users
  // so a lookup by ID alone is enough to find the owning row during login.
  @Index({ unique: true })
  @Column({ name: 'credential_id', type: 'text' })
  credentialId: string;

  // COSE public key, stored as raw bytes. Never store this as text: base64
  // encoding it yourself just adds a lossy round trip you have to reverse.
  @Column({ name: 'public_key', type: 'bytea' })
  publicKey: Buffer;

  // Signature counter reported by the authenticator. A stalled or
  // decreasing counter across authentications is the standard signal that
  // a credential was cloned; verifyAuthenticationResponse checks this,
  // this column is what makes that check possible run over run.
  @Column({ type: 'bigint' })
  counter: string;

  @Column({ type: 'text', array: true, default: '{}' })
  transports: string[];

  @Column({ name: 'device_type', type: 'text' })
  deviceType: 'singleDevice' | 'multiDevice';

  @Column({ name: 'backed_up', type: 'boolean', default: false })
  backedUp: boolean;

  @Column({ type: 'text', nullable: true })
  aaguid: string | null;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
  lastUsedAt: Date | null;
}

This maps directly onto the schema above. Two fields are worth a second look: counter is stored as a string because a Postgres bigint round-trips through the pg driver as a string once it exceeds JavaScript's safe integer range, and transports is a plain text array rather than a join table, since it is metadata about one credential rather than data with its own lifecycle.

The service

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoUint8Array } from '@simplewebauthn/server/helpers';
import type {
  AuthenticationResponseJSON,
  AuthenticatorTransportFuture,
  RegistrationResponseJSON,
  WebAuthnCredential,
} from '@simplewebauthn/server';
import { User } from './user.entity';
import { WebAuthnCredentialEntity } from './webauthn-credential.entity';
import { WebAuthnChallengeStore } from './webauthn-challenge.store';

const RP_NAME = 'Example App';
const RP_ID = process.env.WEBAUTHN_RP_ID ?? 'example.com';
const ORIGIN = process.env.WEBAUTHN_ORIGIN ?? 'https://example.com';

@Injectable()
export class WebAuthnService {
  constructor(
    @InjectRepository(WebAuthnCredentialEntity)
    private readonly credentials: Repository<WebAuthnCredentialEntity>,
    private readonly challenges: WebAuthnChallengeStore,
  ) {}

  // Step 1 of registration: build the options object the browser needs to
  // call navigator.credentials.create(). excludeCredentials stops a user
  // from registering the same authenticator twice.
  async getRegistrationOptions(user: User) {
    const existing = await this.credentials.find({ where: { userId: user.id } });

    const options = await generateRegistrationOptions({
      rpName: RP_NAME,
      rpID: RP_ID,
      userName: user.email,
      userDisplayName: user.displayName,
      userID: isoUint8Array.fromUTF8String(user.id),
      attestationType: 'none',
      excludeCredentials: existing.map((cred) => ({
        id: cred.credentialId,
        transports: cred.transports as AuthenticatorTransportFuture[],
      })),
      authenticatorSelection: {
        residentKey: 'preferred',
        userVerification: 'preferred',
      },
    });

    await this.challenges.save(user.id, 'reg', options.challenge);
    return options;
  }

  // Step 2 of registration: verify the signed attestation the browser
  // returned, then persist the credential. Nothing is written to the
  // database until verification succeeds.
  async verifyRegistration(user: User, response: RegistrationResponseJSON) {
    const expectedChallenge = await this.challenges.consume(user.id, 'reg');
    if (!expectedChallenge) {
      throw new UnauthorizedException('Registration challenge expired or missing');
    }

    const verification = await verifyRegistrationResponse({
      response,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
    });

    if (!verification.verified || !verification.registrationInfo) {
      return { verified: false };
    }

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

    await this.credentials.save(
      this.credentials.create({
        userId: user.id,
        credentialId: credential.id,
        publicKey: Buffer.from(credential.publicKey),
        counter: credential.counter.toString(),
        transports: credential.transports ?? [],
        deviceType: credentialDeviceType,
        backedUp: credentialBackedUp,
        aaguid: aaguid ?? null,
      }),
    );

    return { verified: true };
  }

  // Step 1 of login: list the credential IDs already on file for this
  // account so the browser only prompts for one of those authenticators.
  async getAuthenticationOptions(user: User) {
    const existing = await this.credentials.find({ where: { userId: user.id } });
    if (existing.length === 0) {
      throw new UnauthorizedException('No passkeys registered for this account');
    }

    const options = await generateAuthenticationOptions({
      rpID: RP_ID,
      allowCredentials: existing.map((cred) => ({
        id: cred.credentialId,
        transports: cred.transports as AuthenticatorTransportFuture[],
      })),
      userVerification: 'preferred',
    });

    await this.challenges.save(user.id, 'auth', options.challenge);
    return options;
  }

  // Step 2 of login: verify the signed assertion, then persist the new
  // counter value. Persisting newCounter is what lets the next login
  // detect a counter that went backwards, one signal a credential was
  // cloned onto a second, unauthorized authenticator.
  async verifyAuthentication(user: User, response: AuthenticationResponseJSON) {
    const expectedChallenge = await this.challenges.consume(user.id, 'auth');
    if (!expectedChallenge) {
      throw new UnauthorizedException('Authentication challenge expired or missing');
    }

    const stored = await this.credentials.findOne({
      where: { credentialId: response.id, userId: user.id },
    });
    if (!stored) {
      throw new UnauthorizedException('Unrecognized credential for this account');
    }

    const credential: WebAuthnCredential = {
      id: stored.credentialId,
      publicKey: new Uint8Array(stored.publicKey),
      counter: Number(stored.counter),
      transports: stored.transports as AuthenticatorTransportFuture[],
    };

    const verification = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
      credential,
    });

    if (verification.verified) {
      stored.counter = verification.authenticationInfo.newCounter.toString();
      stored.backedUp = verification.authenticationInfo.credentialBackedUp;
      stored.lastUsedAt = new Date();
      await this.credentials.save(stored);
    }

    return { verified: verification.verified };
  }
}

generateRegistrationOptions needs the internal user ID as bytes, not a string, which is what isoUint8Array.fromUTF8String is for. excludeCredentials stops someone from registering the same authenticator twice, and residentKey: preferred asks the authenticator to create a discoverable credential, the property that lets a user sign in by picking an account rather than typing one first.

verifyAuthenticationResponse takes the stored credential, including its last known counter, and returns a newCounter you are responsible for saving. Most platform authenticators report a counter of zero forever, so the check does little there, but for the security keys and older authenticators that do increment it, a counter that goes backward is the textbook sign of a cloned credential and should trigger revoking it, not just logging it.

The controller

import { Body, Controller, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import type {
  AuthenticationResponseJSON,
  RegistrationResponseJSON,
} from '@simplewebauthn/server';
import { WebAuthnService } from './webauthn.service';

// CurrentUser stands in for whatever request-scoped auth decorator an app
// already uses (session, access token, etc). Passkeys replace the *login
// step*, not the fact that a request has an authenticated subject; the
// registration endpoints in particular must run behind existing auth so a
// stranger cannot add a passkey to someone else's account.
interface AuthedRequest extends Request {
  user: { id: string; email: string; displayName: string };
}

@Controller('webauthn')
export class WebAuthnController {
  constructor(private readonly webauthn: WebAuthnService) {}

  @Post('registration/options')
  getRegistrationOptions(@Req() req: AuthedRequest) {
    return this.webauthn.getRegistrationOptions(req.user);
  }

  @Post('registration/verify')
  verifyRegistration(
    @Req() req: AuthedRequest,
    @Body() body: RegistrationResponseJSON,
  ) {
    return this.webauthn.verifyRegistration(req.user, body);
  }

  @Post('authentication/options')
  getAuthenticationOptions(@Req() req: AuthedRequest) {
    return this.webauthn.getAuthenticationOptions(req.user);
  }

  @Post('authentication/verify')
  verifyAuthentication(
    @Req() req: AuthedRequest,
    @Body() body: AuthenticationResponseJSON,
  ) {
    return this.webauthn.verifyAuthentication(req.user, body);
  }
}

The controller is intentionally thin. Every method reads req.user, which stands in for whatever session or access-token middleware the app already has; the important detail is that the registration endpoints sit behind that same authentication, so a passkey can only ever be added to the account that is already logged in, never to an arbitrary one.

Before you ship it

Do not remove password login the day this ships. Passkeys fail closed in ways passwords do not: a lost phone with no synced backup, a corporate device wiped on offboarding, a user who never finishes the registration flow. Offer passkeys as the default and keep an existing fallback until account recovery for the passkey-only path has actually been tested end to end.

Let users register more than one authenticator, and say so in the UI. A single passkey tied to a single phone is a single point of failure; a phone and a hardware key, or a phone and a laptop's platform authenticator, means losing one device is an inconvenience instead of a lockout ticket.

Two details surfaced only by actually compiling this against current packages. TypeScript 7 removed the legacy node moduleResolution setting outright, so a tsconfig carried over from an older NestJS project needs module and moduleResolution both set to nodenext, or both to node16, not left on the old default. TypeORM entities also need strictPropertyInitialization turned off under strict mode, since the ORM populates columns after construction, not inside it.

The bottom line

None of this closes the door Verizon flagged: vulnerability exploitation is the bigger single vector now, and passkeys will not patch a server. What they do is take the credential half of the risk, the half behind SpyCloud's 5.3 billion exposed pairs, and remove it for every account that enables one. Ship it as an addition, not a replacement, and the registration friction pays for itself the first time it blocks a credential-stuffing run.

Comments (0)

Login to post a comment.

Sanju Singh
Sanju Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Sanju Singh's Newsletter

More from Sanju Singh

View profile

REST vs gRPC vs GraphQL in NestJS: What the Numbers Actually Show

Every REST vs gRPC vs GraphQL post repeats the same line: gRPC is 5-10x faster. I built the same NestJS endpoint on all three transports and benchmarked them myself. Here's what actually happened, and what it means for picking a protocol.

9 minAug 30

OpenAI Pulls the Plug on Cursor After SpaceX's $60 Billion Buyout

OpenAI plans to end Cursorโ€™s native access to its AI models following SpaceX's $60 billion acquisition of the coding startup. With a proposed November 12 transition date, the split highlights growing tensions across the AI industry.

6 minAug 29

Implementing Full-Text Search in NestJS with TypeORM and PostgreSQL

Learn how to build production-ready full-text search in NestJS with TypeORM and PostgreSQL, covering generated tsvector columns, GIN indexing, relevance ranking, pagination, result highlighting, and typo-tolerant search.

6 minAug 28

How to Cross-Post to Dev.to and Hashnode Without Hurting Your SEO

Master developer content syndication without losing search engine authority. Learn how canonical tags work, how to configure Dev.to and Hashnode, and how to automate publishing with ZyVOP.

9 minAug 25

Dev.to vs Hashnode vs Medium: Which Developer Blogging Platform Makes Sense in 2026?

An objective 2026 comparison of Dev.to, Hashnode, and Medium for engineers. We evaluate SEO ownership, APIs, pricing, diagramming, and distribution.

4 minAug 24