npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

did-auth-challenge

v0.0.1

Published

DID-based challenge authentication for did:web, did:pkh, and did:key

Readme

did-auth-challenge

DID-based authentication middleware for Node.js.
Issues signed challenges and verifies signatures produced by did:web, did:pkh (EVM chains · Solana), and did:key controllers.


How it works

 Server                                        Client
   │                                              │
   │  createChallenge({ domain, action, … })      │
   │──────────────── challenge ──────────────────▶│
   │                                              │  wallet.sign(JSON.stringify(challenge))
   │◀──────────────── signature ─────────────────│
   │                                              │
   │  verifySignature(did, key?, challenge, sig)  │
   │  → true / false / throws                     │
  1. createChallenge wraps any payload with a 64-byte random nonce, an issuedAt timestamp, and an expirationTime 30 seconds later. The nonce is registered in an in-process NonceStore.

  2. The client signs JSON.stringify(challenge) with their wallet.
    The exact JSON string must be signed — no re-parsing or re-serialising.

  3. verifySignature validates the challenge (expiry + nonce freshness), then resolves the DID and verifies the signature. The nonce is consumed on first use regardless of signature validity (replay protection).


Installation

npm install did-auth-challenge

Requires Node.js ≥ 18 (built-in fetch used for did:web resolution). EVM signature verification uses viem; did:web and did:key resolution use web-did-resolver and key-did-resolver.


Public API

Functions: createChallenge, verifySignature, NonceStore, isIdentityError, createDidVerifierRegistry, createDefaultDidResolver, createResolveDidDocument, parseDidMethod

Values: IdentityError, IdentityErrorCode, defaultDidVerifierRegistry, resolveDidDocument

Types: Challenge, CreateChallengeOptions, VerifySignatureInput, VerifySignatureOptions, ExpectedBindings, NonceStoreInterface, AsyncNonceStoreInterface, NonceRecord, DidVerifier, DidVerifierRegistry, DidVerifyContext, ResolveDidDocumentFn, CreateDidVerifierRegistryOptions


Quick start

import { createChallenge, verifySignature } from 'did-auth-challenge';

// 1. Server: issue challenge — include the route being authenticated
const challenge = await createChallenge({ domain: 'example.com', route: '/api/protected' });
// {
//   domain: 'example.com',
//   route: '/api/protected',
//   nonce: 'a3f8…',
//   issuedAt: '2025-01-01T00:00:00.000Z',
//   expirationTime: '2025-01-01T00:00:30.000Z'
// }

// 2. Client: sign JSON.stringify(challenge) with their wallet, send back did + signature.

// 3. Server: verify (strict mode is on by default)
await verifySignature({
  did: 'did:pkh:eip155:8453:0xYourAddress',
  challenge,
  signature: '0x...',
  options: {
    expected: { domain: 'example.com', route: '/api/protected' },
  },
});
// throws IdentityError subclasses on failure; returns true on success

API

createChallenge<T>(payload: T, options?): Promise<T & Challenge>

Adds challenge fields to an arbitrary payload object.

| Parameter | Type | Description | | -------------------- | --------------------- | ------------------------------------------------------------ | | payload | object | Your application-specific data (domain, action, user ID, …). | | options.nonceStore | NonceStoreInterface | Custom nonce store (default: module-level singleton). |

Returns the merged object with:

| Field | Type | Description | | ---------------- | -------- | ---------------------------------------------- | | nonce | string | 64-character lowercase hex (32 random bytes). | | issuedAt | string | ISO 8601 UTC timestamp. | | expirationTime | string | ISO 8601 UTC timestamp, 30 s after issuedAt. |


verifySignature(input): Promise<true>

Verifies the owner of did signed the issued challenge bytes.

