VERIFYING SIGNED WEBHOOK REQUESTS: HMAC, JWT, AND TOKEN ROTATION

Verifying signed webhook requests: HMAC, JWT, and token rotation

Any endpoint that accepts scheduled webhooks must prove who sent the payload. A practical guide to HMAC-signed JWT delivery, verification code, and safe token rotation.

If your endpoint accepts unauthenticated POSTs, anyone who discovers the URL owns it — and if those POSTs trigger jobs, that’s a remote code execution by another name: forged “refund” events, double-fires that poison idempotency, or a flood that exhausts your queue. Authentication is not optional for scheduled webhooks. Here’s how Recurrence Cloud handles it and what your receiver should check.

How deliveries are signed

Recurrence Cloud signs every outbound call with a JWT (HS256) whose HMAC secret is your app’s API token. That’s the elegant part: the token you use to administer schedules is the same secret your receiver verifies against. There is no separate webhook secret to distribute, store, and eventually lose.

The JWT contains the schedule’s event — type, subject, and payload — plus standard claims (iss, iat, exp) so your receiver can tell not just what the event is, but that it could only have come from a holder of the token.

Verifying in your handler

Verify with your local API token — fail closed if anything doesn’t check out:

import { verify } from "hono/jwt";

app.post("/webhooks/recurrence", async (c) => {
  const token = c.req.header("Authorization")?.replace("Bearer ", "");
  let claims;
  try {
    claims = await verify(token!, [
      new TextEncoder().encode(process.env.API_TOKEN!),
    ]);
  } catch {
    return c.json({ error: "invalid signature" }, 401);
  }

  await handle(claims.event);
  return c.json({ ok: true });
});

Express/Node with jose:

import { jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.API_TOKEN!);
const { payload } = await jwtVerify(req.header("authorization")!.slice(7), secret);

What your receiver should check

  1. Signature first, business logic never. Unverified payloads should not even be parsed into domain objects.
  2. exp freshness. Signatures are short-lived by design — reject stale claims.
  3. Idempotency on event identity. Retries happen; key deduplication on the event id or (schedule, fire-time) so a redelivery is a no-op.
  4. Status code honesty. Return 2xx only when the work actually completed — that’s what your retry and activity trail reasons about.
  5. Constant-time comparison. Use a vetted JWT library (never hand-rolled HMAC comparison) so timing side channels aren’t a thing.

Token rotation without downtime

Because the signing secret is the API token, rotation is a rollout, not an outage:

  1. Generate a new API token in the dashboard (the old one keeps working until you revoke it).
  2. Update your receiver to accept both secrets during the transition window.
  3. Flip to the new token in the Recurrence Cloud API and deploy the new schedules as needed.
  4. Revoke the old token and drop the transition code.

The same “accept, switch, revoke” dance works when moving services — no mass re-registration of webhooks mid-migration.

The threat model, briefly

Recurrence Cloud’s receiver-side story reduces to one question: could anyone without the token have produced this request? With HS256 over the token you alone hold, the answer is no — and because every delivery is signed rather than IP-allowlisted, your endpoint stays safe even if your egress rules change.


Security is one leg of the triangle; see reliability and monitoring and the scheduled webhook guide for the other two.