createNonce / verifyNonce are misleading for auth — and redundant with signatures
TL;DR
createNonce / verifyNonce in @bsv/sdk are easy to mistake for a
ready-made "prove the caller owns this key" auth mechanism. They aren't a good
fit for that, for three reasons:
- The name "nonce" implies single-use / replay protection that the
primitive does not provide.
- For request authentication they are redundant with
createSignature /
verifySignature, which do the same job better (asymmetric, and able to
sign arbitrary data such as an expiry).
- Used as a standalone auth token, the only way to make them truly single-use
is to store every nonce forever, because they never expire — which isn't
plausible.
What the primitive actually is
createNonce(wallet, counterparty):
bytes = Random(16)
hmac = wallet.createHmac({ protocolID: [2,'server hmac'], keyID: utf8(bytes), data: bytes, counterparty })
return base64(bytes ‖ hmac)
verifyNonce(nonce, wallet, counterparty):
re-derive the HMAC over the first 16 bytes and compare
So a "nonce" here is 16 random bytes plus an HMAC of those bytes, keyed by
the ECDH shared secret between the two identities. There is no timestamp, no
expiry, and no embedded caller data — the only thing authenticated is the random
bytes the function generated itself.
1. The name oversells it
"Nonce" means number used once — it implies freshness and replay protection.
This primitive provides neither: it has no expiry and no single-use bookkeeping.
It's really a keyed token, not a nonce in the anti-replay sense. Developers
reasonably assume "verifyNonce passed → this is a fresh, one-time request," and
that assumption is wrong.
2. It's redundant with signatures for auth
If the goal is "prove the request came from the holder of identity key K",
createSignature / verifySignature already do that, and strictly better:
- Asymmetric. A signature can only be produced by
K's private key. The
nonce HMAC is keyed by the ECDH shared secret, so it's symmetric — either
party (client or server) can produce a value that verifies.
- Can carry data. A signature can sign an arbitrary payload, so you can bind
an expiry, an action, and an audience into the thing you verify.
The nonce can carry none of that — it only MACs random bytes.
So once you have signatures, a separate "nonce" primitive adds no security to an
auth flow; it just looks like it does.
3. As a security control it would need unbounded, permanent storage
The only way to turn a never-expiring token into a genuine single-use one is to
remember every token you've ever accepted — forever. The moment the server
evicts a used nonce, that nonce becomes replayable again, because nothing in the
nonce itself says "this is stale." Permanent unbounded storage of every login
handshake is not plausible in practice.
Contrast a signed payload with an expiry: the server rejects anything past
expiresAt, and only needs to remember used tokens until they expire —
bounded storage (e.g. a TTL collection / short-lived cache). Replay is closed
without storing anything forever.
What the primitive was actually designed for
In the SDK's own auth protocol (Peer / BRC-103), createNonce is called with
counterparty 'self': the server issues a challenge it can later recognise as
its own (a stateless self-MAC), and the actual authentication comes from
ECDSA signatures over the challenge pair plus a session manager that tracks
nonces for freshness/single-use. The nonce there is challenge plumbing inside a
challenge–response handshake — not a standalone bearer proof.
The trap is that copying createNonce + verifyNonce into a one-shot "login"
endpoint silently drops the two things that made them safe in context: the
signature, and the tracking.
Note that the session manager's nonce tracking runs into the same storage
problem from section 3 — a never-expiring nonce can only be "used once" if you
remember it forever. The difference is what the tracking is guarding: in the
intended design the ECDSA signature is the real authentication, so the nonce
tracking is only defense-in-depth, and bounding/forgetting it is effectively a
no-op for security. In the misuse case the nonce is the guard, so that same
unbounded-storage problem becomes fatal rather than cosmetic.
Suggestions
- Clarify in the docs/JSDoc that these are challenge tokens for a
challenge–response handshake, not a standalone proof of key ownership, and
that they carry no expiry or replay protection on their own.
- Point auth use-cases at
createSignature / verifySignature over a payload
that includes an expiry (+ a short-lived single-use store), rather than at
createNonce.
- Consider a name that doesn't imply single-use (e.g. "challenge"), or a brief
"Don't use this for login" note with a pointer to the signature approach.
createNonce/verifyNonceare misleading for auth — and redundant with signaturesTL;DR
createNonce/verifyNoncein@bsv/sdkare easy to mistake for aready-made "prove the caller owns this key" auth mechanism. They aren't a good
fit for that, for three reasons:
primitive does not provide.
createSignature/verifySignature, which do the same job better (asymmetric, and able tosign arbitrary data such as an expiry).
is to store every nonce forever, because they never expire — which isn't
plausible.
What the primitive actually is
So a "nonce" here is 16 random bytes plus an HMAC of those bytes, keyed by
the ECDH shared secret between the two identities. There is no timestamp, no
expiry, and no embedded caller data — the only thing authenticated is the random
bytes the function generated itself.
1. The name oversells it
"Nonce" means number used once — it implies freshness and replay protection.
This primitive provides neither: it has no expiry and no single-use bookkeeping.
It's really a keyed token, not a nonce in the anti-replay sense. Developers
reasonably assume "verifyNonce passed → this is a fresh, one-time request," and
that assumption is wrong.
2. It's redundant with signatures for auth
If the goal is "prove the request came from the holder of identity key
K",createSignature/verifySignaturealready do that, and strictly better:K's private key. Thenonce HMAC is keyed by the ECDH shared secret, so it's symmetric — either
party (client or server) can produce a value that verifies.
an expiry, an action, and an audience into the thing you verify.
The nonce can carry none of that — it only MACs random bytes.
So once you have signatures, a separate "nonce" primitive adds no security to an
auth flow; it just looks like it does.
3. As a security control it would need unbounded, permanent storage
The only way to turn a never-expiring token into a genuine single-use one is to
remember every token you've ever accepted — forever. The moment the server
evicts a used nonce, that nonce becomes replayable again, because nothing in the
nonce itself says "this is stale." Permanent unbounded storage of every login
handshake is not plausible in practice.
Contrast a signed payload with an expiry: the server rejects anything past
expiresAt, and only needs to remember used tokens until they expire —bounded storage (e.g. a TTL collection / short-lived cache). Replay is closed
without storing anything forever.
What the primitive was actually designed for
In the SDK's own auth protocol (
Peer/ BRC-103),createNonceis called withcounterparty
'self': the server issues a challenge it can later recognise asits own (a stateless self-MAC), and the actual authentication comes from
ECDSA signatures over the challenge pair plus a session manager that tracks
nonces for freshness/single-use. The nonce there is challenge plumbing inside a
challenge–response handshake — not a standalone bearer proof.
The trap is that copying
createNonce+verifyNonceinto a one-shot "login"endpoint silently drops the two things that made them safe in context: the
signature, and the tracking.
Note that the session manager's nonce tracking runs into the same storage
problem from section 3 — a never-expiring nonce can only be "used once" if you
remember it forever. The difference is what the tracking is guarding: in the
intended design the ECDSA signature is the real authentication, so the nonce
tracking is only defense-in-depth, and bounding/forgetting it is effectively a
no-op for security. In the misuse case the nonce is the guard, so that same
unbounded-storage problem becomes fatal rather than cosmetic.
Suggestions
challenge–response handshake, not a standalone proof of key ownership, and
that they carry no expiry or replay protection on their own.
createSignature/verifySignatureover a payloadthat includes an expiry (+ a short-lived single-use store), rather than at
createNonce."Don't use this for login" note with a pointer to the signature approach.