HTTP message signatures (RFC 9421)
DaloyJS ships first-party HTTP Message Signatures (RFC 9421), the IETF-standard way to prove a server-to-server request came from a trusted peer. Where webhook HMAC binds a signature to a request body and mTLS authenticates the TLS peer, message signatures bind a signature to a caller-chosen set of HTTP message components(method, path, authority, selected headers…) carried in the standard Signature / Signature-Input headers.
The module is dependency-free and runtime-portable (WebCrypto only, no node: imports) and is imported from the @daloyjs/core root or the @daloyjs/core/http-signatures subpath.
Secure-by-default
- The verifier requires an explicit
algorithmsallowlist. There is no implicit “accept any algorithm” mode, and a resolved key may pin its own algorithm to defeat algorithm-confusion. createdis required by default and the signature is rejected once it is older thanDEFAULT_MAX_SIGNATURE_AGE_SECONDS(300s), or ifcreatedis in the future /expireshas passed (outside a small clock-skew tolerance).- A configurable
requiredComponentsset must be covered (default["@method", "@path"]), so a peer cannot sign an empty or irrelevant component set. - Raw HMAC keys must be at least 32 bytes (RFC 7518 §3.2). SHA-1 and
alg: "none"-style escapes do not exist. - RSA keys (
rsa-pss-sha512,rsa-v1_5-sha256) must have at least a 2048-bit modulus. Shorter keys are refused, in parity with the JWT verifier and per NIST SP 800-131A (RSA under 2048 bits has been disallowed since 2014). - Optional
noncereplay defense via anisReplaycallback.
Supported algorithms
The labels map 1:1 onto the RFC 9421 HTTP Signature Algorithms registry:
hmac-sha256: symmetric shared secret (simplest to deploy).ed25519,ecdsa-p256-sha256,ecdsa-p384-sha384: asymmetric (publish a public key, no shared secret).rsa-pss-sha512,rsa-v1_5-sha256: RSA (2048-bit modulus floor; see below).
Verify inbound requests (middleware)
httpSignatureAuth() rejects any request without a valid signature with a 401 (Cache-Control: no-store) and stamps the verified result on ctx.state.httpSignature.
- 01requestCallerhttpSignatureAuth()Request with Signature / Signature-Inputcovers @method, @path, @authority, content-digest, ...
- 02notehttpSignatureAuth()httpSignatureAuth()Resolve keyid -> key (alg pinned to key)alg not in allowlist -> alg_not_allowed; key missing -> key_not_found
- 03notehttpSignatureAuth()CallerForged / stale / replayed / missing component -> 401invalid_signature, signature_stale, replay_detected, missing_required_component
- 04responsehttpSignatureAuth()HandlerSignature valid + fresh -> proceedctx.state.httpSignature = VerifySuccess
Sign an outbound request
signRequest() returns a new Request with the Signature and Signature-Input headers attached (the original is not mutated).
Bind the body with Content-Digest (RFC 9530)
Message signatures cover headers and derived components, not the body. To bind the body, compute a Content-Digest header with contentDigest(), include content-digest in the covered components, and re-check it on the receiving side with verifyContentDigest().
Low-level sign / verify
signMessage() and verifyMessage() work with plain method/URL/headers when you are not inside a request/response object.
Rejection reasons
verifyMessage() / verifyRequest() never throw on a forged or malformed signature. They return { valid: false, reason } with a stable code such as invalid_signature, signature_stale, created_in_future, signature_expired, missing_created, missing_required_component, alg_not_allowed, alg_mismatch, key_not_found, replay_detected, tag_mismatch, or malformed_signature_headers. They throw only on a programming error (an empty algorithms allowlist, or WebCrypto being unavailable).