src/auth/nostr.js verifies the NIP-98 payload tag by hashing JSON.stringify(request.body) — i.e. the parsed-then-re-serialized (compact) body — instead of the raw request bytes the client actually signed. Any NIP-98 client that sends non-minified JSON (pretty-printed, or a different key order / string escaping than Node's JSON.stringify produces) fails with Payload hash mismatch → 401, even though the signature, URL, method, and timestamp are all valid.
Where
src/auth/nostr.js, the payload-hash block (~L244–262):
When the JSON body parser turns the request into an object, request.body is that object, so the else branch runs JSON.stringify(request.body) → compact JSON. The client (per NIP-98) hashed the exact body bytes it sent. If those bytes were pretty-printed (JSON.stringify(x, null, 2)) — or just serialized by a different implementation — the two hashes differ.
Repro
PUT a WAC-protected resource with NIP-98 auth and a pretty-printed JSON body:
Sending the same entry as compact JSON.stringify(entry) succeeds — because it happens to match the server's re-serialization. That's a brittle coincidence, not correctness.
Impact
NIP-98 writes only work if the client's body is byte-identical to Node's JSON.stringify(parsed) — i.e. minified, with Node's exact key order and escaping. Pretty-printed bodies, or bodies from other serializers, always 401.
It silently pushes every NIP-98 client toward "send minified JSON," which isn't a real spec requirement and is easy to get wrong. (We hit this from a browser; worked around it by forcing compact JSON.stringify.)
DPoP/Solid writes are unaffected (no payload-hash check), so the bug only bites Nostr auth — making it look like "NIP-98 writes are broken" when the signature is actually fine.
Proposed fix
Hash the raw request body bytes, never a re-serialization. NIP-98's payload tag is sha256(request body) over the bytes on the wire. Options:
Capture the raw body (e.g. a Fastify content-type parser that stashes the original buffer/string, or fastify-raw-body) and hash that.
Then bodyString is the original bytes regardless of pretty/compact/escaping, and any conformant NIP-98 client verifies.
Keep the existing string/Buffer branches as-is; only the else { JSON.stringify(...) } fallback is wrong — there should be no fallback that re-serializes the parsed object.
Notes
Found while onboarding a Nostr guest key (xlogin guestLogin) writing a pool entry from a browser; the signature/URL/method all verified, only the payload hash failed.
Activity
fix(nostr): NIP-98 payload hash verifies raw request bytes, not a re-…
bump version to 0.0.209