HomeJournalThis post

HTTP Message Signatures in TypeScript

Build a TypeScript HTTP signature verifier around covered components, canonical bytes, content digests, key policy, clock windows, proxies, replay, and denial tests.

JP
JP Casabianca
UI/UX designer and full-stack engineer · Bogotá

HTTP Message Signatures let an API verify selected request components after transport, but only if client and server construct exactly the same signature base. This TypeScript tutorial binds method, target URI, body digest, request identity, time, and key policy, then attacks mutation, proxy rewriting, and replay.

HTTP Message Signatures cover selected meaning

A message signature is not a magical seal over an abstract request. It covers an ordered list of derived components and fields, plus signature parameters. If the client signs method and content digest but not target URI, a valid message may be replayed against another endpoint. If it signs a volatile proxy header, legitimate requests may fail after transit.

Write the request contract before the cryptography. For a payment command, cover @method, @target-uri, content-digest, content-type, and a stable request identifier. Bind creation time, expiration, key ID, algorithm policy, and nonce through signature parameters. HTTP Message Signatures now protect the pieces that define this operation's meaning.

Transport TLS still protects the connection and authenticates its endpoint. OAuth may authorize a principal. An idempotency key may deduplicate effects. Message signatures add integrity and proof of key control for selected HTTP semantics; they do not replace those layers. Keeping that boundary explicit prevents an otherwise elegant verifier from becoming a vague claim that the request is “secure.”

Read RFC 9421 as a byte contract

The RFC 9421 specification defines component identifiers, signature parameters, and base construction. RFC 9530 defines modern digest fields, while the Web Cryptography API provides browser and compatible runtime primitives. The implementation must preserve the standards' serialization rather than invent a friendlier approximation.

Represent the parsed Signature-Input as a typed structure that retains component order and parameters. Reject duplicate labels, unsupported critical parameters, invalid structured fields, missing components, and algorithms outside local policy. Avoid reconstructing input from a plain object whose property order or casing rules are ambiguous.

The central SVG shows each covered component entering one signature-base spine. Its visual order matters because HTTP Message Signatures sign lines in the declared sequence. Add conformance fixtures from the RFC and retain their expected base bytes. A one-character serialization difference should fail early, before anyone blames keys or network middleware.

Store the ordered component list with each verification fixture; changing that order creates a different signed message even when the values appear identical.

Covered components form one signature baseMethod, target URI, body digest, and request identity enter an ordered byte spine before key policy and replay checks.@method@target-uricontent-digestx-request-idsignature base+ parameters
  • Method: operation verb
  • Target: external URI
  • Digest: actual body bytes
  • Request: replay identity
Figure 1: The signature protects only the explicitly covered HTTP meaning.

Canonicalize derived components precisely

Derived components such as @method, @authority, @path, and @target-uri have defined values. Choose the smallest set that expresses the product boundary, and construct them from the request view the verifier is authorized to trust. A framework's rewritten URL may not match the external target the client signed.

Create a VerificationRequest adapter that carries method, external target URI, headers with preserved field values, and raw body bytes. At a trusted proxy, reconstruct the external URI only from validated forwarding metadata and documented topology. Do not accept arbitrary Forwarded or X-Forwarded-* values from the public internet.

TypeScript types help communicate required inputs, but runtime parsing remains the security boundary. Test uppercase methods, encoded paths, empty query strings, repeated fields, whitespace, non-ASCII values, and multiple proxy hops. HTTP Message Signatures are operationally reliable only when client and server agree on those hostile edge cases, not merely on the example request.

Test an externally visible URL that differs from the internal proxy address, then make the trusted reconstruction boundary explicit in deployment configuration.

Verify body integrity before application parsing

Read the request body as bounded bytes and validate its Content-Digest before JSON parsing changes representation. A signature over the digest field proves that the signer covered the claimed digest; verifying the digest against actual bytes proves the body matches. Both checks are required.

Enforce body-size limits while streaming, compare supported digests in constant-time helpers where appropriate, and reject malformed or duplicate digest values. Decide whether content coding is applied before or after the layer that computes the digest, then keep that contract consistent through proxies. The table separates body bytes, digest field, signature base, and product schema because each failure has a different owner.

After integrity passes, parse JSON and validate its semantic schema. A perfectly signed request can still ask for an unauthorized amount or contain an invalid account. The Content-Digest header is not a product validator. HTTP Message Signatures authenticate integrity and signer possession; product authorization still checks principal, resource, action, limits, and current state.

LayerInputCheckDenial
BytesBodyDigestMismatch
BaseComponentsSerializeMalformed
KeykeyidPolicyUntrusted
Freshtime/nonceReplaySeen
Figure 2: Parsing, integrity, identity, and freshness remain separate verifier stages.

Resolve keys through an explicit trust policy

A keyid tells the verifier where to look in local policy; it does not make the referenced key trustworthy. Resolve it within an allowlisted issuer, tenant, or workload namespace. Validate key type, usage, status, algorithm compatibility, rotation window, and environment. Cache carefully and honor revocation bounds.

