# HTTP message signatures (RFC 9421)

DaloyJS ships first-party **HTTP Message Signatures** ([RFC 9421](https://www.rfc-editor.org/rfc/rfc9421)), the IETF-standard way to prove a server-to-server request came from a trusted peer. Where [webhook HMAC](/docs/webhook-delivery) binds a signature to a request *body* and [mTLS](/docs/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 `algorithms` allowlist. 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", "@target-uri"]`), so a peer cannot sign an empty or irrelevant component set. The default binds scheme, authority, path, **and query**. A path-only signature (`@path`) no longer satisfies a default verify, so an attacker cannot swap the query string under a signature that left it unbound. Pass `requiredComponents: ["@method", "@path"]` explicitly if you deliberately sign only the path.
- `@query-param` refuses to sign a parameter that appears more than once. Signing only the first value while an app or intermediary reads the last value (or the full array) is a classic HTTP parameter-pollution differential. Cover `@query` or `@target-uri` instead when multiple values are legitimate.
- 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`.

**Diagram: Sign then verify**

Participants: Caller, httpSignatureAuth(), Handler

1. **Caller -> httpSignatureAuth()** (request) - Request with Signature / Signature-Input - covers @method, @path, @authority, content-digest, ...
2. **httpSignatureAuth() -> httpSignatureAuth()** (note) - Resolve keyid -> key (alg pinned to key) - alg not in allowlist -> alg_not_allowed. Key missing -> key_not_found
3. **httpSignatureAuth() -> Caller** (note) - Forged / stale / replayed / missing component -> 401 - invalid_signature, signature_stale, replay_detected, missing_required_component
4. **httpSignatureAuth() -> Handler** (response) - Signature valid + fresh -> proceed - ctx.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,
    // Default is ["@method", "@target-uri"] (binds path + query). Tighten further
    // when you need authority or specific headers covered.
    requiredComponents: ["@method", "@target-uri", "@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, {
  // Default is ["@method", "@target-uri"]; add content-type / authority as needed.
  components: ["@method", "@target-uri", "@authority", "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).

---

Source: https://daloyjs.dev/docs/http-signatures