Skip to content

Search docs

Jump between documentation pages.

Browse docs

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.
  • created is required by default and the signature is rejected once it is older than DEFAULT_MAX_SIGNATURE_AGE_SECONDS (300s), or if created is in the future / expires has passed (outside a small clock-skew tolerance).
  • A configurable requiredComponents set 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 nonce replay defense via an isReplay callback.

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.

Sign then verify
CallerhttpSignatureAuth()Handler
  1. 01requestCallerhttpSignatureAuth()Request with Signature / Signature-Inputcovers @method, @path, @authority, content-digest, ...
  2. 02notehttpSignatureAuth()httpSignatureAuth()Resolve keyid -> key (alg pinned to key)alg not in allowlist -> alg_not_allowed; key missing -> key_not_found
  3. 03notehttpSignatureAuth()CallerForged / stale / replayed / missing component -> 401invalid_signature, signature_stale, replay_detected, missing_required_component
  4. 04responsehttpSignatureAuth()HandlerSignature valid + fresh -> proceedctx.state.httpSignature = VerifySuccess
The verifier requires an explicit algorithms allowlist, a fresh created timestamp, and a covered requiredComponents set. Any failure rejects with 401 and Cache-Control: no-store before the handler runs.
ts
import { createApp } from "@daloyjs/core";
import { httpSignatureAuth } from "@daloyjs/core";

const app = createApp();

// Shared secret per calling service (>= 32 bytes).
const KEYS: Record<string, Uint8Array> = {
  "svc-a": new TextEncoder().encode(process.env.SVC_A_SECRET!),
};

app.use(
  httpSignatureAuth({
    algorithms: ["hmac-sha256"],
    // Pin the algorithm to the key to defeat algorithm-confusion.
    resolveKey: ({ keyid }) =>
      keyid && KEYS[keyid]
        ? { alg: "hmac-sha256", key: KEYS[keyid] }
        : undefined,
    requiredComponents: ["@method", "@path", "@authority"],
  }),
);

app.post(
  "/internal/charge",
  {
    responses: { 200: { description: "ok" } },
  },
  (ctx) => {
    const sig = ctx.state.httpSignature; // verified VerifySuccess
    return { status: 200, body: { caller: sig.keyid } };
  },
);

Sign an outbound request

signRequest() returns a new Request with the Signature and Signature-Input headers attached (the original is not mutated).

ts
import { signRequest } from "@daloyjs/core";

const secret = new TextEncoder().encode(process.env.SVC_A_SECRET!);

const req = new Request("https://billing.internal/internal/charge", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ amount: 100 }),
});

const signed = await signRequest(req, {
  components: ["@method", "@authority", "@path", "content-type"],
  alg: "hmac-sha256",
  key: secret,
  keyid: "svc-a",
});

await fetch(signed);

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().

ts
import { contentDigest, signRequest, verifyContentDigest } from "@daloyjs/core";

const body = JSON.stringify({ amount: 100 });
const digest = await contentDigest(body); // "sha-256=:<base64>:"

const req = new Request("https://billing.internal/charge", {
  method: "POST",
  headers: { "content-type": "application/json", "content-digest": digest },
  body,
});
const signed = await signRequest(req, {
  components: ["@method", "@path", "content-digest"],
  alg: "hmac-sha256",
  key: secret,
  keyid: "svc-a",
});

// On the receiver, after httpSignatureAuth() verified the signature:
const raw = await request.text();
if (!(await verifyContentDigest(request.headers.get("content-digest") ?? "", raw))) {
  throw new Error("body does not match its signed digest");
}

Low-level sign / verify

signMessage() and verifyMessage() work with plain method/URL/headers when you are not inside a request/response object.

ts
import { signMessage, verifyMessage } from "@daloyjs/core";

const sig = await signMessage({
  method: "GET",
  url: "https://api.example.com/me",
  headers: { host: "api.example.com" },
  components: ["@method", "@path", "@authority"],
  alg: "ed25519",
  key: privateKey, // CryptoKey | Uint8Array | JsonWebKey
  keyid: "ed-1",
});

const result = await verifyMessage({
  method: "GET",
  url: "https://api.example.com/me",
  headers: {
    host: "api.example.com",
    "signature-input": sig.signatureInput,
    signature: sig.signature,
  },
  algorithms: ["ed25519"],
  resolveKey: () => ({ alg: "ed25519", key: publicKey }),
});

if (!result.valid) {
  // result.reason is a stable machine-readable code, e.g. "invalid_signature",
  // "signature_stale", "alg_not_allowed", "missing_required_component".
  throw new Error(result.reason);
}

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).