# HTTP encrypted is not signed

> When a delegated purchase goes wrong, both sides have logs, and the logs disagree. Why TLS can't settle that, and what RFC 9421 message signatures can (and can't) do about it.

Source: https://www.bluewise.fr/http-encrypted-is-not-signed  
Author: Marc Morel  
Published: 2026-05-11  
Category: Engineering

Ask a team how their B2B API is secured and the answer is almost always "HTTPS, plus OAuth". Fair enough. TLS is excellent at what it does. But what it does is protect a connection, and in most architectures that connection ends at a load balancer, a long way from the service that actually records the order. Once the connection is closed, nothing is left that a third party could check.

Most of the time nobody cares. You start caring the day a customer's system places an order through your API and the customer later says it never asked for that. I'll walk through one of those days, then look at what message signatures add, what they cost, and when I think they're worth it.

## The dispute

Say you sell office furniture to businesses. A few years ago you opened an ordering API so that larger customers could connect their purchasing tool directly to your catalogue, instead of emailing PDFs. One of them, a mid-size company opening a new site in Lyon, uses it regularly.

On a Tuesday morning in September, this arrives from their purchasing tool:

```json title="Order received on Tuesday" {4}
{
  "customer_po": "PO-2026-04417",
  "sku": "CHR-ERGO-240-BLK",
  "quantity": 120,
  "unit_price_eur": "395.00",
  "delivery_site": "Lyon, 3rd floor"
}
```

120 ergonomic chairs at the contract price of 395 euros, so 47,400 euros. Not unusual for a new floor. The chairs are configured to order (fabric, armrests, their logo stitched on the back), so your system confirms the order and production starts at the manufacturer the same afternoon. Under your terms, a configured order can't be cancelled once production has started.

Three weeks later the truck shows up in Lyon. The facility manager refuses the delivery and calls their purchasing department, who calls you. According to them they ordered 12 chairs, not 120. Either your system mangled the order or someone on your side changed it. They will pay for 12 and you can keep the rest.

You pull what you have. The API gateway logged `POST /v1/orders 200`. The orders table has a row with `quantity: 120`, their PO number and the client ID of their integration. You send that over.

Their reply is polite and, to be honest, fair. A row in your database is your system talking about itself. The OAuth token proves that someone had the token, not what they asked for. And their own purchasing tool shows a requisition for 12 chairs, approved by their manager, with a screenshot to prove it.

So you have two records, each kept by a party with an interest in the outcome, saying different things. 108 chairs with someone else's logo on them are not going back into stock. If this ends up in front of a commercial court, a judge will have to decide whose IT looks more trustworthy, which is not a place I'd like to be.

## What TLS gives you, and where it stops

I'll skip plain HTTP. Anybody on the path can read the request, turn a 12 into a 120 and replay it, and I hope nobody sends orders that way in 2026.

TLS 1.3 fixes most of that while the connection is open. Session keys come from an ephemeral exchange and never travel on the wire, so you get confidentiality and forward secrecy. The server proves it holds the certificate for its hostname (the client only proves anything if you run mutual TLS). Records are encrypted with AEAD, so a modified byte fails decryption and the connection is dropped.

What it doesn't give you is exactly what this dispute needs.

First, it protects a hop, not the message. In real deployments TLS terminates at the CDN, the load balancer, the WAF, the API gateway or a sidecar. Each of those decrypts the request, may rewrite headers or even the body, and opens a new connection downstream. Whether you planned it or not, every one of them is inside your trust boundary, and the customer has no reason to trust any of them.

Second, it leaves nothing behind that an outsider can verify. Record integrity relies on symmetric keys that both ends hold, so either end could have produced any record. Even with a full packet capture and the session keys, you can't show a judge who wrote what.

Third, a bearer token is tied to a string, not to a sender. Whoever presents it is "the customer". A leaked token, a misconfigured test environment pointing at production or a colleague with access to the secret store will all look exactly the same.

*[Figure: What each layer resolves]*

## Signing the request itself

RFC 9421, HTTP Message Signatures, takes the guarantee off the connection and puts it on the request. The client signs a chosen set of components with its private key, and anyone with the public key can check the result, next week or in five years, however many proxies sat in between.

