Theo Zourzouvillys

Sign the Request, Not the Session: Announcing httpsig

TLDR: Bearer tokens — cookies, JWTs, API keys — grant access to whoever holds them. Steal the token, become the user. HTTP Message Signatures (RFC 9421) flip that: you sign the request itself, so a recipient can prove who sent this message and that not a byte was changed in flight. I built httpsig — one consistent implementation across Go, TypeScript, Java, Swift, and Kotlin — and a playground where you can sign, verify, and inspect real signatures entirely in your browser. Keys never leave the page.

The problem with bearer tokens

Almost everything authenticates with a bearer token: a session cookie, a JWT, an OAuth access token, an API key. The defining property is right there in the name — bearer. The token grants access to whoever bears it. There’s no binding to who you are, what device you’re on, or what you’re actually asking the server to do.

So if a token leaks — through an XSS bug, a logged Authorization header, a compromised proxy, a backup someone left in an S3 bucket — the attacker can replay it from anywhere and do anything you could, until it expires or someone notices. The token says nothing about the request it’s attached to. GET /me and POST /transfer?amount=all carry the exact same credential.

We’ve spent years papering over this with shorter expiries, refresh-token dances, device fingerprinting, and DPoP. They help. But they’re all working around the same root fact: the credential isn’t tied to the message.

Sign the message instead

RFC 9421 takes the other path. Instead of attaching a token that says “trust me,” the sender signs the specific request — method, path, a few headers, a digest of the body — with a private key. Two headers go on the wire:

  • Signature-Inputwhat was covered, plus metadata (key id, created/expires timestamps, a nonce)
  • Signature — the actual signature bytes

The recipient rebuilds the exact same “signature base” from the message it received and checks it against the sender’s public key. Change one covered byte — flip a path, edit the body — and verification fails. The credential is the request. A captured signature is worth nothing on a different request, and nothing at all once it expires.

This isn’t exotic; it’s how AWS SigV4, the Fediverse (ActivityPub), and a growing list of APIs already authenticate. RFC 9421 is the standardized, algorithm-agnostic version of the idea. What’s been missing is good, consistent libraries — so I wrote some.

What it looks like on the wire

Here’s a signed request. The only additions over a normal HTTP request are the last three headers:

POST /transfer HTTP/1.1
Host: bank.example
Content-Type: application/json
Content-Digest: sha-256=:C+S5XnC+NDF3Vvz+7BvB8BRmRo/o2Hgxr28ITVzHNNU=:
Signature-Input: sig1=("@method" "@authority" "@path" "content-digest");created=1749100000;keyid="acme-key";alg="ed25519"
Signature: sig1=:n2pYwQ8r…3aQK0g==:

{"to":"alice","amount":100}

How those values come to be, at a high level:

  1. Hash the body. Content-Digest is just sha-256 of the body, base64-encoded. (Run the body above through it and you get the digest you see.)

  2. Pick what to protect, and lay it out canonically. The signer chooses a list of components and writes one line each — this is the signature base, the exact bytes that get signed:

    "@method": POST
    "@authority": bank.example
    "@path": /transfer
    "content-digest": sha-256=:C+S5XnC+NDF3Vvz+7BvB8BRmRo/o2Hgxr28ITVzHNNU=:
    "@signature-params": ("@method" "@authority" "@path" "content-digest");created=1749100000;keyid="acme-key";alg="ed25519"
  3. Sign those bytes with the private key. The result, base64-encoded, is the Signature value.

  4. Signature-Input is the recipe, not a secret: it lists the covered components and the parameters (key id, algorithm, timestamps) so the verifier can rebuild the identical base.

To verify, the recipient reconstructs that same base from the message it actually received, checks the signature against the sender’s public key, and re-hashes the body to confirm the digest. Change the amount to 1000000 and the body’s hash no longer matches the signed content-digest — so the request is rejected, even though the signature bytes are otherwise untouched.

Don’t roll your own

Everything above is simple in spirit and unforgiving in detail. The signature base is byte-exact: get the component ordering, the quoting, the @signature-params serialization, or the Structured Field Values encoding slightly wrong and you’ll produce a signature that verifies against your own code and nothing else on Earth. ECDSA has to be raw r‖s, not DER. Derived components like @authority have their own normalization. Request-to-response binding has sharp edges. None of it is hard — it’s a long list of small things that must match the other end exactly, which is precisely what a library should own and a shared test suite should pin down.

So don’t hand-roll it. Adding it is a few lines.

Client — sign every outgoing request:

