SimpleWebAuthn Library Testing: Unit and Integration Tests for WebAuthn Servers

SimpleWebAuthn Library Testing: Unit and Integration Tests for WebAuthn Servers

SimpleWebAuthn is the most widely used open-source WebAuthn library for JavaScript servers. Its @simplewebauthn/server package handles the complex cryptographic operations for credential registration and authentication verification, so you don't have to implement CBOR parsing, COSE key validation, or attestation verification yourself. But even with a battle-tested library, your integration code needs testing. This guide covers unit and integration testing patterns for SimpleWebAuthn and its Python counterpart, py_webauthn.

What Needs Testing in a SimpleWebAuthn Integration

The library handles cryptographic verification — don't test that. Test the surrounding integration code:

  1. Challenge generation and storage — challenges must be unique, stored securely, and expire
  2. Credential storage — public keys, credential IDs, and counters must be persisted correctly
  3. Registration options generation — RP config, user info, and exclusion lists
  4. Authentication options generation — allowed credentials, UV requirements
  5. Counter update logic — each authentication must increment and persist the counter
  6. Error handling — what happens when verification fails
  7. Session management — challenges tied to correct user sessions

Setting Up Test Fixtures

Create realistic test fixtures for WebAuthn credential data:

// test/fixtures/webauthn.ts
import { isoBase64URL } from '@simplewebauthn/server/helpers';

export const testUser = {
  id: 'user-test-123',
  username: 'testuser@example.com',
  displayName: 'Test User',
};

export const testCredential = {
  id: 'credential-id-base64url',
  publicKey: new Uint8Array([/* valid COSE key bytes */]),
  counter: 0,
  transports: ['internal'] as AuthenticatorTransport[],
};

export function createMockRegistrationOptions() {
  return {
    rp: { name: 'Example Corp', id: 'example.com' },
    user: {
      id: isoBase64URL.fromString('user-test-123'),
      name: 'testuser@example.com',
      displayName: 'Test User',
    },
    challenge: isoBase64URL.fromBuffer(crypto.getRandomValues(new Uint8Array(32))),
    pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
    timeout: 60000,
    attestation: 'none',
  };
}

Unit Testing Registration Options Generation

import { generateRegistrationOptions } from '@simplewebauthn/server';
import { expect, describe, it } from 'vitest';
import { testUser } from './fixtures/webauthn';

describe('generateRegistrationOptions', () => {
  it('generates unique challenge for each call', async () => {
    const options1 = await generateRegistrationOptions({
      rpName: 'Example Corp',
      rpID: 'example.com',
      userName: testUser.username,
      userID: isoBase64URL.fromString(testUser.id),
      attestationType: 'none',
    });

    const options2 = await generateRegistrationOptions({
      rpName: 'Example Corp',
      rpID: 'example.com',
      userName: testUser.username,
      userID: isoBase64URL.fromString(testUser.id),
      attestationType: 'none',
    });

    expect(options1.challenge).not.toBe(options2.challenge);
  });

  it('excludes existing credentials to prevent duplicates', async () => {
    const existingCredentialID = 'existing-credential-id';

    const options = await generateRegistrationOptions({
      rpName: 'Example Corp',
      rpID: 'example.com',
      userName: testUser.username,
      userID: isoBase64URL.fromString(testUser.id),
      attestationType: 'none',
      excludeCredentials: [
        { id: existingCredentialID, type: 'public-key' },
      ],
    });

    expect(options.excludeCredentials).toHaveLength(1);
    expect(options.excludeCredentials![0].id).toBe(existingCredentialID);
  });

  it('requires resident key for passkey flows', async () => {
    const options = await generateRegistrationOptions({
      rpName: 'Example Corp',
      rpID: 'example.com',
      userName: testUser.username,
      userID: isoBase64URL.fromString(testUser.id),
      authenticatorSelection: {
        residentKey: 'required',
        userVerification: 'required',
      },
    });

    expect(options.authenticatorSelection?.residentKey).toBe('required');
    expect(options.authenticatorSelection?.userVerification).toBe('required');
  });
});

Unit Testing Challenge Storage

Challenges are security-critical. Test the storage implementation:

// ChallengeStore interface
interface ChallengeStore {
  save(userId: string, challenge: string, expiresIn: number): Promise<void>;
  get(userId: string): Promise<string | null>;
  delete(userId: string): Promise<void>;
}

describe('ChallengeStore', () => {
  let store: ChallengeStore;

  beforeEach(() => {
    store = new InMemoryChallengeStore(); // your implementation
  });

  it('stores and retrieves challenge', async () => {
    await store.save('user-123', 'challenge-abc', 60000);
    const retrieved = await store.get('user-123');
    expect(retrieved).toBe('challenge-abc');
  });

  it('returns null for expired challenge', async () => {
    await store.save('user-123', 'challenge-abc', 1); // 1ms TTL
    await new Promise(r => setTimeout(r, 10));
    const retrieved = await store.get('user-123');
    expect(retrieved).toBeNull();
  });

  it('deletes challenge after retrieval (one-time use)', async () => {
    await store.save('user-123', 'challenge-abc', 60000);
    await store.get('user-123');
    await store.delete('user-123');
    const second = await store.get('user-123');
    expect(second).toBeNull();
  });

  it('isolates challenges between users', async () => {
    await store.save('user-1', 'challenge-for-user-1', 60000);
    await store.save('user-2', 'challenge-for-user-2', 60000);

    expect(await store.get('user-1')).toBe('challenge-for-user-1');
    expect(await store.get('user-2')).toBe('challenge-for-user-2');
  });
});

Unit Testing Credential Repository

