BlogEngineering

On-demand ISR revalidation with signed webhooks

Static pages that update the moment an editor hits publish: verifying an HMAC-signed webhook in a Next.js route, what to purge besides the page itself, why to always answer 200, and a crash we found in our own handler.

glokki team3 min read

glokki.com is served with ISR: every page is rendered once, cached at the edge, and refreshed in the background at most every 60 seconds. Visitors get static-site speed, but an editor who publishes a fix could wait up to a minute to see it.

On-demand revalidation removes that wait. The CMS calls your site when something is published, and your site purges exactly what changed. Here's the core of our handler, and one bug we found in it while writing this.

The setup

Two pieces work together:

  • A time-based fallback. The catch-all page route exports revalidate = 60. If a webhook is ever lost, pages still catch up within a minute.
  • A webhook. In the CMS, Settings → Visual Editor → Revalidation URL points at https://glokki.com/api/revalidate, with a shared secret. On every publish, the CMS sends a signed POST with the page's slug.

Step 1: verify the signature

Anyone can send a POST to a public URL, so the handler first checks that the request came from the CMS. Each request carries two headers: X-Editor-Timestamp and X-Editor-Signature, an HMAC-SHA256 of <timestamp>.<raw body> with the shared secret.

const timestamp = request.headers.get("X-Editor-Timestamp")
const signature = request.headers.get("X-Editor-Signature")
if (!timestamp || !signature) {
  return Response.json({ error: "Missing signature headers" }, { status: 401 })
}

const body = await request.text() // the raw body, before any JSON parsing
const expected = crypto
  .createHmac("sha256", secret)
  .update(`${timestamp}.${body}`)
  .digest("hex")

const signatureBuf = Buffer.from(signature)
const expectedBuf = Buffer.from(expected)
if (
  signatureBuf.length !== expectedBuf.length ||
  !crypto.timingSafeEqual(signatureBuf, expectedBuf)
) {
  return Response.json({ error: "Invalid signature" }, { status: 401 })
}

Two details matter:

  • Sign the raw body. Parse JSON and serialize it again, and key order or whitespace can change, so the signature stops matching. Read request.text() once, verify it, then JSON.parse it.
  • Compare in constant time, and check the length first. === on strings can leak how many leading characters matched. crypto.timingSafeEqual doesn't, but it throws when the two buffers differ in length.

That second point is the bug. Our handler called timingSafeEqual without the length check, so any request with a signature of the wrong length crashed it, and glokki.com answered 500 instead of 401. Nothing was ever purged by a bad request, so it wasn't a security hole. It was a crash any visitor could trigger, and noise in the logs. The integration guide had the check all along; our copy had lost it. A test now covers it:

it("rejects a signature of the wrong length with 401, not a crash", async () => {
  const res = await POST(request(JSON.stringify({ slug: "a" }), "short"))
  expect(res.status).toBe(401)
})

Step 2: purge what changed, and what lists it

const { slug } = JSON.parse(body) as { slug?: string }
const path = slug ? `/${slug}` : "/"

revalidateTag("cms-pages", "default") // every CMS fetch made by the SDK client
revalidatePath(path)                   // the page itself
if (/^\/+blog\//.test(path)) revalidatePath("/blog") // the index that lists it

The last line is the one that's easy to miss. Publishing a post changes the post, but also every page that lists posts, and the webhook only names the post. Without that line, a new post would reach /blog up to a minute late. If you have index pages, related-post lists or a home page that shows the latest items, purge them too.

Step 3: always answer 200 to what you don't handle

The revalidation URL is the CMS's one webhook to your site, and it carries more than page publishes, e.g. cache-tag invalidations and data-model purges, all signed the same way. The CMS treats any non-2xx answer as a failed delivery and tries again with exponential backoff, up to five attempts in total.

So a handler that returns 400 for "no slug in this payload" gets the same message five times, though it was never going to use it. Ours lets those payloads fall through to the tag purge and answers 200. The test suite checks that as well.

What we don't do (yet)

  • Reject old timestamps. The timestamp is signed, so it can't be changed, but we don't refuse a valid request replayed an hour later. Here the worst a replay can do is purge a page early, so we've left it out. For a webhook with side effects, refuse timestamps older than a few minutes.
  • Purge by tag per page. cms-pages purges every CMS fetch at once. For a site our size that's cheaper than tracking which page uses which data. On a large site, tag fetches per page and purge only what changed.

Try it

curl is enough to see the signature check work:

curl -i -X POST https://your-site.com/api/revalidate \
  -H "X-Editor-Timestamp: 1" -H "X-Editor-Signature: nope" -d '{}'
# → 401 {"error":"Invalid signature"}

The full handler and the CMS settings are in the integration guide.

To see a publish reach your site without the 60-second wait, create a free account.

More from the blog

Publish something that shows up instantly

Free during beta · No credit card required