Back to Blog
Security

Webhook Signatures Are Converging on Standard Webhooks, and Moving to Public Keys

OpenAI, Anthropic, and more now sign webhooks with Standard Webhooks. Here is how the shared header format works, why an empty secret silently disables it, and what asymmetric ed25519 keys change for receivers.

WebhookVault Team · Webhook Infrastructure Experts8 min read
Close-up of colorful syntax-highlighted JavaScript on a dark screen, showing a getElementById addEventListener call and a fibonacci method

The signing secret that verified nothing

A receiver I reviewed in July had signature-verification code that passed every test and protected nothing. The handler read a WEBHOOK_SECRET environment variable, passed it to a verify helper, and rejected anything whose signature did not match. It had shipped months earlier. The catch was that the variable was never set in that environment, so the secret was an empty string, and the library happily computed an HMAC keyed on nothing. Every request that arrived with a signature derived from an empty key sailed through, which is to say every request an attacker bothered to forge.

On July 7th, 2026 the Standard Webhooks libraries merged a change that rejects empty signing secrets outright, titled plainly "forbid empty webhook secrets." It is a two-line guard. It also tells you how many receivers were running with the door open.

Every provider used to invent its own header

For years, verifying a webhook meant reading whichever dialect the sender picked. Stripe puts a timestamp and a hex signature in Stripe-Signature and asks you to sign the timestamp joined to the raw body. GitHub sends X-Hub-Signature-256 with a sha256= prefix and a hex digest over the body alone, no timestamp. Shopify hands you a base64 digest in its own header. Three senders, three signed strings, three encodings, and none of the code you wrote for one carried over to the next.

Standard Webhooks is the attempt to stop that. It is a small specification, maintained by the team behind Svix, that pins down which headers travel with a delivery, which string gets signed, and how the signature is encoded. It matters now because the providers adopting it are the ones landing in everyone's backlog this year. OpenAI verifies webhooks with the Standard Webhooks headers. Anthropic ships a whsec_ signing secret and a five-minute freshness window on Claude webhooks. Supabase, Twilio, and Vanta are on the list too. If you consume events from more than one of them, you now write the verification once.

What Standard Webhooks actually specifies

Three headers ride along with every delivery: webhook-id, a unique id for the message; webhook-timestamp, an integer unix timestamp in seconds; and webhook-signature, a space-delimited list of signatures. The signer builds one string from those parts and the body, joined with dots:

webhook-id.webhook-timestamp.raw-request-body

The symmetric mode signs that string with HMAC-SHA256. The secret is handed to you base64-encoded behind a whsec_ prefix, so you strip the prefix, base64-decode the rest into key bytes, and only then key the HMAC. Skipping the decode step is a common first-try bug: the signature gets computed over the ASCII of the secret instead of its bytes, and nothing matches. The signature header carries versioned entries like v1,base64sig, and it can list more than one so a provider can rotate keys without a flag day.

import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(rawBody: string, headers: Record<string, string>, secret: string): boolean {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  const header = headers['webhook-signature']
  if (!id || !timestamp || !header || !secret) return false

  // Reject anything outside a five-minute window before checking the signature.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (Number.isNaN(age) || age > 300) return false

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const signed = id + '.' + timestamp + '.' + rawBody
  const expected = createHmac('sha256', key).update(signed).digest('base64')

  // The header is a space-delimited list of version,signature pairs.
  return header.split(' ').some((entry) => {
    const [version, value] = entry.split(',')
    if (version !== 'v1' || !value) return false
    const a = Buffer.from(value)
    const b = Buffer.from(expected)
    return a.length === b.length && timingSafeEqual(a, b)
  })
}

Two details in there earn their keep. The timestamp is checked first, so a replayed body with a valid but old signature never reaches the comparison. And the comparison is timing-safe, which an earlier post on HMAC verification covers in depth if you want the long version of why == is a liability here.

The empty-secret trap, and why it hid so long

Go back to the opening incident. Why did nobody notice? Because an empty secret fails silently in the direction of "looks fine." The verify function does not throw. It computes a real HMAC, keyed on zero bytes, and rejects genuine deliveries whose signatures were made with the actual secret. So the endpoint returns 401 to the legitimate sender, the team debugging it sees "signature mismatch," assumes config drift on the sender's side, and adds a bypass "just for staging." The bypass outlives everyone's memory of why it exists. Meanwhile the one party who benefits is anyone who signs with the empty key on purpose.

The library fix closes the specific hole. It does not close the category. Any value your verifier will accept as a key but that an attacker can also guess or supply is the same bug wearing a different secret. Load the secret at boot, fail to start if it is missing, and never let request handling begin without it.

