Skip to main content Skip to sidebar

Understanding HTTP Message Signatures

TLS protects HTTP messages in transit, but only between two directly connected endpoints. In modern architectures with load balancers, reverse proxies, and CDNs, the TLS connection terminates at the intermediary. After that point, there is no cryptographic proof that the message was sent by the claimed sender or that its contents have not been modified. RFC 9421 (HTTP Message Signatures) solves this by providing a standard mechanism for signing and verifying HTTP messages at the application layer.

The Problem

Consider a request that passes through multiple intermediaries before reaching the application:

flowchart LR
    Client -->|TLS| LB[Load Balancer]
    LB -->|TLS| CDN
    CDN -->|TLS| Proxy[Reverse Proxy]
    Proxy -->|TLS| App[Application]

Each TLS connection is independent. The application knows the message came from the reverse proxy, but it cannot verify that the original client sent it. An intermediary could modify headers, change the request method, or alter the body without the application detecting it.

Existing solutions do not fully address this:

  • API keys authenticate the client but do not protect message integrity. A compromised intermediary can modify the request while keeping the same API key.
  • JWTs sign their own payload but not the HTTP request. An attacker could change the method from GET to DELETE while keeping the JWT valid.
  • mTLS authenticates the connection, not the message. It provides no protection once TLS terminates.

Before RFC 9421, every service invented its own signing scheme (AWS Signature V4, GitHub webhook HMAC signatures, etc.). RFC 9421 standardizes this into a single, interoperable mechanism.

How It Works

RFC 9421 uses two HTTP headers to carry signature data:

  • Signature-Input: declares which message components are signed and includes metadata (algorithm, creation time, key identifier)
  • Signature: contains the actual cryptographic signature value

Both headers use labels (e.g., sig1) to correlate them, allowing multiple independent signatures on the same message.

Signing Process

sequenceDiagram
    participant Signer
    participant Message as HTTP Message

    Signer->>Signer: 1. Select components to sign
    Signer->>Message: 2. Extract component values
    Signer->>Signer: 3. Build signature base (canonical form)
    Signer->>Signer: 4. Sign with private key / shared secret
    Signer->>Message: 5. Add Signature-Input and Signature headers

Step 1 – Select components. The signer chooses which parts of the HTTP message to protect. These are called “covered components” and can include headers, the method, path, authority, query string, and more.

Step 2 – Extract values. The signer reads the values of each selected component from the message.

Step 3 – Build the signature base. The signer constructs a canonical byte sequence from the selected components. Each component is formatted as "<name>": <value>, separated by newlines. The last line is always @signature-params, which includes the ordered list of covered components and all metadata.

Step 4 – Sign. The signature base is signed using the chosen algorithm and key.

Step 5 – Attach headers. The Signature-Input and Signature headers are added to the message.

Verification Process

sequenceDiagram
    participant Verifier
    participant Message as HTTP Message

    Verifier->>Message: 1. Parse Signature-Input and Signature
    Verifier->>Verifier: 2. Reconstruct signature base from message
    Verifier->>Verifier: 3. Resolve key using keyid
    Verifier->>Verifier: 4. Verify signature cryptographically
    Verifier->>Verifier: 5. Check created/expires timestamps

The verifier independently reconstructs the same signature base from the received message, then verifies the signature using the appropriate public key or shared secret. If any covered component was modified in transit, the reconstructed signature base will differ and verification will fail.

Signature Base

The signature base is the canonical byte sequence that both signer and verifier produce independently. It is never transmitted – both parties construct it from the HTTP message and the Signature-Input metadata.

Example signature base for a POST request:

"@method": POST
"@authority": api.example.com
"@path": /api/resource
"content-type": application/json
"content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
"@signature-params": ("@method" "@authority" "@path" "content-type" "content-digest");alg="ed25519";created=1618884476;keyid="app-key-1"

Rules:

  • Each line is formatted as "<name>": <value> (the name is always double-quoted)
  • Lines are separated by newline characters
  • The @signature-params line is always last
  • Component order must match the order declared in Signature-Input

Covered Components

