Search docs

Jump between documentation pages.

Browse docs

HTTP message signatures (RFC 9421)

As of 0.37.0 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.
  • 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.

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.

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.route({
  method: "POST",
  path: "/internal/charge",
  responses: { 200: { description: "ok" } },
  handler: (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).