KibiPay
HomeBlog › API Security

Ed25519 Request Signing With a One-Time Replay Nonce

7 min read API Security
API SecurityCryptography
Ed25519 Request Signing With a One-Time Replay Nonce

A cryptographic signature proves a request is authentic and untampered. It does not, on its own, prove the request is fresh. If an attacker captures a perfectly valid signed request — from a proxy log, a mirror, a compromised intermediary — they can resend it, and every signature check will pass, because the request really was validly signed. That's a replay attack, and KibiPay closes it by burning each signature exactly once.

Ed25519 first

The signatures themselves use Ed25519. A tenant generates a keypair, registers the public half, and signs each request's RFC 9421 signature base with the private half. Ed25519 is deterministic, fast to verify, and produces compact 64-byte signatures — a good fit for signing on every API call. Verification confirms the request came from the holder of the private key and that none of the covered components changed. But a verified signature is still replayable, which is where the nonce comes in.

The signature is the nonce

KibiPay doesn't ask clients to generate and send a separate nonce value. It uses something that's already unique per request: the signature itself. After a signature verifies, the middleware tries to record it as seen, using a key of the form sig:seen:{key_id}:{signature} in Redis. The write is a set-if-absent: SET key "1" NX EX 600. If the key didn't exist, the set succeeds and the request proceeds — this signature has now been "burned." If the key already existed, the set returns falsy, meaning this exact signature was already used, and the request is rejected with 401 signature_replayed.

Using the signature as the nonce is elegant because it needs no client cooperation and no coordination: two genuinely distinct requests produce two distinct signatures (the base includes the method, path, query, body digest and timestamp), so they never collide, while a replayed request reuses the identical signature and is caught on the second attempt.

Why the TTL is ten minutes

The replay record lives for 600 seconds, and that number isn't arbitrary — it's exactly twice the clock-skew window. Signature verification also checks the request's Date against the server clock and rejects anything more than 300 seconds off in either direction. So a captured signature can only possibly verify within a ±300-second window. Keeping the "seen" entry for 600 seconds guarantees the nonce record always outlives the window during which the signature could still be accepted. Once the Date is too old to verify anyway, remembering the signature is pointless, so the entry can expire — which keeps the replay cache bounded rather than growing forever.

Fail closed, not open

There's a tempting failure mode to get wrong here: what happens if Redis — the thing that stores the nonces — is unreachable? The lazy answer is to skip the replay check and let the request through, so an infrastructure blip doesn't cause an outage. KibiPay does the opposite. If the replay store can't be consulted, the request is refused with 503 replay_check_unavailable. The reasoning is that a replay check you can't perform is a security control you can't enforce, and silently disabling a security control during an outage is precisely when an attacker would strike. Better a brief, honest unavailability than a window where replay protection quietly evaporates.

Layered, on purpose

Note how the responsibilities split cleanly. The signature verifier proves authenticity and, via the Date check, bounds age. The nonce cache proves uniqueness. Neither does the other's job — the verifier explicitly does not track seen signatures, and the nonce cache doesn't re-check the crypto. Together they give you a request that must be authentic, recent, and used only once. That combination is what turns "validly signed" into "validly signed, right now, exactly once."

Where the pieces live

The layering here maps cleanly onto separate components, which is part of why it's robust. The signing helper — shared across services — knows how to build the signature base and produce or verify an Ed25519 signature, and it deliberately does not concern itself with replay; its job ends at "is this signature valid and recent?" The replay check lives one layer out, in the request middleware, which only runs it after a signature has verified, so an invalid request never even touches the nonce store. The store itself is Redis, chosen because a set-if-absent with an expiry is exactly the primitive a one-time nonce needs and Redis does it atomically. Keys are namespaced per key id and signature, so two tenants can never collide, and each entry evicts itself once it can no longer possibly matter. Should the platform ever move signing keys into a hardware security module or a managed transit engine — which the code comments flag as the production intent — none of the replay logic changes, because it operates on the signature bytes, not the key. Clean seams like that are what let a security design evolve without being rewritten.

See it in motion

KibiPay connects UK Faster Payments, Bacs, CHAPS, Mojaloop mobile money, a mock ACH rail and Solana behind one API, with a cross-rail alias directory, ISO 20022 messaging and real-time fraud & AML screening.

Open the live console Directory demo