Integration guide

Validating webhook signatures in ASP.NET Core

The raw-body trap that breaks HMAC verification in .NET, why == is a vulnerability, and how three real providers sign — including one that does not.

Runnable sample code

Most webhook signature bugs are not cryptographic. They come from three misunderstandings, and all three produce the same symptom: a signature that never matches, and a couple of hours lost to suspecting the wrong secret.

This guide covers all three, then compares how three real providers sign — because they do not agree, and the differences change what your handler has to do.

Every code sample is from a repository that builds and tests with no credentials and no network.

1. The raw body trap, which is specific to .NET

A signature covers the exact bytes the provider sent. By the time your action method receives a bound model, those bytes no longer exist. The JSON has been parsed, and re-serialising it reorders keys, changes whitespace, and normalises numbers and escapes.

The HMAC will not match. The failure looks exactly like a wrong secret.

Worse, the request body is a forward-only stream that model binding consumes. Read it after binding and you get an empty string.

public static async Task<byte[]> ReadAsync(HttpRequest request, CancellationToken ct = default)
{
    // Without this the stream cannot be rewound and is single-use.
    request.EnableBuffering();

    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer, ct);

    // Rewind so whatever runs next sees a complete body.
    request.Body.Position = 0;

    return buffer.ToArray();
}

Read raw bytes, rewind, verify against those bytes, deserialise afterwards. Never verify against a model.

2. == is a vulnerability, not a style preference

Every provider that documents this says the same thing. Stripe requires a “constant-time-string comparison”. GitHub says “never use a plain == operator”.

Ordinary string comparison returns as soon as it finds a differing byte, so how long it takes reveals how many leading bytes were correct. An attacker who can measure that recovers a valid signature one byte at a time — turning an infeasible 2^256 search into a few thousand requests.

.NET ships the primitive:

public static bool FixedTimeEquals(string? a, string? b)
{
    if (a is null || b is null) return false;

    // FixedTimeEquals requires equal-length inputs and returns false for
    // mismatched ones — but that length check is not itself constant time.
    // Hashing both sides makes the comparison length-independent, so a
    // wrong-length signature is indistinguishable from a wrong-value one.
    Span<byte> hashA = stackalloc byte[32];
    Span<byte> hashB = stackalloc byte[32];
    SHA256.HashData(Encoding.UTF8.GetBytes(a), hashA);
    SHA256.HashData(Encoding.UTF8.GetBytes(b), hashB);

    return CryptographicOperations.FixedTimeEquals(hashA, hashB);
}

The hashing step is the part usually left out. CryptographicOperations.FixedTimeEquals is constant time for equal-length inputs — it returns early on a length mismatch, which leaks the length. Hashing first removes that.

3. Providers do not sign the same way

Three real schemes, in decreasing order of what they protect against.

Stripe: HMAC over timestamp and body

Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a…

The signed payload is the timestamp, a literal ., then the raw body:

1492774577.{"id":"evt_1",…}

HMAC-SHA256, keyed by the endpoint signing secret.

The timestamp being inside the HMAC is the whole point: an attacker cannot replay a captured body with a fresh timestamp, because changing the timestamp invalidates the signature. Stripe’s libraries default to a five-minute tolerance, and the documentation warns explicitly that a tolerance of 0 disables the recency check rather than tightening it.

// Check recency BEFORE the HMAC. It is the cheaper check, so a flood of
// stale replays costs almost nothing to reject.
var age = now - DateTimeOffset.FromUnixTimeSeconds(timestamp.Value);
if (age > window || age < -window)
    return Result.TimestampOutsideTolerance;

Note the two-sided check. Clock skew cuts both ways, and a far-future timestamp is just as suspect as an old one.

GitHub: HMAC over the body alone

X-Hub-Signature-256: sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46…

Same algorithm, no timestamp. Which means the signature never expires. A delivery captured today stays valid indefinitely, and the scheme offers no way to detect a replay.

That is not a defect so much as a division of responsibility: replay defence has to live in your handler.

One trap here — GitHub also sends a legacy SHA-1 header. Parse the algorithm prefix and reject anything that is not what you expect, or you have handed a caller the ability to downgrade you to the weaker hash:

if (!string.Equals(algorithm, algorithmPrefix, StringComparison.Ordinal))
    return Result.UnexpectedAlgorithm;

Moyasar: no signature at all

Saudi gateway Moyasar’s webhook reference documents no signature header and no HMAC. Authenticity rests on a secret_token field delivered inside the payload body, compared against the secret you set when registering the endpoint.

Three consequences, none of them stated in the documentation:

  1. You are comparing a secret, not verifying a signature — so the comparison must still be constant time.
  2. There is no replay protection whatsoever. TLS is the only thing preventing capture.
  3. Do not log the raw body. It contains your shared secret.

Anyone porting a Stripe handler will reach for HMAC verification and find nothing to verify. That is worth knowing before you start rather than during.

4. What the docs do not tell you

Secret rotation sends multiple signatures. A header can carry several entries under the same scheme — during rotation, one per active secret. Accept the delivery if any matches, or rotation becomes an outage:

// Deliberately does NOT short-circuit on first match: bailing early
// reintroduces a timing signal across the candidate list.
var matched = false;
foreach (var candidate in candidates)
{
    matched |= SignatureComparer.FixedTimeEquals(expected, candidate);
}

Scheme versions are not interchangeable. Stripe sends v0 for test events. Verifying against v1 must not silently accept a v0 entry.

A valid signature does not mean you should process the event. It proves the request is authentic and unmodified. It says nothing about whether you have already handled it. Providers retry aggressively — Moyasar retries a non-2xx six times over two hours — so a verified handler without deduplication will fulfil an order repeatedly. Verification and idempotency are separate problems and you need both.

5. Failure modes

Symptom Cause Fix
Signature never matches, secret is definitely right Verifying against a deserialised model Read raw bytes before binding
Works locally, fails behind a proxy Body modified in transit, or header reformatted Verify at the edge, or trim header parts
Fails intermittently under load Reading the body twice without buffering EnableBuffering(), rewind after reading
Everything fails after a secret rotation Only checking the first signature in the header Check all candidates for the scheme
Sporadic failures around deploys Clock drift between provider and host Two-sided tolerance; check NTP
Duplicate orders Assuming verified means new Deduplicate on event id

6. Production checklist

  • Verify against raw bytes, never a model
  • Constant-time comparison, hashed first so length does not leak
  • Reject unexpected algorithm prefixes explicitly
  • Two-sided timestamp tolerance; never 0
  • Accept any valid signature so rotation does not break you
  • Return 2xx before slow work, process out of band
  • Deduplicate on the provider’s event id, durably
  • Never log raw bodies where the scheme puts secrets in them
  • Alert on a rising rate of signature failures — it is either a misconfiguration or someone probing

Sources

All checked 27 July 2026.

The sample repository tests the GitHub implementation against that provider’s own published vector — payload Hello, World!, secret It's a Secret to Everybody. A self-consistent implementation can be confidently wrong; matching a vector the provider published means it interoperates.