Skip to content
Bhavansh Gupta
Go back

HTTP Signatures in Real-Time Payments

•5 min read

📝 Note: This post was edited with AI assistance for clarity and structure. The system design, implementation decisions, and technical thinking are entirely my own.

TL;DR


Introduction

Our payments platform previously routed interbank fund transfers over traditional correspondent banking rails. While reliable, settlement could take several hours. Integrating with a real-time payments API gave us near-instant settlement, but introduced a hard security requirement:

Every request must be authenticated, tamper-proof in transit, tamper-evident at rest, and non-repudiable.


Security Requirements

Before choosing an authentication mechanism, we defined what we actually needed:


Why Not JWT, OAuth, or mTLS?

MechanismSender AuthenticationMessage IntegrityNon-RepudiationReplay Protection
OAuth / JWT✅❌❌❌
mTLS✅Transport only❌Transport only
HMAC Request Signing✅✅❌Implementation-specific
HTTP Signatures✅✅✅*✅*
  • Requires asymmetric keys for non-repudiation and timestamps/nonces with server-side validation for replay protection.

Key Eliminations

HTTP Signatures (draft-cavage-http-signatures-12, later standardized as RFC 9421) was the only mechanism that met all our requirements without additional round-trips or a separate verification service.


How HTTP Signatures Work

The specification defines a signing string — a canonical, newline-separated concatenation of selected HTTP components — which is then signed and base64-encoded into the request headers.

Here’s what the signing string looks like for a typical transfer request — this exact string is what gets signed with your private key:

Signing String

(request-target): post /v1/transfers
(created): 1718300000
host: api.example.com
digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=
content-length: 142
x-nonce: a3f9c21b-7e84-4d12-b001-9e5c3d8f0a72

Authorization Header

Authorization: Signature
  keyId="payments-service-prod",
  algorithm="hs2019",
  headers="(request-target) (created) host digest content-length x-nonce",
  signature="Base64(Signature(signing-string))"

The header order in the signature string is a bilateral contract with the payment provider. Our agreed canonical order is:

(request-target) → (created) → host → digest → content-length → x-nonce

Component Breakdown

ComponentPurpose
(request-target)Prevents method or path tampering
(created)Limits replay window
hostPrevents endpoint substitution
digestProtects request body integrity
content-lengthPrevents truncation or padding attacks (defense-in-depth)
x-noncePrevents replay of captured requests

RFC 9421 vs draft-cavage-http-signatures-12

The mental model is nearly identical. The terminology evolved:

draft-cavage-12RFC 9421
Signing stringSignature input
(request-target)@method + @target-uri derived components
(created) / (expires)Same concept with cleaner specification language
NonceExplicitly covered in security considerations

The investment in understanding draft-cavage-http-signatures-12 transfers directly to RFC 9421. The model remains the same; the specification simply tightened the details.

With that context established, here’s how both mechanisms fit together in practice.


System Architecture

We use two distinct signing mechanisms serving two different concerns.

1. HTTP Signatures (In Transit)

The outbound request to the payment provider is signed using HTTP Signatures with an asymmetric keypair (e.g., Ed25519). The private key signs the signing string; the provider verifies with our public key. This provides non-repudiation — we cannot later deny having generated the request. The signature covers:

This protects the request while it traverses networks and intermediary infrastructure.

2. HMAC Stored in the Database (At Rest)

The business intent is independently signed with an HMAC (symmetric, shared only between our signing and verification services) and persisted alongside the transfer record.

{
  sourceIdentifier,
  destinationIdentifier,
  amount,
  currency,
  timestamp
}

This HMAC serves as an at-rest tamper-detection mechanism and is entirely separate from the HTTP Signature. It cannot provide non-repudiation (the verifying side also knows the key), but that’s not its job — it exists solely to detect unauthorized modification after the fact.


Request Lifecycle

Internal Transfer Initiator
     │
     │  Input: Source ID, Destination ID, Amount, Currency
     ▼
  Payments Service
     │
     ├─► Resolve payment details
     │
     ├─► Compute HMAC over
     │    {source, destination, amount, currency, timestamp}
     │
     ├─► Persist
     │    {transfer payload + HMAC}
     │
     ├─► Construct outbound request
     │    (fresh timestamp, nonce, correlation ID)
     │
     ├─► Sign outbound request via HTTP Signatures
     │
     └─► POST to payment provider
          │
          ├─ 2xx → update state
          ├─ 4xx → fail request
          └─ timeout / transient failure
                 → asynchronous retry workflow

Why Sign the Input Instead of the Outbound Request?

During retries, the outbound request is reconstructed:

These changes are legitimate.

What must never change is the business intent:

Who is transferring what amount to whom.

Signing the input at receipt cryptographically locks the business intent at the moment of authorization.

Before every retry:

  1. Recompute HMAC from persisted values.

  2. Compare with stored HMAC.

  3. If they differ, halt processing and raise an operational alert.

A modified amount or substituted beneficiary never reaches the payment provider.


Retry Design: Avoiding Double Posts

A timeout during a real-time payment operation is one of the most dangerous states in fintech. Retrying blindly risks duplicate transfers.

Idempotency

The payment provider supports idempotent requests through a client-generated correlation identifier.

The service:

  1. Generates the identifier during the first attempt.

  2. Persists it alongside the transfer record.

  3. Reuses the same identifier for every retry.

This guarantees duplicate submissions resolve to the original transaction rather than creating additional transfers.

The HMAC verification gate serves a dual purpose:


Multi-Currency Request Construction

A single transfer endpoint often supports multiple currencies, but field requirements vary considerably:

A branching approach quickly becomes unmaintainable:

if (currency == USD) { ... }
else if (currency == GBP) { ... }
else if (currency == EUR) { ... }

Instead, we implemented a factory and generator pattern:

public interface PaymentRequestGenerator {
    PaymentRequest generate(TransferInput input);
}

public class UsdPaymentRequestGenerator
        implements PaymentRequestGenerator { ... }

public class GbpPaymentRequestGenerator
        implements PaymentRequestGenerator { ... }

public class EurPaymentRequestGenerator
        implements PaymentRequestGenerator { ... }

public class PaymentRequestGeneratorFactory {
    public static PaymentRequestGenerator
            forCurrency(Currency currency) {

        return switch (currency) {
            case USD -> new UsdPaymentRequestGenerator();
            case GBP -> new GbpPaymentRequestGenerator();
            case EUR -> new EurPaymentRequestGenerator();
            default ->
                throw new UnsupportedCurrencyException(currency);
        };
    }
}

The signing, retry, and tamper-detection layers operate entirely on TransferInput and remain currency-agnostic.

Adding a new payment corridor becomes a single new generator implementation with no changes to core logic.


Key Takeaways


Share this post:

Next Post
Building an Internal Admin Dashboard with HTMX and Thymeleaf in 2025