interface CredentialRepository {
  save(userId: string, credential: StoredCredential): Promise<void>;
  findById(credentialId: string): Promise<StoredCredential | null>;
  findByUserId(userId: string): Promise<StoredCredential[]>;
  updateCounter(credentialId: string, newCounter: number): Promise<void>;
}

describe('CredentialRepository', () => {
  it('saves and retrieves credential by ID', async () => {
    await repo.save('user-123', testCredential);
    const found = await repo.findById(testCredential.id);

    expect(found).not.toBeNull();
    expect(found!.id).toBe(testCredential.id);
    expect(found!.counter).toBe(0);
  });

  it('updates counter correctly', async () => {
    await repo.save('user-123', testCredential);
    await repo.updateCounter(testCredential.id, 5);

    const found = await repo.findById(testCredential.id);
    expect(found!.counter).toBe(5);
  });

  it('returns all credentials for user', async () => {
    await repo.save('user-123', { ...testCredential, id: 'cred-1' });
    await repo.save('user-123', { ...testCredential, id: 'cred-2' });

    const credentials = await repo.findByUserId('user-123');
    expect(credentials).toHaveLength(2);
  });
});

Integration Testing the Full Registration Flow

import request from 'supertest';
import app from '../src/app';

describe('WebAuthn Registration API', () => {
  it('POST /webauthn/registration/options returns valid options', async () => {
    const response = await request(app)
      .post('/webauthn/registration/options')
      .set('Cookie', 'session=test-session-id')
      .send({ userId: 'user-123' })
      .expect(200);

    expect(response.body).toMatchObject({
      rp: { id: 'example.com' },
      challenge: expect.any(String),
      pubKeyCredParams: expect.arrayContaining([
        expect.objectContaining({ alg: -7 }),
      ]),
    });
  });

  it('POST /webauthn/registration/verify rejects tampered response', async () => {
    // Get a valid challenge first
    const optionsResponse = await request(app)
      .post('/webauthn/registration/options')
      .set('Cookie', 'session=test-session-id')
      .send({ userId: 'user-123' });

    // Submit a tampered registration response
    const tamperedResponse = buildTamperedRegistrationResponse();

    const verifyResponse = await request(app)
      .post('/webauthn/registration/verify')
      .set('Cookie', 'session=test-session-id')
      .send(tamperedResponse)
      .expect(400);

    expect(verifyResponse.body.error).toBeDefined();
  });
});

Testing py_webauthn (Python)

The Python equivalent, py_webauthn, follows the same patterns:

import pytest
from webauthn import (
    generate_registration_options,
    verify_registration_response,
    generate_authentication_options,
    verify_authentication_response,
)
from webauthn.helpers.structs import (
    PublicKeyCredentialDescriptor,
    AuthenticatorTransport,
    RegistrationCredential,
)

class TestRegistrationOptions:
    def test_generates_unique_challenges(self):
        opts1 = generate_registration_options(
            rp_id="example.com",
            rp_name="Example Corp",
            user_name="user@example.com",
        )
        opts2 = generate_registration_options(
            rp_id="example.com",
            rp_name="Example Corp",
            user_name="user@example.com",
        )
        assert opts1.challenge != opts2.challenge

    def test_excludes_existing_credentials(self):
        existing = PublicKeyCredentialDescriptor(
            id=b"existing-credential-id",
            transports=[AuthenticatorTransport.INTERNAL],
        )
        opts = generate_registration_options(
            rp_id="example.com",
            rp_name="Example Corp",
            user_name="user@example.com",
            exclude_credentials=[existing],
        )
        assert len(opts.exclude_credentials) == 1

class TestVerificationErrors:
    def test_rejects_wrong_origin(self, test_registration_response):
        with pytest.raises(InvalidCBORData):
            verify_registration_response(
                credential=test_registration_response,
                expected_challenge=b"test-challenge",
                expected_rp_id="example.com",
                expected_origin="https://malicious.com",  # wrong
            )

    def test_rejects_replayed_challenge(self, test_registration_response):
        # First verification succeeds
        verify_registration_response(
            credential=test_registration_response,
            expected_challenge=TEST_CHALLENGE,
            expected_rp_id="example.com",
            expected_origin="https://example.com",
        )
        # Challenge must be deleted after use — second attempt with same challenge
        # should fail because challenge was consumed
        # (This tests your challenge store, not the library)
        with pytest.raises(AssertionError):
            challenge_store.get("user-123")  # should be None after first use

CI/CD Pipeline

# .github/workflows/webauthn-tests.yml
name: WebAuthn Tests

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm install
      - run: npm run test:unit -- --testPathPattern=webauthn

  integration-tests:
    runs-on: ubuntu-latest
    services:
      redis:
        image: redis:7
        ports: ['6379:6379']
    steps:
      - uses: actions/checkout@v3
      - run: npm install
      - run: npm run test:integration -- --testPathPattern=webauthn
        env:
          REDIS_URL: redis://localhost:6379
          WEBAUTHN_RP_ID: localhost
          WEBAUTHN_RP_NAME: Test App
          WEBAUTHN_ORIGIN: http://localhost:3000

Summary

Testing SimpleWebAuthn integrations requires: unit tests for challenge generation uniqueness and expiry, challenge storage isolation between users and one-time-use enforcement, credential repository save/retrieve/counter-update operations, registration options testing for exclusion lists and authenticator selection, API integration tests for both valid and tampered responses, and equivalent tests in py_webauthn for Python servers. The library handles cryptography — your tests must cover the application logic surrounding it: challenge lifecycle, credential storage, counter updates, and error handling.

Read more