The body isn't signed as such. Its hash goes into a `Content-Digest` header RFC 9530 and that header is one of the signed components. If the customer's purchasing tool had been signing, the Tuesday order would have looked roughly like this:

```http title="Signed request" {4-6} "content-digest"
POST /v1/orders HTTP/1.1
Host: api.furniture-supplier.example
Content-Type: application/json
Content-Digest: sha-256=:<base64 of SHA-256 over the exact body bytes>:
Signature-Input: sig1=("@method" "@authority" "@path" "content-type" "content-digest");created=1789458120;expires=1789458420;nonce="d9a1f7e3b2c4";keyid="customer-purchasing-2026-01";alg="ed25519";tag="order"
Signature: sig1=:<base64 of the 64-byte Ed25519 signature>:

{"customer_po":"PO-2026-04417","sku":"CHR-ERGO-240-BLK","quantity":120,"unit_price_eur":"395.00","delivery_site":"Lyon, 3rd floor"}
```

What actually gets signed is not the raw HTTP text but a "signature base" that both sides rebuild the same way, one line per component:

```text title="Signature base"
"@method": POST
"@authority": api.furniture-supplier.example
"@path": /v1/orders
"content-type": application/json
"content-digest": sha-256=:<same value as the header>:
"@signature-params": ("@method" "@authority" "@path" "content-type" "content-digest");created=1789458120;expires=1789458420;nonce="d9a1f7e3b2c4";keyid="customer-purchasing-2026-01";alg="ed25519";tag="order"
```

A few things in there deserve a comment. Forget `content-digest` in the covered components and the body isn't protected at all. It's an easy mistake to make and a hard one to notice, because everything still verifies. `@authority` and `@path` pin the destination. I'd only cover what no intermediary legitimately rewrites, otherwise you'll spend a week chasing signatures that break somewhere between the CDN and the gateway.

`created` and `expires` give a five minute window, and with a nonce cache on the server that's what stops replays. `keyid` points to the key the customer registered with you during onboarding, and to which version of it (more on that below). `tag` scopes the signature to one use, so a signature made for another API can't be presented to this one. I picked Ed25519 because keys are small, signatures are deterministic, and HSMs and cloud KMS services support it widely.

## HTTPS with message signatures what is guarenteed

*[Figure: HTTPS Message Signatures]*

## The unglamorous part: what you keep

Verifying a signature and then throwing it away buys you nothing three weeks later. Most of the real work is on the receiving side, and it's mostly about storage discipline.

The service that records the order has to be the one that refuses an unsigned or invalid request. Checking at the gateway is fine as a first filter, but if the gateway is the only check, the gateway becomes one more component that could have changed the order after verification, and you're back where you started.

Keep the bytes exactly as they arrived, together with the signature headers, before any JSON parsing. This is the mistake I'd expect most teams to make: they store the parsed object, re-serialise it later, and the digest no longer matches because key order, spacing or number formatting changed on the way.

Keep the key history too. Keys rotate, so the evidence has to name the key that was valid at `created`. Register customer keys with validity dates, and never delete a retired public key while something signed with it is still under retention.

Finally, seal it. The customer can't forge its own signature, but it can still argue about when you received the order, which matters if your terms say production starts on receipt. Writing the package to a tamper-evident trail with qualified timestamps closes that, and I went through how in [Audit Trails: Logs are not enough](https://www.bluewise.fr/audit-trails-logs-are-not-enough).

For our order the evidence package is small. The raw body, the `Content-Digest`, `Signature-Input` and `Signature` headers, the public key behind `keyid` as it was that day, and the proof that the package was sealed in the audit trail.

## Back to Tuesday

With that package, "what did their system send?" stops being a matter of opinion. Anyone can check it with standard tools and no access to your systems. OpenSSL 3 is enough for Ed25519:

