Verify webhook signatures before you trust a goal

Your webhook URL is public. Without a signature check, anyone who learns the URL posts fake goals into your app. Six lines of code stop them.

GOAL API· Engineering

1 min read

Your webhook URL is not a secret

Your endpoint URL appears in logs, proxies, error trackers, and browser history. Treat the URL as public.

Anyone who learns the URL posts whatever they like to your app. Fake goals. Fake final scores. Settled bets on matches nobody played.

The signature proves a delivery came from us. Checking the signature takes 6 lines.

What we send

Every delivery carries a header:

X-Goal-Signature: t=1736899200,v1=5257a869e7ecebeda32affa62cdca3fa...
  • t is the unix timestamp when we signed the payload
  • v1 is an HMAC SHA256 of the timestamp and the raw body, keyed with your signing secret

Verify in Node

import crypto from "crypto";

app.post("/webhooks/goal-api",
  // The RAW bytes. A re-serialised body will not match the signature.
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.get("X-Goal-Signature");
    const [t, v1] = header.split(",").map((p) => p.split("=")[1]);

    const expected = crypto
      .createHmac("sha256", process.env.GOAL_WEBHOOK_SECRET)
      .update(t + "." + req.body)
      .digest("hex");

    const valid = crypto.timingSafeEqual(
      Buffer.from(v1, "hex"),
      Buffer.from(expected, "hex")
    );
    if (!valid) return res.sendStatus(401);

    if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(401);

    res.sendStatus(200);
    handle(JSON.parse(req.body));
  }
);

Four mistakes to avoid

1. Comparing with ===

A plain string comparison exits at the first wrong byte. Timing differences leak the correct signature to anyone patient enough to measure.

Use crypto.timingSafeEqual. Python has hmac.compare_digest. Most languages ship an equivalent.

2. Signing a parsed body

JSON.parse then JSON.stringify changes key order and whitespace. Your HMAC will never match.

Sign the raw bytes. In Express, mount express.raw on the webhook route only, before any global express.json.

3. Skipping the timestamp check

Signature checks alone stop forgery. Replay is a different attack.

An attacker who captures one real delivery resends the exact bytes forever. The signature stays valid because the payload never changed.

Reject anything older than 5 minutes. The timestamp sits inside the signed material, so an attacker cannot edit the timestamp without breaking the signature.

4. Logging the secret

Your signing secret ends up in a log aggregator, then in a support ticket. Read the secret from the environment. Never print the secret, even at debug level.

Rotate when in doubt

curl -X POST https://api.goal-api.com/v1/auth/webhook-endpoints/ENDPOINT_ID/rotate-secret \
  -H "Authorization: Bearer $GOAL_API_KEY"

Rotate after any laptop goes missing, any contractor leaves, or any secret lands in a shared channel.

Keep reading

Verify webhook signatures before you trust a goal | GOAL API