| Field | Type | Description | | ------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | did | string | A did:pkh:…, did:web:…, or did:key:… DID string. | | authenticationKey | string \| undefined | did:web / did:key — key ID fragment (e.g. #key-1) or absolute ID. Defaults to the first entry in authentication. | | challenge | Challenge | The object returned by createChallenge. | | signature | string | The wallet signature (see Signature formats). | | options.nonceStore | NonceStoreInterface | Must match the store used in createChallenge. | | options.expected | ExpectedBindings | Required when strict is true (default). domain and route must match the challenge. | | options.strict | boolean | Default true. Enforces expected bindings and challenge field validation. | | options.maxClockSkewMs | number | Default 60000. Rejects challenges with issuedAt too far in the future. | | options.didResolver | Resolver | Custom did-resolver instance for document-based methods (web, key). | | options.methods | Record<string, DidVerifier> | Add or override DID method verifiers (merged over built-in pkh / web / key). | | options.registry | DidVerifierRegistry | Full registry replacement. Takes precedence over methods and didResolver. |

Returns true on success.
Throws IdentityError — use isIdentityError(err) and err.code (IdentityErrorCode) to handle failures:

import { verifySignature, isIdentityError, IdentityErrorCode } from 'did-auth-challenge';

try {
  await verifySignature({ did, challenge, signature, options });
} catch (err) {
  if (isIdentityError(err)) {
    if (err.code === IdentityErrorCode.CHALLENGE_EXPIRED) {
      /* ... */
    }
  }
}

| IdentityErrorCode | When | | ------------------------- | ------------------------------------------------------------ | | CHALLENGE_EXPIRED | expirationTime is in the past. | | CHALLENGE_NOT_YET_VALID | issuedAt is too far in the future. | | INVALID_CHALLENGE | Malformed challenge or bytes do not match issued challenge. | | INVALID_NONCE | Nonce is unknown or was already consumed. | | DOMAIN_MISMATCH | Challenge domain does not match options.expected.domain. | | ROUTE_MISMATCH | Challenge route does not match options.expected.route. | | METHOD_MISMATCH | Challenge method does not match options.expected.method. | | DID_MISMATCH | Challenge did does not match the did parameter. | | UNSUPPORTED_DID_METHOD | DID method is not did:pkh, did:web, or did:key. | | INVALID_SIGNATURE | Signature verification failed. | | DID_RESOLUTION_FAILED | did:web or did:key document could not be resolved. |

Pluggable DID method registry

Built-in methods (pkh, web, key) register automatically. Add new methods without modifying library internals:

import {
  verifySignature,
  createDidVerifierRegistry,
  createDefaultDidResolver,
  type DidVerifyContext,
} from 'did-auth-challenge';
import { getResolver as ethrDidResolver } from 'ethr-did-resolver';

// Option A: per-request overrides via verifySignature options
await verifySignature({
  did: 'did:ethr:0x…',
  challenge,
  signature,
  options: {
    didResolver: createDefaultDidResolver(ethrDidResolver()),
    methods: {
      ethr: async ({ did, authenticationKey, message, signature, resolveDidDocument }) => {
        const doc = await resolveDidDocument(did);
        // verify against doc…
        return true;
      },
    },
    expected: { domain: 'example.com', route: challenge.route },
  },
});

// Option B: reusable registry
const registry = createDidVerifierRegistry({
  didResolver: createDefaultDidResolver(ethrDidResolver()),
  methods: { ethr: myEthrVerifier },
});
registry.register('custom', async (ctx: DidVerifyContext) => {
  /* … */
});

await verifySignature({
  did,
  challenge,
  signature,
  options: { registry, expected: { domain: 'example.com', route: challenge.route } },
});

DidVerifyContext includes resolveDidDocument so document-based verifiers can resolve DIDs with your injected Resolver.


Supported DID methods

did:pkh — blockchain accounts

Format: did:pkh:{namespace}:{chainReference}:{address}

| Namespace | Chains | Signature format | | --------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------ | | eip155 | Ethereum · Base (8453) · Tempo · any EVM chain | EIP-191 personal_sign — 65-byte r \|\| s \|\| v as 0x-prefixed hex (verified via viem) | | solana | Solana mainnet | Raw Ed25519 — 64-byte signature as hex, base58, or base64 |

EVM example

// did:pkh:eip155:8453:0xAbCd… (Base mainnet)
const sig = await wallet.signMessage(JSON.stringify(challenge));
await verifySignature({
  did: 'did:pkh:eip155:8453:0xAbCd…',
  challenge,
  signature: sig,
  options: { expected: { domain: 'example.com', route: challenge.route } },
});

Solana example

// did:pkh:solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV
import { sign } from '@solana/web3.js'; // or @solana/signers

const msgBytes = new TextEncoder().encode(JSON.stringify(challenge));
const sigBase58 = bs58.encode(nacl.sign.detached(msgBytes, keypair.secretKey));
await verifySignature({
  did: `did:pkh:solana:4sGjM…:${pubkeyBase58}`,
  challenge,
  signature: sigBase58,
  options: { expected: { domain: 'example.com', route: challenge.route } },
});

did:web — web-hosted DID documents

The DID document is resolved via web-did-resolver (HTTPS). Localhost is resolved over plain HTTP for development.

did:web:example.com            → https://example.com/.well-known/did.json
did:web:example.com:users:bob  → https://example.com/users/bob/did.json
did:web:localhost%3A3000       → http://localhost:3000/.well-known/did.json  (dev)

Local development: localhost and 127.0.0.1 are resolved over plain HTTP.
All other hosts require HTTPS as per the did:web specification.

Supported verification method types:

| Type | Key format | Signature format | | ----------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------- | | Ed25519VerificationKey2020 | publicKeyMultibase (z-prefix, multicodec 0xed01) | 64-byte Ed25519 (hex / base58 / base64) | | Ed25519VerificationKey2018 | publicKeyBase58 | 64-byte Ed25519 (hex / base58 / base64) | | JsonWebKey2020 crv Ed25519 | publicKeyJwk.x (base64url) | 64-byte Ed25519 (hex / base58 / base64) | | EcdsaSecp256k1VerificationKey2019 | publicKeyHex / publicKeyBase58 | compact (64 B) or DER hex, SHA-256 message hash | | Secp256k1VerificationKey2018 | publicKeyHex / publicKeyBase58 / JWK | compact (64 B) or DER hex, SHA-256 message hash | | JsonWebKey2020 crv secp256k1 | publicKeyJwk.x + .y | compact (64 B) or DER hex, SHA-256 message hash | | JsonWebKey2020 crv P-256 | publicKeyJwk.x + .y | DER or compact ECDSA, SHA-256 message hash (WebCrypto / passkey) | | EcdsaSecp256r1VerificationKey2019 | publicKeyJwk / publicKeyHex / multibase | DER or compact ECDSA, SHA-256 message hash | | EcdsaSecp256k1RecoveryMethod2020 | blockchainAccountId | EIP-191 personal_sign — 65-byte hex |

await verifySignature({
  did: 'did:web:example.com',
  authenticationKey: '#key-1',
  challenge,
  signature,
  options: { expected: { domain: 'example.com', route: challenge.route } },
});

did:key — self-contained key DIDs

The DID document is derived locally via key-did-resolver — no network fetch required.

Supported verification method types match did:web (Ed25519, secp256k1, P-256, JWK). Use authenticationKey to pin a specific key when the DID has multiple verification methods.

await verifySignature({
  did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK',
  challenge,
  signature,
  options: { expected: { domain: 'example.com', route: challenge.route } },
});

Nonce management

The default NonceStore is an in-process Map with a 60-second cleanup interval.
It works for single-server deployments out of the box.

NonceStoreInterface is asynccreateChallenge and verifySignature await store operations.
Use a shared backend (Redis, DynamoDB, etc.) for stateless or multi-instance deployments.

import { createChallenge, verifySignature } from 'did-auth-challenge';
import type { NonceRecord, NonceStoreInterface } from 'did-auth-challenge';
import type { Redis } from 'ioredis';

class RedisNonceStore implements NonceStoreInterface {
  constructor(private redis: Redis) {}

  async add(nonce: string, expiresAt: Date, challengeJson: string): Promise<void> {
    const ttl = Math.max(1, Math.ceil((expiresAt.getTime() - Date.now()) / 1000));
    const record: NonceRecord = {
      challengeJson,
      expiresAt: expiresAt.getTime(),
    };
    await this.redis.set(`nonce:${nonce}`, JSON.stringify(record), 'EX', ttl);
  }

  async get(nonce: string): Promise<NonceRecord | null> {
    const raw = await this.redis.get(`nonce:${nonce}`);
    if (!raw) return null;
    const record = JSON.parse(raw) as NonceRecord;
    if (Date.now() > record.expiresAt) {
      await this.redis.del(`nonce:${nonce}`);
      return null;
    }
    return record;
  }

  async has(nonce: string): Promise<boolean> {
    return (await this.get(nonce)) !== null;
  }

  async consume(nonce: string): Promise<NonceRecord | null> {
    const raw = await this.redis.getDel(`nonce:${nonce}`);
    if (!raw) return null;
    return JSON.parse(raw) as NonceRecord;
  }
}

const nonceStore = new RedisNonceStore(redis);

const challenge = await createChallenge(
  { domain: 'example.com', route: '/api/foo' },
  { nonceStore },
);
await verifySignature({
  did,
  challenge,
  signature: sig,
  options: { nonceStore, expected: { domain: 'example.com', route: '/api/foo' } },
});

GETDEL requires Redis ≥ 6.2 for atomic consume. On older Redis, use a Lua script or MULTI/EXEC instead.


Signature formats — summary

| DID method | Signature encoding | | ------------------------- | ---------------------------------------------------- | | did:pkh:eip155:* | 0x + 130 hex chars (r||s||v, v = 27 or 28) | | did:pkh:solana:* | 64-byte Ed25519 as base58, base64, or hex | | did:web + Ed25519 key | 64-byte Ed25519 as base58, base64, base64url, or hex | | did:web + secp256k1 key | 64-byte compact or DER, as hex | | did:web + P-256 key | DER or compact ECDSA as hex, base64url, or base64 | | did:web + recovery key | same as eip155 (viem) | | did:key + Ed25519 | 64-byte Ed25519 as base58, base64, base64url, or hex | | did:key + secp256k1 | 64-byte compact or DER, as hex | | did:key + P-256 | DER or compact ECDSA as hex, base64url, or base64 |


Live examples

The examples/ directory contains two servers and a scenario runner that exercise every supported identity type over a shared The-Diddler header protocol.

| Server | Port | Nonce store | Framework | | ------- | ---- | --------------------------- | -------------------------- | | Hono | 3001 | in-memory (single-instance) | examples/hono/server.mjs | | Next.js | 3000 | Redis | examples/next/ |

Both expose:

  • GET /.well-known/did.json — multi-key did:web document (Ed25519, secp256k1, P-256)
  • GET /ping — returns 402 with The-Diddler challenge header when unauthenticated; returns 200 { pong: true } after valid proof

The-Diddler protocol

  1. GET /ping without header → 402 + The-Diddler: base64url({ v:1, challenge })
  2. Client signs JSON.stringify(challenge)
  3. GET /ping with The-Diddler: base64url({ v:1, challenge, did, signature, authenticationKey? })200

Run locally

pnpm build
pnpm example:fixtures

# Terminal 1 — Hono (in-memory)
pnpm example:hono

# Terminal 2 — Redis for Next
pnpm example:redis

# Terminal 3 — Next.js (Redis-backed)
pnpm example:next

# Terminal 4 — run all scenarios against both servers
pnpm example:clients

Run a single scenario:

node examples/clients/run.mjs --server hono --scenario did:key:ed25519
node examples/clients/run.mjs --server next --scenario did:pkh:eip155

Scenarios: did:key:ed25519, did:key:secp256k1, did:key:p256, did:web:ed25519, did:web:secp256k1, did:web:p256, did:pkh:eip155, did:pkh:solana.

Fixtures live in examples/fixtures/keys.json (deterministic test keys — local dev only).


Security notes

  • Nonce replay: the nonce is consumed on first call to verifySignature, even if the signature is invalid. This prevents brute-force attempts against a single challenge.
  • Expiry: challenges are valid for 30 seconds. Both the expirationTime field and the nonce-store TTL enforce this independently.
  • DID document integrity: did:web resolution validates that document.id matches the requested DID. Always serve DID documents over TLS.
  • Key confusion: authenticationKey lets you pin verification to a specific key, avoiding cross-purpose key reuse.