Skip to main content
Understanding Webhook Signatures
AI & Technology

Understanding Webhook Signatures

10 min read
431 views
Share:

Almost every fintech integration I have shipped eventually grows a webhook surface. Card networks, payment processors, KYC vendors, ledger systems, and disbursement partners all want to tell us about events asynchronously rather than waiting for us to poll. Webhooks are wonderful for that. They are also, if you treat them casually, one of the easiest ways to let an attacker forge a payment confirmation or a refund authorization straight into your system.

Understanding Webhook Signatures
Understanding Webhook Signatures

The mechanism that keeps webhooks honest is the signature. It is a small thing conceptually, a header with some hex or base64 in it, but the details around how it is computed, verified, and rotated are where most teams quietly introduce risk. I want to walk through how I think about webhook signatures, why the common shortcuts are dangerous, and what a defensible implementation actually looks like in production.

Why Webhook Signatures Exist at All

A webhook endpoint is, by necessity, a publicly reachable URL. Your payment provider has to be able to reach it from their infrastructure, which means anyone on the internet can also reach it. If your endpoint trusts the body of any POST request that arrives, then anyone who can guess or discover the URL can tell your system that an invoice was paid, that a chargeback was reversed, or that a payout succeeded. That is not a hypothetical; endpoint URLs leak through logs, browser history, error trackers, and well-meaning support tickets all the time.

The signature solves this by proving two things at once. First, that the message genuinely came from the party who holds the shared secret, which is authenticity. Second, that the bytes you received are exactly the bytes that were sent, which is integrity. A correctly verified signature lets you treat the payload as trustworthy enough to move money against. Without it, you are essentially running an open API that performs financial state changes on the say-so of strangers.

It is worth being precise about what a signature does not give you. It is not encryption; the payload is still readable to anyone who intercepts it in transit, which is why you still want TLS. It is not a replay defense by itself, because a valid signed message stays valid forever unless you add something more. Knowing the boundaries of the guarantee keeps you from leaning on it for things it was never meant to do.

The HMAC Foundations You Need to Get Right

The overwhelming majority of webhook signatures use HMAC, typically HMAC-SHA256. HMAC is a keyed hash: you feed it the message bytes and a secret key, and it produces a fixed-length digest that cannot be reproduced without the key. The provider computes the HMAC over the request body using a secret you both share, and sends the result in a header. You recompute it on your side and compare.

The subtlety that trips people up is what exactly gets hashed. It is the raw request body, byte for byte. If your web framework parses the JSON, re-serializes it, and you sign that re-serialized version, you will get a different digest because key ordering, whitespace, and unicode escaping all differ. I have lost more debugging hours than I would like to admit to this exact problem. You must capture the original raw bytes before any middleware touches them.

The signature is computed over the exact bytes on the wire. The moment you let a JSON parser round-trip the payload before verification, you are signing something the sender never signed, and your comparison will fail in ways that look random.

Verifying a Signature in .NET

Here is the pattern I use in ASP.NET Core. The important parts are reading the raw body, using a fixed-time comparison, and never logging the secret. I treat the secret as a configuration value injected from a secrets manager, not something that lives in the codebase.

public async Task<bool> VerifyAsync(HttpRequest request, string secret)
{
    request.EnableBuffering();

    using var reader = new StreamReader(
        request.Body, Encoding.UTF8, leaveOpen: true);
    var rawBody = await reader.ReadToEndAsync();
    request.Body.Position = 0;

    if (!request.Headers.TryGetValue("X-Signature", out var header))
        return false;

    var keyBytes = Encoding.UTF8.GetBytes(secret);
    using var hmac = new HMACSHA256(keyBytes);
    var computed = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody));
    var expected = Convert.ToHexString(computed).ToLowerInvariant();

    var provided = header.ToString();

    // Constant-time comparison to avoid timing leaks.
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(provided));
}

Notice the call to FixedTimeEquals. A naive string equality check returns as soon as it finds a mismatched byte, and that timing difference is measurable over enough requests. An attacker can, in principle, recover a valid signature byte by byte by observing how long your comparison takes. The fix is cheap, so there is no reason to skip it.

Constant-Time Comparison Is Not Optional

