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 │createChallengewraps any payload with a 64-byte randomnonce, anissuedAttimestamp, and anexpirationTime30 seconds later. The nonce is registered in an in-processNonceStore.The client signs
JSON.stringify(challenge)with their wallet.
The exact JSON string must be signed — no re-parsing or re-serialising.verifySignaturevalidates 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-challengeRequires 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 successAPI
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:
localhostand127.0.0.1are 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 async — createChallenge 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' } },
});
GETDELrequires Redis ≥ 6.2 for atomic consume. On older Redis, use a Lua script orMULTI/EXECinstead.
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-keydid:webdocument (Ed25519, secp256k1, P-256)GET /ping— returns 402 withThe-Diddlerchallenge header when unauthenticated; returns 200{ pong: true }after valid proof
The-Diddler protocol
GET /pingwithout header → 402 +The-Diddler: base64url({ v:1, challenge })- Client signs
JSON.stringify(challenge) GET /pingwithThe-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:clientsRun a single scenario:
node examples/clients/run.mjs --server hono --scenario did:key:ed25519
node examples/clients/run.mjs --server next --scenario did:pkh:eip155Scenarios: 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
expirationTimefield and the nonce-store TTL enforce this independently. - DID document integrity:
did:webresolution validates thatdocument.idmatches the requested DID. Always serve DID documents over TLS. - Key confusion:
authenticationKeylets you pin verification to a specific key, avoiding cross-purpose key reuse.