Covered components fall into two categories: HTTP field components and derived components.

HTTP Field Components

Any HTTP header field can be a covered component, referenced by its lowercased name (e.g., content-type, authorization, date). Canonicalization rules handle header value normalization: multiple values for the same field are combined with a comma and a space, field names are lowercased, and leading/trailing whitespace is trimmed.

Field parameters provide additional control:

ParameterPurpose
;sfStrict Structured Fields serialization per RFC 8941
;key="member"Select a specific member from a Dictionary-type field
;bsWrap the field value as a byte sequence
;trRetrieve value from trailer fields instead of headers
;reqPull value from the originating request (for response signing)

Derived Components

Derived components are prefixed with @ and represent parts of the HTTP message that are not regular headers:

ComponentApplies ToDescription
@methodRequestHTTP method (GET, POST, PUT, DELETE)
@target-uriRequestFull request URI including scheme, host, path, query
@authorityRequestHost and optional port, normalized to lowercase
@schemeRequestURI scheme (http or https)
@pathRequestAbsolute path component
@queryRequestQuery string including leading ?
@query-paramRequestIndividual query parameter by name
@statusResponseThree-digit HTTP status code

Signature Parameters

The @signature-params line contains metadata about the signature:

ParameterDescription
algCryptographic algorithm (e.g., ed25519, rsa-pss-sha512)
createdUNIX timestamp when the signature was created
expiresUNIX timestamp after which the signature should be rejected
keyidIdentifier for the key, allowing the verifier to look up the correct key
nonceRandom value to prevent replay attacks
tagApplication-specific label for distinguishing signature purposes

The signature parameters are themselves signed (they form the last line of the signature base), which prevents an attacker from tampering with the metadata.

Body Protection

RFC 9421 does not directly sign the HTTP message body. Instead, it relies on RFC 9530 (Digest Fields), which defines the Content-Digest header:

  1. Compute a cryptographic hash of the body (e.g., SHA-256 or SHA-512)
  2. Set it as the Content-Digest header
  3. Include content-digest as a covered component in the signature
flowchart LR
    Body[Request Body] -->|SHA-256| Digest[Content-Digest Header]
    Digest --> SigBase[Signature Base]
    Method["@method"] --> SigBase
    Path["@path"] --> SigBase
    SigBase -->|Sign| Signature

If anyone modifies the body, the digest will not match, and signature verification will fail.

Supported Algorithms

RFC 9421 supports both asymmetric and symmetric algorithms:

Asymmetric

IdentifierAlgorithmNotes
ed25519EdDSA using edwards25519Fast, modern, recommended
ecdsa-p256-sha256ECDSA using P-256 with SHA-256Good balance of speed and security
ecdsa-p384-sha384ECDSA using P-384 with SHA-384Stronger, slower than P-256
rsa-pss-sha512RSASSA-PSS using SHA-512Recommended for RSA deployments
rsa-v1_5-sha256RSASSA-PKCS1-v1_5 using SHA-256Legacy compatibility only

Symmetric

IdentifierAlgorithmNotes
hmac-sha256HMAC using SHA-256For shared-secret scenarios

Examples

Signed GET Request

GET /api/v1/users HTTP/1.1
Host: api.example.com
Date: Wed, 12 Feb 2026 10:30:00 GMT
Signature-Input: sig1=("@method" "@authority" "@path" "date");\
  alg="ed25519";created=1739353800;keyid="client-key-ed25519"
Signature: sig1=:dGhpcyBpcyBhIHNhbXBsZSBzaWduYXR1cmUgdmFsdWU=:

The signature base:

"@method": GET
"@authority": api.example.com
"@path": /api/v1/users
"date": Wed, 12 Feb 2026 10:30:00 GMT
"@signature-params": ("@method" "@authority" "@path" "date");alg="ed25519";created=1739353800;keyid="client-key-ed25519"

Signed POST Request with Body Protection