Symmetric secrets do not scale to public verification

HMAC has one structural limit: the same secret both signs and verifies. The sender holds it, every receiver holds it, and anyone holding it can forge a delivery that verifies perfectly. Inside one integration that is fine. It stops being fine the moment a secret has to live in more places than you can rotate at once, or when a marketplace wants third parties to verify events without being handed the ability to mint them.

That is the case Standard Webhooks answers with an asymmetric mode. The signer keeps an ed25519 private key, prefixed whsk_. It publishes the matching public key, prefixed whpk_. Receivers verify with the public key and cannot sign with it. A leaked public key is a non-event. Signatures in this mode carry a v1a version tag instead of v1, so a receiver can tell the two apart straight off the header.

Verifying an asymmetric signature

The signed string, the headers, and the timestamp check are identical to the symmetric flow. What changes is the last step. Instead of recomputing an HMAC and comparing, you verify an ed25519 signature against a public key you already hold. In practice you hand the whpk_ key to the same library and it picks ed25519 off the prefix.

import { Webhook } from 'standard-webhooks'

// The provider's published public key, e.g. "whpk_Mc3...".
const wh = new Webhook(process.env.WEBHOOK_PUBLIC_KEY)

export function handle(rawBody: string, headers: Record<string, string>) {
  // Throws if the timestamp is stale or the signature does not verify.
  const event = wh.verify(rawBody, {
    'webhook-id': headers['webhook-id'],
    'webhook-timestamp': headers['webhook-timestamp'],
    'webhook-signature': headers['webhook-signature'],
  })
  return event
}

The payoff shows up at key rotation. With HMAC, rotating means getting a new shared secret into the sender and every receiver during an overlap window, and any receiver you miss starts rejecting live traffic. With asymmetric keys the sender rotates its private key and publishes the new public one. Receivers pull the new public key on their own schedule, and nothing they ever held was a forging capability to begin with.

Migrating a receiver that already verifies HMAC

You do not have to rewrite anything on day one. A receiver that already does Stripe-style or GitHub-style verification keeps working, because those providers are not changing their headers. The move is additive, and it happens one integration at a time as each provider you consume adopts the spec.

The cheap first step is to stop hand-rolling the parse. Read the webhook-id, webhook-timestamp, and webhook-signature headers into a verifier and delete the per-provider branching you were maintaining. The second step, when a provider offers it, is to switch that integration's secret from a whsec_ shared secret to a whpk_ public key and let asymmetric verification take over. The signed-string construction, the timestamp window, and your idempotency handling on webhook-id do not move.

Where to start

Load your signing secret at boot and refuse to start without it. That single guard would have caught the incident this post opened with. Verify the timestamp before the signature so replays die cheaply. Read the standardized headers instead of one provider's dialect, and the next integration becomes a config change rather than a code change. And when a provider you depend on publishes a whpk_ key, take it. A verification credential that cannot forge is strictly better than one that can.

Frequently asked questions

Is Standard Webhooks a new signing algorithm I have to learn?

No. The symmetric mode is HMAC-SHA256, the same primitive Stripe, GitHub, and Shopify already use. What the spec standardizes is the parts around the algorithm: which three headers carry the id, timestamp, and signature, exactly which string gets signed, and that the encoding is base64. The asymmetric mode adds ed25519, but you consume it through the same verify call rather than implementing the curve yourself.

Why does an empty signing secret bypass verification instead of failing loudly?

Because an HMAC keyed on zero bytes is still a valid HMAC. The verifier computes it without error and compares, so nothing throws. The practical failure is that legitimate deliveries signed with the real secret get rejected, the mismatch gets misread as sender-side config drift, and someone adds a bypass to make staging work. The July 2026 libraries reject an empty secret at construction time so the misconfiguration stops the process instead of silently disabling the check.

When is the asymmetric mode worth the extra moving parts?

When the number of parties who verify is larger than the number who should be able to sign. A single service consuming one provider's events is fine on a shared secret. A marketplace whose third-party developers all need to verify order events, or any setup where the secret would otherwise be copied into more places than you can rotate in one motion, is where publishing a public key and keeping the private key in one place pays off.

Do I still need idempotency and replay checks if the signature verifies?

Yes. A valid signature proves the message came from the signer and was not altered. It says nothing about whether you have already processed it. The webhook-id header doubles as an idempotency key, and the timestamp window is what stops a captured-and-replayed valid request. Signature verification, timestamp tolerance, and deduplication on the id are three separate jobs, and you want all three.

Related posts