Authenticating an API with RFC 9421 HTTP Message Signatures

A bearer token is a password: whoever holds it can do anything, and if it leaks in a log or a proxy, it's game over. KibiPay authenticates its API differently — every request is signed using RFC 9421 HTTP Message Signatures, so the caller proves not just that they hold a key but that this exact request came from them and wasn't altered in transit.
What gets signed
RFC 9421 defines how to sign a set of HTTP "components" and carry the signature in headers. A KibiPay request sends four:
Date— an RFC 1123 timestamp, used to bound how old a request may be.Content-Digest— a SHA-256 hash of the raw body, in the structured-field formsha-256=:<base64>:. Signing the digest, rather than the whole body, means the body itself is covered without the signer having to canonicalize it.Signature-Input— which components are covered, plus the key id and algorithm.Signature— the signature bytes themselves, base64, labelledsig1.
The covered components — the "signature base" — are, in exact order: @method, @path, @query, host, content-digest and date. Each is written as a labelled line and the lines are newline-joined, then signed. Covering @method and @path stops an attacker replaying a GET as a DELETE or pointing it at a different resource; covering @query — the raw query string — stops query-parameter tampering in transit; covering content-digest ties the signature to the body. Notably, reads are signed too, not just writes: a GET carries a full signature over an empty-body digest.
Ed25519, with the algorithm pinned
Production signatures use Ed25519, a modern elliptic-curve signature scheme — fast, small, and free of the footguns that dog RSA. Keys are generated as a standard PEM pair. There's a legacy hmac-sha256 mode for local development, but it's rejected at runtime unless an explicit dev secret is set, so it can never be the production path by accident.
One detail in the verifier is worth calling out because it's a classic vulnerability if you get it wrong: the algorithm used to verify is pinned from trusted key material, never read from the attacker-supplied Signature-Input. If you let the request tell you which algorithm to verify with, an attacker simply picks the weakest one — the "alg confusion" attack that has broken many JWT implementations. KibiPay's verifier requires the caller to supply the expected algorithm from the stored key, so the request can't choose its own verification.
Per-tenant keys, built for rotation
Each tenant registers one or more public keys, stored server-side in a keys table keyed by tenant and key id, holding the algorithm, the public PEM, and an active flag. Only the public half is ever persisted — the private key is returned exactly once when the key is issued and never stored, so a database compromise can't yield anyone's signing key. Because a tenant can have several active keys at once, rotation is a non-event: register the new key, start signing with it, retire the old one, no flag-day. The signed key id is what establishes the caller's identity — the verified tenant, not any client-asserted header, is the authoritative account for the request.
How this compares to Form3
KibiPay's scheme is explicitly built for "Form3 parity," and the developer tutorials are modelled on Form3's. The shared idea is the same: each client holds an asymmetric key and signs each request together with a digest of the body, so the server verifies against a public key rather than a shared secret. The difference is the envelope. Form3's approach signs using Authorization and Digest headers; KibiPay uses the standardized RFC 9421 header set — Signature-Input, Signature and Content-Digest — which is now an IETF standard rather than a bespoke scheme. On top of the baseline, KibiPay adds a one-time replay nonce, covered in Ed25519 request signing with a one-time replay nonce.
The net effect: a request that's been tampered with fails verification, a request captured from a log can't be replayed, and a leaked public key is worthless to an attacker. That's a materially stronger position than "hold this token and you're in."
Why signing beats bearer tokens
It's worth being explicit about what this design buys over the usual "send a secret token" approach. A bearer token is a single secret that grants everything; capture it once — from a log, a proxy, a crash dump — and you can impersonate the client until it's rotated. A signed request is different in kind. The server only ever stores the client's public key, so a database breach yields nothing an attacker can sign with. The signature covers the method, path, query and a digest of the body, so a captured request can't be altered — change any covered part and verification fails. And because the signature is bound to this request rather than being a reusable credential, a leaked signature is far less dangerous than a leaked token, especially once the one-time replay nonce ensures each one works exactly once. The trade-off is that signing is more work for the client than pasting a token into a header — which is exactly why the platform ships Form3-style tutorials and a shared signing helper, so the extra rigour costs integrators effort once, at build time, not forever.