I want to dwell on the timing point because it is the one engineers most often wave away as paranoid. The argument against it goes something like: an attacker would need millions of requests with stable network latency to extract anything useful, so why bother. The answer is that defenses against cryptographic attacks should not depend on the attacker being inconvenienced. Network jitter raises the cost, but it does not make the attack impossible, and your endpoint may sit behind infrastructure that smooths latency in ways you do not control.

More importantly, the correct approach costs nothing. Every mature platform ships a constant-time comparison primitive. In .NET it is CryptographicOperations.FixedTimeEquals. In Node it is crypto.timingSafeEqual. In Python it is hmac.compare_digest. Reaching for these instead of the equality operator is a one-line decision that closes an entire class of attack. When something is both correct and free, the burden of proof should be on whoever wants to do it the risky way.

Enjoying this article?

Get more like it in your inbox — practical engineering leadership, fintech, and AI. No spam, unsubscribe anytime.

Replay Protection and Timestamp Tolerance

A valid signature proves the message is authentic, but it does not prove the message is fresh. If an attacker captures one legitimately signed webhook, perhaps from a leaked log or a compromised proxy, they can resend it as many times as they like and every copy will verify cleanly. For a payment confirmation event, replaying that could trigger duplicate fulfillment or duplicate credit.

The standard mitigation is to include a timestamp in the signed material and reject anything outside a tolerance window. Good providers sign a string that concatenates the timestamp and the body, then send the timestamp in its own header so you can reconstruct the signed string. Your verification then does two checks: the signature matches, and the timestamp is recent.

  • Sign over the timestamp plus the body, not the body alone, so the timestamp cannot be altered independently.
  • Reject requests whose timestamp is more than a few minutes old; five minutes is a common, defensible window.
  • Store recently seen event identifiers and reject duplicates, so even within the window a replay is caught.
  • Make your handler idempotent regardless, because retries from the provider are legitimate and will reuse identifiers.

Idempotency as the Real Backstop

I have come to believe that idempotency is more important than any single verification check, because it protects you even when something upstream goes wrong. Providers retry. They retry when your endpoint times out, when it returns a 5xx, and sometimes when their own delivery tracking is uncertain. A well-behaved provider will send the same event with the same identifier several times, and your system must treat the second and third arrivals as no-ops.

The practical pattern is to record the provider's event identifier in a table with a unique constraint, inside the same transaction that performs the side effect. If the insert fails on the unique constraint, you know you have already processed that event and you return success without doing the work again. This makes replay protection and retry handling fall out of the same mechanism.

CREATE TABLE processed_webhook_events (
    event_id        VARCHAR(128) NOT NULL,
    provider        VARCHAR(64)  NOT NULL,
    received_at     TIMESTAMP    NOT NULL DEFAULT now(),
    CONSTRAINT pk_processed_events PRIMARY KEY (provider, event_id)
);

-- Inside the handler transaction:
INSERT INTO processed_webhook_events (event_id, provider)
VALUES (@eventId, @provider)
ON CONFLICT (provider, event_id) DO NOTHING
RETURNING event_id;

If that statement returns no row, the event was already handled and we stop. If it returns the identifier, we proceed with the financial work in the same transaction. Pairing the dedupe insert with the side effect is what makes the guarantee real rather than aspirational, because a crash between the two would otherwise let a replay slip through.

Managing and Rotating Secrets

The signing secret is a credential, and it deserves the same handling as a database password. It should never be committed to source control, never logged, and never passed around in plaintext configuration files that end up in a build artifact. I keep these in a managed secrets store and inject them at runtime, with access scoped to the services that actually verify webhooks.

Rotation is the part people forget to plan for until they are forced into it by an incident. You cannot atomically swap a secret across two independent systems, so any rotation strategy has to tolerate a window where both the old and new secret are valid. The clean way to do this is to accept either secret during the overlap: compute the expected signature with the current secret, and if it does not match, try the previous one before rejecting.

Design for rotation on day one. If your verification code can only ever hold a single secret, then rotating it means a flag day with downtime, and flag days are exactly when mistakes get made under pressure.

Handling Verification Failures Sensibly

When a signature fails to verify, the right response is a clean rejection without leaking detail. Return a 401 or 400, do not echo back why the verification failed, and do not run any of the downstream business logic. An error message that says the timestamp was stale versus the signature was wrong gives an attacker a feedback channel they can probe, so I keep the external response generic and put the diagnostic detail in internal logs only.