```bash title="Verifying the evidence package"
# 1. the stored body still matches its digest
openssl dgst -sha256 -binary body.bin | base64
# compare with the value between the colons in Content-Digest

# 2. rebuild the signature base from the stored headers into base.txt
#    (lines joined with \n and no trailing newline, this one bites everybody once)

# 3. decode the signature from the Signature header
printf '%s' "$SIG_B64" | base64 --decode > sig.bin

# 4. verify against the customer's registered public key
openssl pkeyutl -verify -pubin -inkey customer-purchasing-2026-01.pem \
  -rawin -in base.txt -sigfile sig.bin
# Signature Verified Successfully
```

Their IT team can run exactly the same commands. If it passes, three things are settled. The body you kept is byte for byte what was signed, so nobody on your side turned a 12 into a 120. It was signed with the key their company registered as `customer-purchasing-2026-01`. And the timestamp and nonce show it was one fresh request, not something replayed by a proxy.

Faced with that, their team looks at their own side and finds the cause in an hour. When the supplier catalogue was last imported into their purchasing tool, this chair came in with a unit of measure of "pack of 10". The requester asked for 12, the tool converted 12 packs into 120 units, and that's what the integration sent. Their screenshot wasn't lying, it just showed a different step of the process than the one that talked to you.

That closes the technical argument. It doesn't close the commercial one, and you may still decide to share the cost to keep a good customer. But you're now discussing their catalogue import and your terms of sale instead of whose logs to believe, which is a much healthier conversation.

### What it doesn't prove

It's tempting to oversell this, so let me be careful.

A signature proves that something holding the key signed the request. It says nothing about whether a human meant 120 chairs. Their purchasing tool signed a perfectly valid mistake, and it would have signed it again.

It's also only as good as the key custody. If the private key lived in a config file on a shared server, "someone else used our key" is still a credible argument. If it was generated in an HSM or a cloud KMS and can't be exported, that argument gets much weaker.

It doesn't carry legal weight on its own either. What it's worth in a dispute depends on the contract, so the API terms or the framework agreement should say that orders verified against a registered key are binding, how keys get registered and revoked, and how long evidence is kept. Get your legal team involved early, not after the first refused delivery.

And it only covers one direction. The customer may want proof of what you confirmed back to them, price and delivery date included. RFC 9421 can sign responses too, and if you're on the buying side, that's the part you should be asking your suppliers for.

## The bill

None of this is free, and the code is the cheap part.

Key management is where it gets serious. Keys generated in an HSM or KMS, public keys exchanged through a JWKS endpoint, rotation with an overlap period, and a revocation process someone has actually tested. Lose a private key and the signatures become a debate. Delete an old public key and you've deleted evidence.

Canonicalisation is the second trap. Use a mature library on both sides and run the test vectors from the RFC appendix before going live. Otherwise the first false rejection in production gets "fixed" by turning verification off, and nobody turns it back on.

Replay protection needs a nonce cache shared by every instance and sized to the validity window, plus clocks that agree. The covered components have to be chosen with whoever runs the network, because some proxy will rewrite a header you didn't expect. And every customer has to implement signing in their own stack, which slows onboarding down, so expect pressure from sales to make exceptions.

There are other tools, and they're good at neighbouring problems. Mutual TLS authenticates the client and can bind tokens to its certificate (RFC 8705), but it still stops at the first terminating hop and leaves no message level evidence. [DPoP](https://www.rfc-editor.org/rfc/rfc9449) binds a token to a key and signs the method and URI, not the body. [JWS](https://www.rfc-editor.org/rfc/rfc7515) can sign the payload, attached or detached, which gives the same evidence for the body but nothing for headers or the target, and every API ends up inventing its own convention around it.

In practice I'd combine them. mTLS or DPoP to constrain tokens on every call, and message signatures only on the handful of operations where a dispute is plausible.

## What changes

For most traffic, never. Browsing a catalogue or checking a delivery status is perfectly well served by TLS.

My rule of thumb is that signing earns its cost when a system commits a company to something on its behalf (orders, payments, contract changes), when a mistake would be expensive enough that someone will fight about it, and when the two sides don't share a trust boundary, so neither has any reason to take the other's logs at face value.

If an operation ticks those boxes, I'd ask one question before it goes live. If the customer disputes this request in three weeks, what exactly do we show them, and can they check it without having to trust us? If the honest answer is "a log line", sign it.