import { createSigningFetch, newEd25519SigningKey } from "@zourzouvillys/httpsig";
import { createPrivateKey } from "node:crypto";

const key = newEd25519SigningKey("acme-key", createPrivateKey(privateKeyPem));
const fetch = createSigningFetch({ key }); // signs @method, @path, @authority by default

await fetch("https://bank.example/transfer", { method: "POST", body });

Server — require a valid signature:

import { verifyMessage, buildRequestMessage, newEd25519VerifyingKey, component } from "@zourzouvillys/httpsig";
import { createPublicKey } from "node:crypto";

const keys = { "acme-key": newEd25519VerifyingKey("acme-key", createPublicKey(publicKeyPem)) };

const msg = buildRequestMessage(method, url, headerPairs); // headerPairs: Array<[name, value]>
const result = await verifyMessage(msg, async (keyId) => keys[keyId], {
  requiredComponents: [component("@method"), component("@authority")],
  maxAgeMs: 60_000, // reject stale signatures
}); // throws on any failure; result.keyId is the verified signer

That’s the whole integration. The shape is the same in Go, Java, Swift, and Kotlin, and there are drop-in adapters for axios, undici, OkHttp, the JDK HttpClient, Spring WebClient, URLSession, Alamofire, and the Ktor client — usually you wire it in one place and forget it’s there.

httpsig

httpsig implements RFC 9421 (and RFC 9530 Content-Digest) natively in five languages — Go, TypeScript, Java, Swift, and Kotlin — each idiomatic to its ecosystem rather than a transliteration, all passing the same shared test vectors. It does the fiddly parts you’d otherwise get subtly wrong: canonical signature-base construction, Structured Field Values, raw r‖s ECDSA encoding, request/response binding, and drop-in signing for fetch, axios, OkHttp, URLSession, Ktor, and friends. Keys can be backed by the Secure Enclave, Android Keystore, an HSM, a cloud KMS, or a non-extractable Web Crypto key — the library only ever asks them to sign.

There’s a thorough set of docs. But docs aren’t the part I’m most pleased with.

A playground, because reading about signatures is the slow way

Last time I argued that the cheapest way to understand something is to build an interactive artifact for it — and that LLMs have made those nearly free. The httpsig playground is me taking my own advice. It’s jwt.io, but for HTTP signatures, and it runs entirely in your browser on the library’s own TypeScript build over the Web Crypto API:

The httpsig playground: a Sign tab with a request builder on the left (method, URL, headers, body, covered-component checkboxes, signature parameters, algorithm, and a freshly generated Ed25519 key shown as PEM) and, on the right, the output — the canonical signature base, the Signature-Input and Signature headers, and the full signed HTTP request.

Generate a key (or paste your own), build a request, pick which components to cover, and Sign — and you get the signature base, the two headers, and a copy-pasteable signed request. Flip to Verify to check a signed request against a key, or Inspect to decode any Signature-Input/Signature pair into a structured breakdown without a key at all. Nothing is sent anywhere; the keys are generated and used locally.

The thing I most wanted to make tangible is why this is better than a token. So: sign a request that covers a Content-Digest of the body, then change one character of the body and verify again.

The playground's Verify result after tampering: a red "Verification failed — signature is valid but the body does not match the signed Content-Digest" banner, a checklist showing Signature-Input present, Signature present, and signature cryptographically valid all green-checked, but "Content-Digest matches body (RFC 9530)" failing, above a decoded view of the signature's label, key id, algorithm, timestamp, and covered components.

The signature is still cryptographically valid — the math checks out — but the body no longer matches the digest the signature covered, so the message is rejected as tampered. A stolen bearer token would have sailed straight through that edit. This is the whole pitch in one screen.

The bar should move

Let me state this plainly, because I think it’s overdue: bearer-only authentication should be treated as legacy.

When you build systems from here on:

  • Every client should sign its requests — httpsig over the request, plus DPoP to bind its credential to a key it actually holds.
  • Every server should require it — reject the request unless it carries a valid, key-bound signature over that request, not merely a token in a header.
  • A JWT — or any bearer token — on its own is no longer sufficient. It must be presented together with proof that the caller controls the key the token was issued to (DPoP) and proof that this specific request came from that caller (httpsig). Token without both: rejected.

The division of labour is clean. The token says what you’re allowed to do. The signature says that it’s really you asking, and this is really what you asked. You need both, on every request — not a token alone, holder unknown, request unverified. We have the standards — RFC 9421 for message signatures, RFC 9449 for DPoP — and now we have the libraries. The only thing left is to stop accepting less.

Try it

Stop mailing everyone your password. Sign the request.


← All writing