It is equally important to alert on patterns of failure rather than individual ones. A single failed verification is usually a clock skew or a misconfigured test. A sudden burst of them from one source is either a provider rolling a secret you did not coordinate on, or someone actively probing your endpoint. I route verification failures to the same observability pipeline as other security signals so a spike is visible, and I make sure the legitimate provider's failures are distinguishable from anonymous traffic.

Anselm Fowel, CTO and fintech architect
Anselm Fowel — CTO & fintech architect

Conclusion

Webhook signatures look like a checkbox item, a header you copy from a vendor's documentation and move on. In a system that moves money, they are load-bearing. The discipline is in the unglamorous details: hashing the raw bytes, comparing in constant time, binding a timestamp, deduplicating on the provider's event identifier, and planning rotation before you need it. None of these are difficult individually, but skipping any one of them opens a gap that is invisible until it is exploited. I would rather spend the extra afternoon getting the verification path right than explain to a regulator why a forged payload reconciled cleanly through our ledger.

Enjoyed this article? Share it with others!

Share:

Get new posts in your inbox

Occasional, practical notes on engineering leadership, fintech, and building with AI. No spam, unsubscribe anytime.

Comments (9)

Leave a Comment

Comments are moderated and will appear after review.

Chris Martinez

September 11, 2026

Thanks for writing it up. One nit on "Verifying a Signature in .NET": in Neon Postgres you get most of this for free via a small library nobody talks about.

Yaw Nkrumah

August 29, 2026

Thanks for writing it up. A small nit on "Verifying a Signature in .NET": the retry-after budget for FX rate feed reliability is usually more like 24 hours in practice, not 2.

Zainab Musa

August 24, 2026

Does the "Verifying a Signature in .NET" still hold on a 299-service estate? We're at the smaller end of that and some of these patterns feel like they need a dedicated ops person to run properly.

Vanessa Baker

August 15, 2026

Good topic. Staff SWE at a Series C neobank in Austin here. What we do differently: split the read and write paths at the DB level on Temporal. It is not universally better; operational complexity is real, but the testability is dramatically better and that pays for itself the first time you have to answer a forensic investigator question at 2am.

Ashley Martinez

August 11, 2026

Founder-CTO, 5 engineers, 11 months post-launch. We hit this exact thing with Stripe fee math last quarter — tail latency was the presenting symptom, and the "Handling Verification Failures Sensibly" section is basically how we untangled it. Ended up adding a background worker on Neon Postgres, cut p99 latency by 41%. For context: 179 paying customers.

Rasheed Ogundipe

August 10, 2026

If anyone hits this in zero-downtime window specifically, check out a home-rolled state machine — saved us weeks of custom retry code.

Hauwa Aliyu

August 10, 2026

Quick q on "Replay Protection and Timestamp Tolerance" — how do you handle backpressure when the downstream service times out? We're on Cloudflare Workers and ops keep asking for manual replay tooling.

Brandon Carter

August 9, 2026

If anyone hits this in IC-to-manager transition specifically, we had good luck with SQS FIFO with a small deduper — the operational visibility alone pays for itself.

Stephanie Rodriguez

August 9, 2026

Good topic. SRE at a ledger-as-a-service startup here. What we do differently: split the read and write paths at the DB level on BigQuery for logs. It is not universally better; operational complexity is real, but the debuggability is dramatically better and that pays for itself the first time you have to answer a forensic investigator question at 2am.

About the author

Anselm Fowel

Anselm Fowel

Chief Technology Officer & fintech architect. 16+ years leading engineering across AlliancePay, Mondu, Transalliance, Global Accelerex, and Fidelity Bank — writing here about engineering leadership, fintech architecture, and AI in production.

Read next

Subscribe to the newsletter

Practical notes on engineering leadership, fintech, and building with AI — delivered to your inbox. No spam, unsubscribe anytime.

Anselm Fowel

Chief Technology Officer | Fintech Architect | Engineering Leader

Building the future of financial technology through innovative engineering and strategic leadership.

Expertise

  • CTO Advisory
  • Fintech Architecture
  • Team Leadership
  • Technical Strategy
  • System Design

Get In Touch

[email protected]
Lagos, Nigeria

© 2026 Anselm Fowel. Crafted with passion.