Prefer algorithm selection from server policy and key metadata rather than untrusted request text. Reject downgrade attempts and ambiguous keys. For asymmetric signatures, ensure the public key is bound to the authorized workload identity. For shared secrets, isolate tenants and operations because one verifier secret can otherwise impersonate every caller.

The small artifact uses HMAC only to make the signature-base property runnable without dependencies. A production HTTP Message Signatures implementation should use a reviewed structured-field parser and cryptographic suite, retain interoperability vectors, and expose stable denial codes. The tutorial's core claim is component binding, not a recommendation to replace standards libraries with a dozen lines of code.

The runnable fixture creates a deterministic HMAC over method, target URI, content digest, and request ID, then proves that changing either the target or identity invalidates the signature base.

Runnable artifact — http-signature-base.test.mjs

import assert from "node:assert/strict";import {createHmac,timingSafeEqual} from "node:crypto";
const base=r=>['"@method": '+r.method.toLowerCase(),'"@target-uri": '+r.url,'"content-digest": '+r.digest,'"x-request-id": '+r.id].join("\n");const mac=x=>createHmac("sha256","fixture-key").update(base(x)).digest();
const request={method:"POST",url:"https://api.test/pay",digest:"sha-256=:abc:",id:"r-7"},signature=mac(request);assert.equal(timingSafeEqual(signature,mac(request)),true);assert.equal(timingSafeEqual(signature,mac({...request,url:"https://api.test/read"})),false);assert.equal(timingSafeEqual(signature,mac({...request,id:"r-8"})),false);
console.log("PASS: HTTP signature binds covered components");

Run node http-signature-base.test.mjs. Expected receipt: PASS: HTTP signature binds covered components.

Bound time, nonce, and replay identity

Validate creation and expiration against a monotonic policy window translated from wall-clock claims. Set a maximum accepted age and future skew; reject absent time parameters when policy requires them. A long-lived valid signature can become a bearer artifact if an attacker captures the complete request.

Add a nonce or unique request identifier where replay has consequences, then retain it for at least the signature validity window. Namespace the replay key by signer and operation. Store a digest of the signature or nonce when raw material is sensitive, and make cache failure explicit: a verifier should not silently disable replay checks when its store is unavailable.

Request replay defense and idempotency are related but different. The first rejects reuse of proof; the second can return the original effect for a repeated intent. Decide whether a legitimate retry signs a fresh message with the same idempotency key or reuses a request under a narrowly defined contract. HTTP Message Signatures need that policy written before network retries begin.

Preserve semantics across gateways and agents

An intermediary may verify the external signature and create a new internal message. If so, it should forward a typed verification receipt naming signer, covered components, external target, digest, policy, and decision, protected by the internal trust boundary. It must not claim that the original end-to-end signature still covers rewritten fields.

The surrounding authorization stack stays modular. OAuth DPoP sender-constrains access tokens, MCP OAuth audience validation binds a token to a resource, idempotency lifecycle contracts deduplicate effects, and AI agent workload identity supplies short-lived service identity.

For agent tool calls, sign only after the tool adapter has finalized method, target, body, and request identity. Keep the private key outside model-generated code. The model may propose parameters; deterministic code validates authorization, serializes the request, creates the digest, and signs. This prevents prompt content from redefining what the cryptographic layer covers.

Log rejection categories without logging secrets or raw authorization values, giving operators a useful replay and clock-skew signal without expanding exposure.

Release with an interoperability and mutation corpus

Collect valid fixtures from at least two independent implementations and compare exact signature bases. Then mutate one property at a time: method, target URI, query encoding, body byte, digest, content type, request ID, creation time, key ID, signature byte, proxy header, and component order. Every denial should identify the failed stage without leaking secret material.

The release sequence verifies parsing, canonicalization, body digest, key policy, cryptography, time window, replay state, and product authorization in that order. HTTP Message Signatures fail closed when any required evidence is absent. Log a stable request digest and decision receipt, not full sensitive bodies or signing secrets.

Revisit the verifier whenever frameworks, proxies, algorithms, or protected operations change. Retain the corpus in CI and run a production-shaped gateway test before enabling a new covered component. The long-term cost is not signature computation; it is maintaining one exact understanding of the HTTP message across every layer that can rewrite it.

  1. 1Adapt

    Capture the trusted HTTP view

  2. 2Digest

    Bind raw content bytes

  3. 3Verify

    Resolve key and signature

  4. 4Authorize

    Apply product policy

Figure 3: Cryptographic success precedes, but never replaces, authorization.

Verify the message your system actually received

A useful signature implementation is mostly a discipline of exact meaning: which request view is trusted, which components define the operation, which identity may sign, and how long the proof may live. Keep those bytes and policies explicit, and the cryptography becomes the smallest part of a comprehensible boundary. Preserve valid interop fixtures alongside denials so refactoring can prove compatibility as well as strictness. Record the external request view at the trusted gateway in every fixture, because an unexplained proxy rewrite can otherwise look indistinguishable from a broken signer during incident review across several network intermediaries and services today.