POST /api/v1/transactions HTTP/1.1
Host: bank.example.com
Content-Type: application/json
Content-Length: 52
Content-Digest: sha-256=:RK/0qy18MlBSVnWgjwz6lZEWjP/lF5HF9bvEF8FabDg=:
Signature-Input: sig1=("@method" "@authority" "@path" \
  "content-type" "content-digest");\
  alg="ecdsa-p256-sha256";created=1739353800;\
  expires=1739354100;keyid="client-ecdsa-p256";\
  nonce="b3k2pp5k7z-50gnwp0ox2"
Signature: sig1=:MEYCIQCFsP8q2W8Uf24cOe0k8pISQv0w0bqo...:

{"amount": 100.00, "currency": "USD", "to": "acct-789"}

This request includes:

  • content-digest to protect body integrity
  • expires to limit signature validity to 5 minutes
  • nonce to prevent replay attacks

Signed Response Bound to Request

HTTP/1.1 200 OK
Content-Type: application/json
Content-Digest: sha-256=:mEkdbO7Srd9LIOegftO0aBX+VPTqz6Bk5pgbherEbvo=:
Signature-Input: resp=("@status" "content-type" "content-digest" \
  "@authority";req "@method";req);\
  alg="ed25519";created=1739353801;keyid="server-key-ed25519"
Signature: resp=:dGhpcyBpcyBhIHNhbXBsZSByZXNwb25zZSBzaWduYXR1cmU=:

{"status": "success", "transaction_id": "txn-12345"}

The ;req flag on @authority and @method pulls values from the originating request. This binds the response to the specific request that triggered it, preventing an attacker from replaying a valid response for a different request.

Multiple Signatures

A message can carry multiple independent signatures with different labels, algorithms, and keys:

POST /api/v1/orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
Content-Digest: sha-256=:abc123=:
Signature-Input: proxy=("@method" "@authority");\
  alg="hmac-sha256";created=1739353800;keyid="proxy-secret",\
  origin=("@method" "@authority" "@path" "content-type" \
  "content-digest");alg="ed25519";created=1739353799;\
  keyid="origin-client-key"
Signature: proxy=:cHJveHkgc2lnbmF0dXJl:,\
  origin=:b3JpZ2luIHNpZ25hdHVyZQ==:

Here, the original client signs the full request with Ed25519, and an intermediary proxy adds its own HMAC signature covering a subset of components. The application can verify both signatures independently.

Implementation in Go

The following examples use only the Go standard library (crypto/ed25519, crypto/sha256, net/http, encoding/base64).

Building the Signature Base

The signature base is constructed from the HTTP request and an ordered list of covered components:

func resolveComponent(req *http.Request, component string) string {
    switch component {
    case "@method":
        return req.Method
    case "@authority":
        return strings.ToLower(req.Host)
    case "@path":
        if req.URL.Path == "" {
            return "/"
        }
        return req.URL.Path
    case "@query":
        return "?" + req.URL.RawQuery
    case "@target-uri":
        return req.URL.String()
    case "@scheme":
        return strings.ToLower(req.URL.Scheme)
    default:
        return strings.TrimSpace(req.Header.Get(component))
    }
}

func buildSignatureBase(req *http.Request, components []string, sigParams string) string {
    lines := make([]string, 0, len(components)+1)

    for _, component := range components {
        lines = append(lines, "\""+component+"\": "+resolveComponent(req, component))
    }

    lines = append(lines, "\"@signature-params\": "+sigParams)

    return strings.Join(lines, "\n")
}

Computing Content-Digest

Body protection requires computing a SHA-256 digest and setting it as a header before signing:

func setContentDigest(req *http.Request, body []byte) {
    digest := sha256.Sum256(body)
    encoded := base64.StdEncoding.EncodeToString(digest[:])
    req.Header.Set("Content-Digest", "sha-256=:"+encoded+":")
}

Formatting Signature Parameters

The @signature-params value includes the ordered component list and metadata:

func formatSignatureParams(components []string, alg, keyID string, created int64) string {
    quoted := make([]string, len(components))
    for i, c := range components {
        quoted[i] = fmt.Sprintf("%q", c)
    }

    return fmt.Sprintf("(%s);alg=%q;created=%d;keyid=%q",
        strings.Join(quoted, " "), alg, created, keyID,
    )
}

