---
id: 7
title: "Sign the message, not just the session (HTTP Message Signatures)"
status: current
date: 2026-06-12
authors:
  - "Theo Zourzouvillys"
tags: [security, auth, http]
summary: "A bearer token proves nothing about the request it rides on. Sign the message itself (HTTP Message Signatures, RFC 9421) — request, and ideally response — so the recipient can prove who sent this exact message and not a byte changed. Shared keys first; asymmetric better."
supersedes: null
superseded_by: null
aliases: []
references:
  - id: rfc9421
    title: "RFC 9421 — HTTP Message Signatures"
    url: https://www.rfc-editor.org/rfc/rfc9421
    abstract: "Specifies how to sign and verify chosen components of an HTTP message — method, target URI, selected headers, a content digest — so a recipient can prove who sent a specific request or response and that it wasn't altered in transit. Supports both shared-key (HMAC) and asymmetric signatures."
---

## TL;DR

A bearer credential authenticates *the connection or the caller*, but says nothing about **the
request it's attached to** — `GET /me` and `POST /transfer?amount=all` carry the identical token.
**Sign the message itself** with [HTTP Message Signatures (RFC 9421)](ref:rfc9421):
the sender signs a chosen set of components (method, path, key headers, a digest of the body) so the
recipient can prove *who sent this exact message* and that **not a byte changed in flight**. Sign
**requests**, and **ideally responses too** — a signed response lets the client prove the answer
really came from the server and wasn't tampered with on the way back.

Don't make PKI a prerequisite. A **shared symmetric key (HMAC)** is a perfectly good first step and
far better than nothing; **asymmetric** keys are the better end-state (the verifier can't also
forge). Start where you can operate well and climb.

I built tooling for this — a library across several languages and a browser playground — see
[Sign the Request, Not the Session](/blog/2026-06-httpsig/).

## Context

Almost everything authenticates with a **bearer token** — a cookie, a JWT, an API key. The token
grants access to whoever holds it, and crucially it is *unbound from the request*: it travels
unchanged regardless of what you're actually asking the server to do. Two different problems follow
from that:

- **Replay.** If the token leaks (a log, a proxy, a backup), it's reusable from anywhere. Sender
  constraint via [DPoP](/zfn/6-sender-constrained-tokens-dpop/) addresses this by binding the
  *token* to a holder key.
- **Integrity and provenance of the message.** Even with a valid credential, the recipient can't
  prove that *this specific request* — this method, path, headers, and body — is what the
  legitimate sender intended, or that an intermediary didn't alter it. A bearer token says nothing
  about the bytes.

Signing the message solves the second problem directly, and (because the signature covers a
nonce/timestamp and the request line) substantially mitigates the first as well. It's the same idea
I keep coming back to: bind the proof to *what is actually being said*, not to a long-lived secret
that says nothing about the conversation.

## Recommendation

**Sign the message with HTTP Message Signatures (RFC 9421) wherever message provenance and integrity
matter** — service-to-service calls, webhooks you send (and receive), anything passing through
intermediaries you don't fully trust. Concretely:

- **Cover the parts that matter.** Sign the method, the target URI, the security-relevant headers,
  and a **content digest** of the body (RFC 9421 pairs with the `Content-Digest` header). The
  signature base is explicit about what's covered, so both sides agree on exactly what's protected.
- **Sign requests, and ideally responses.** Request signing lets the server trust the caller and the
  call. **Response signing** lets the *client* prove the response genuinely came from the server and
  arrived intact — valuable for webhooks, financial responses, and anything a client will act on or
  store as authoritative. Treat response signing as the goal, not an afterthought.
- **Include anti-replay material.** A timestamp (`created`) and/or nonce in the signature parameters,
  with a bounded acceptance window, so a captured signed message can't be replayed indefinitely.
- **Choose the key model by what you can operate — don't force PKI.**
  - **Shared symmetric key (HMAC) — the fine first step.** Sender and verifier share a secret. Simple,
    no certificate machinery, and a big improvement over an unbound bearer token. Its limit: anyone who
    can *verify* can also *sign* (they hold the same secret), so a verifier compromise is a forging
    compromise. Scope and rotate shared keys accordingly.
  - **Asymmetric — the better step.** The signer holds a private key; verifiers hold only the public
    key. A verifier (or recipient) compromise can't forge new signed messages. This is where you want
    to end up for anything crossing a trust boundary.
  - Distribute public keys by whatever you already trust (a provisioned key, a key rooted in a KMS, or
    a platform identity service — see [ZFN-5](/zfn/5-platform-workload-identity-service/)); a JWKS
    endpoint is one option, not a requirement.
- **Use a real implementation; don't roll your own canonicalization.** The signature base
  construction is where homegrown attempts go wrong. Use a library. (I wrote one — see below.)

## Consequences

**Easier:**

- The recipient can prove provenance *and* integrity of a specific message, not just possession of a
  credential. Tampering in flight is detectable.
- Response signing gives clients the same guarantee in reverse — useful anywhere the answer is acted
  on or stored as authoritative.
- A shared-key start means you can adopt it without standing up PKI; the same wire format upgrades to
  asymmetric later.
- Pairs naturally with short-lived platform identity tokens
  ([ZFN-5](/zfn/5-platform-workload-identity-service/)) and with DPoP
  ([ZFN-6](/zfn/6-sender-constrained-tokens-dpop/)).

**Harder:**

- Both sides need the signing/verification logic and agreed covered components; a mismatch in the
  signature base is a frustrating class of bug. A good library removes most of this.
- Signing adds per-message work, and signed components must survive intermediaries (proxies that
  rewrite headers can break a signature unless covered components are chosen with that in mind).
- Shared-key signing only raises the bar so far: a holder of the shared secret can forge. If that
  matters, you're at the asymmetric rung, not the first one.
- Key management is now yours to run (rotation, distribution) whichever rung you pick.

## References

- [RFC 9421 — HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421); [RFC 9530 — Digest Fields (`Content-Digest`)](https://www.rfc-editor.org/rfc/rfc9530).
- [Sign the Request, Not the Session](/blog/2026-06-httpsig/) — my write-up, a multi-language library, and a browser playground for signing/verifying.
- [ZFN-6](/zfn/6-sender-constrained-tokens-dpop/) — sender-constrained tokens (DPoP): bind the *token* to a key rather than signing the *message*. Complementary.
- [ZFN-5](/zfn/5-platform-workload-identity-service/) — where the signing identities/keys can come from.

## Changelog

- **2026-06-12**: First published as a Field Note.