Signing a Request with Ed25519

Combining the above functions to sign an outgoing HTTP request:

func signRequest(req *http.Request, privateKey ed25519.PrivateKey, keyID string) {
    components := []string{"@method", "@authority", "@path"}

    if req.Header.Get("Content-Digest") != "" {
        components = append(components, "content-type", "content-digest")
    }

    created := time.Now().Unix()
    sigParams := formatSignatureParams(components, "ed25519", keyID, created)
    base := buildSignatureBase(req, components, sigParams)

    signature := ed25519.Sign(privateKey, []byte(base))
    encoded := base64.StdEncoding.EncodeToString(signature)

    req.Header.Set("Signature-Input", "sig1="+sigParams)
    req.Header.Set("Signature", "sig1=:"+encoded+":")
}

Verifying a Request with Ed25519

The server reconstructs the signature base from the received request and verifies it against the public key:

func verifyRequest(req *http.Request, publicKey ed25519.PublicKey) error {
    sigInput := req.Header.Get("Signature-Input")
    if sigInput == "" {
        return errors.New("missing Signature-Input header")
    }

    sigHeader := req.Header.Get("Signature")
    if sigHeader == "" {
        return errors.New("missing Signature header")
    }

    // Extract signature params after "sig1="
    sigParams, ok := strings.CutPrefix(sigInput, "sig1=")
    if !ok {
        return errors.New("unsupported signature label")
    }

    // Parse covered components from params: ("@method" "@authority" "@path" ...)
    openParen := strings.Index(sigParams, "(")
    closeParen := strings.Index(sigParams, ")")
    if openParen < 0 || closeParen < 0 {
        return errors.New("malformed signature params")
    }

    componentStr := sigParams[openParen+1 : closeParen]
    var components []string
    for _, part := range strings.Fields(componentStr) {
        components = append(components, strings.Trim(part, "\""))
    }

    // Reconstruct signature base
    base := buildSignatureBase(req, components, sigParams)

    // Extract and decode signature value: sig1=:<base64>:
    sigValue, ok := strings.CutPrefix(sigHeader, "sig1=:")
    if !ok {
        return errors.New("unsupported signature label in Signature header")
    }

    sigValue, ok = strings.CutSuffix(sigValue, ":")
    if !ok {
        return errors.New("malformed signature value")
    }

    sigBytes, err := base64.StdEncoding.DecodeString(sigValue)
    if err != nil {
        return fmt.Errorf("decoding signature: %w", err)
    }

    if !ed25519.Verify(publicKey, []byte(base), sigBytes) {
        return errors.New("signature verification failed")
    }

    return nil
}

Complete Client Example

func main() {
    publicKey, privateKey, err := ed25519.GenerateKey(nil)
    if err != nil {
        log.Fatal(err)
    }

    body := []byte(`{"amount": 100.00, "currency": "USD"}`)

    req, err := http.NewRequest(http.MethodPost,
        "https://api.example.com/api/v1/transactions", bytes.NewReader(body))
    if err != nil {
        log.Fatal(err)
    }

    req.Header.Set("Content-Type", "application/json")
    setContentDigest(req, body)
    signRequest(req, privateKey, "my-key-1")

    // At this point, the request has Signature-Input and Signature headers.
    // The server would call verifyRequest(req, publicKey) to validate.

    if err := verifyRequest(req, publicKey); err != nil {
        log.Fatal(err)
    }

    log.Println("signature verified")
}

Verifying Content-Digest

After verifying the signature, the server should also verify that the body matches the Content-Digest header:

func verifyContentDigest(req *http.Request) error {
    digestHeader := req.Header.Get("Content-Digest")
    if digestHeader == "" {
        return errors.New("missing Content-Digest header")
    }

    expected, ok := strings.CutPrefix(digestHeader, "sha-256=:")
    if !ok {
        return errors.New("unsupported digest algorithm")
    }

    expected, ok = strings.CutSuffix(expected, ":")
    if !ok {
        return errors.New("malformed Content-Digest value")
    }

    body, err := io.ReadAll(req.Body)
    if err != nil {
        return fmt.Errorf("reading body: %w", err)
    }

    req.Body = io.NopCloser(bytes.NewReader(body))

    digest := sha256.Sum256(body)
    actual := base64.StdEncoding.EncodeToString(digest[:])

    if actual != expected {
        return errors.New("Content-Digest mismatch")
    }

    return nil
}

Server Middleware Example

A middleware that verifies both the signature and body digest on incoming requests:

func signatureVerification(publicKey ed25519.PublicKey, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if err := verifyRequest(r, publicKey); err != nil {
            http.Error(w, "invalid signature", http.StatusUnauthorized)
            return
        }

        if r.Header.Get("Content-Digest") != "" {
            if err := verifyContentDigest(r); err != nil {
                http.Error(w, "body integrity check failed", http.StatusBadRequest)
                return
            }
        }

        next.ServeHTTP(w, r)
    })
}

Differences from draft-cavage-http-signatures

Before RFC 9421, the most widely used HTTP signing mechanism was draft-cavage-http-signatures, an expired individual draft that was never adopted as a standard. It is still used in the ActivityPub/fediverse ecosystem (Mastodon and others). RFC 9421 is a complete redesign, not a revision.

Aspectdraft-cavageRFC 9421
StatusExpired draftIETF Proposed Standard
HeadersSingle Signature headerSeparate Signature-Input + Signature
FormatCustom key-value pairsRFC 8941 Structured Fields
Metadata signedNoYes (@signature-params is part of the signature base)
Multiple signaturesNot supportedFirst-class support via labels
Component granularityHeaders + (request-target)Headers + rich derived components (@method, @path, @query-param, @status, etc.)
Response signingNot well definedFull support with ;req binding
Body coverageNot standardizedVia Content-Digest (RFC 9530)

The most critical difference is that RFC 9421 signs the metadata. In draft-cavage, the algorithm and key identifier are not part of the signed data, meaning an attacker could potentially alter them. In RFC 9421, any change to the signature parameters invalidates the signature.

Real-World Adoption

  • Cloudflare uses RFC 9421 for their Verified Bots program, where legitimate crawlers sign requests so origin servers can distinguish them from spoofed bots
  • OpenAI uses HTTP Message Signatures for ChatGPT agent verification
  • GNAP (RFC 9635) uses HTTP Message Signatures as a proof mechanism for client authentication
  • ActivityPub/Fediverse is migrating from draft-cavage to RFC 9421 for server-to-server authentication
  • Financial APIs use message signatures for PSD2 compliance, where requests between payment providers must be cryptographically authenticated

Security Considerations

Replay Attacks

A captured signed request can be replayed. Mitigations:

  • Use created and expires with short lifetimes (e.g., 300 seconds)
  • Include nonce values and track them server-side
  • Cover @target-uri so a signature for one endpoint cannot be replayed against another

Insufficient Coverage

Signing only a few headers allows modification of unsigned parts. Applications should define a minimum set of required covered components. For most APIs, this includes:

  • @method – prevent method substitution
  • @authority – prevent host substitution
  • @path – prevent path substitution
  • content-digest (when body is present) – prevent body tampering
  • Authentication headers (e.g., authorization)

Algorithm Downgrade

An attacker might try to force a weaker algorithm. RFC 9421 mitigates this by signing the algorithm identifier as part of @signature-params. Verifiers should also maintain an allowlist of acceptable algorithms per key.

Key Management

  • Use keyid to support key rotation without disruption
  • Never share private keys across services
  • Ensure compromised keys can be revoked promptly

Summary

  • RFC 9421 provides application-layer message integrity and authenticity for HTTP, complementing TLS transport-level security
  • It uses two headers: Signature-Input (what is signed) and Signature (the proof)
  • Both request and response signing are supported, with request-response binding
  • Body protection is achieved through Content-Digest (RFC 9530) as a signed component
  • Multiple independent signatures can coexist on a single message
  • Ed25519 and ECDSA P-256 are the recommended algorithms for new deployments
  • The specification replaces the older draft-cavage approach with stronger guarantees, including signed metadata and standardized component canonicalization