Securing Strapi Webhooks for Revalidation

This guide, part of Strapi Self-Hosted Setup, connects Strapi’s webhooks to a frontend’s on-demand revalidation and makes the connection safe. It covers authenticating webhook requests with a secret header, subscribing only to events that change public content, mapping payloads to cache tags, revalidating pages that show related entries, and a backstop for deliveries that never arrive.

Strapi sends webhooks as plain JSON POST requests. Unlike some hosted CMSs, it does not sign the body, so the receiving endpoint cannot verify a signature. What Strapi does support is custom headers per webhook, and default headers for all webhooks in the server configuration. A long random secret in a header, compared in constant time on the frontend, gives the endpoint a reliable way to reject requests that did not come from Strapi. Combined with HTTPS, that is the standard way to secure Strapi webhooks.

From publish to revalidated pagesAn editor publishes an entry; Strapi sends an entry.publish webhook with the secret header; the frontend endpoint checks the header in constant time, rejects mismatches with 401, maps the model and entry to cache tags including related entries, revalidates them and answers 200.entry.publishWebhooksecret headerHeader valid?401Map to tagsentry, list, relationsrevalidateTagnoyes
The header decides whether to trust the request; the payload decides what to revalidate.

The Problem

A travel site’s revalidation endpoint accepted any POST request and revalidated whatever path was in the body. A crawler that followed a leaked URL from an error log started posting to it, and each request revalidated the homepage, which triggered expensive rendering and data fetching. At the same time, the webhook in Strapi was subscribed to entry.update, so every draft save by an editor revalidated published pages that had not changed. The frontend’s origin load doubled during working hours, and the team could not tell real publishes from noise in its logs.

How to Secure and Scope the Webhook

Add a secret header. In Settings > Webhooks, add a header such as x-webhook-secret with a long random value. For several webhooks, define it once as a default header in config/server.ts, read from an environment variable, so the value never sits in the admin panel’s database export.

Subscribe to publish events. With Draft & Publish enabled, choose entry.publish, entry.unpublish and entry.delete. Saving a draft sends entry.update, which does not change what readers see. Add media.update and media.delete if pages embed files that editors replace.

Check the header in constant time. Compare the header with the expected value using a timing-safe comparison, and return 401 before parsing the body.

Map the payload to tags. The payload contains the event, the model, the entry and, in Strapi 5, the uid. Map them to the same tags the frontend’s fetches use.

Plan for missed deliveries. Strapi sends each webhook once and does not queue failed deliveries for later. Keep a scheduled job that revalidates entries published recently, so a failed delivery delays an update instead of losing it.

Webhook events and whether to subscribeFor Strapi webhook events, whether they change what readers see when Draft and Publish is enabled, and whether a revalidation webhook should subscribe to them.EventChanges public pages?Subscribe?entry.createno, draft onlynoentry.updateno, draft onlynoentry.publishyesyesentry.unpublishyesyesentry.deleteyesyesmedia.updateif files replacedwhen pages embed replaced files
With Draft and Publish, only publish, unpublish and delete change public pages.

Implementation

Define the secret header for all webhooks in the server configuration:

TypeScript
// config/server.ts (excerpt)
export default ({ env }) => ({
  host: env("HOST", "0.0.0.0"),
  port: env.int("PORT", 1337),
  app: { keys: env.array("APP_KEYS") },
  webhooks: {
    defaultHeaders: {
      "x-webhook-secret": env("WEBHOOK_SECRET"),
    },
  },
});

The frontend endpoint checks the header, filters events and revalidates tags:

TypeScript
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { timingSafeEqual } from "node:crypto";

type StrapiWebhook = {
  event: string;
  model: string;
  uid?: string;
  entry?: { documentId?: string; id?: number; slug?: string; locale?: string; [k: string]: unknown };
};

const HANDLED = new Set(["entry.publish", "entry.unpublish", "entry.delete", "media.update", "media.delete"]);

// Which models appear on list pages of other models.
const REFERENCED_BY: Record<string, string[]> = {
  author: ["article"],
  category: ["article"],
};

function validSecret(got: string | null) {
  const a = Buffer.from(got ?? ""), b = Buffer.from(process.env.WEBHOOK_SECRET!);
  return a.length === b.length && timingSafeEqual(a, b);
}

export async function POST(req: Request) {
  if (!validSecret(req.headers.get("x-webhook-secret"))) return new Response("unauthorized", { status: 401 });

  const body = (await req.json()) as StrapiWebhook;
  if (!HANDLED.has(body.event)) return Response.json({ ignored: body.event });

  const tags = new Set<string>();
  if (body.event.startsWith("media.")) {
    tags.add("media");
  } else {
    const id = body.entry?.documentId ?? String(body.entry?.id);
    tags.add(`${body.model}:${id}`);
    tags.add(`${body.model}:list`);
    for (const parent of REFERENCED_BY[body.model] ?? []) tags.add(`${parent}:list`);
    if (body.entry?.locale) tags.add(`${body.model}:list:${body.entry.locale}`);
  }
  for (const t of tags) revalidateTag(t);

  console.log(JSON.stringify({ kind: "strapi_revalidate", event: body.event, model: body.model, tags: [...tags] }));
  return Response.json({ revalidated: [...tags] });
}

The frontend’s fetches carry matching tags: an article page tags its fetch with article:{documentId} and the tags of its author and categories, and listings tag theirs with article:list. With that mapping, publishing an author revalidates every article list that shows authors, and each article page that includes the author through its own tag.

Relations and fan-out

The hardest part of revalidation is not the entry itself but the pages that display it. An author’s name appears on every article they wrote; a category’s title appears on listings and breadcrumbs. Tag each page’s fetch with the ids of related entries it renders, such as author:{documentId}, and revalidate those tags when the related model is published. That keeps revalidation precise without a full list of dependent pages in the handler. For shared singletons such as global navigation or a footer, a single tag on every page is simpler: publishing the navigation revalidates all pages, which is acceptable because it happens rarely.

Rotating the secret

Accept two secrets in the handler during rotation: compare against the current and the previous value. Change the environment variable in Strapi, restart it, confirm that deliveries succeed with the new value, then remove the old one from the frontend.

Configuration Reference

Item Recommendation Why
Authentication secret header, constant-time check Strapi does not sign bodies.
Secret location webhooks.defaultHeaders from env Not stored in the admin database.
Events publish, unpublish, delete Draft saves do not reach readers.
Transport HTTPS only Header cannot be read in transit.
Tags entry, list, related entries Precise revalidation.
Backstop scheduled revalidation of recent publishes Failed deliveries are not lost.
Logging event, model, tags; no bodies Traceable without leaking content.

Gotchas & Edge Cases

  • Draft & Publish disabled. Without Draft & Publish, saves are immediately public and send entry.update; subscribe to create, update and delete instead.
  • Bulk publishing. Publishing many entries sends many webhooks in a burst. Tag revalidation is cheap, but a build hook is not; debounce build triggers.
  • Deleted entries. Delete payloads still contain the entry’s fields as they were, so the slug is available for path-based revalidation.
  • Locales. Each locale is published separately and sends its own webhook, with the locale in the entry; revalidate per locale.
  • Endpoint exposure. Rate-limit the endpoint and keep it off public sitemaps; the secret protects it, but noise still costs.

Worked Example

The travel site added a secret header through webhooks.defaultHeaders, switched the subscription from entry.update to publish, unpublish and delete, and replaced path revalidation from the body with tag mapping. Requests from the crawler were rejected with 401 before any work was done, and the revalidation volume fell to the number of real publishes. Origin load during working hours returned to its earlier level, and logs now list one line per publish, which made a later investigation of a stale page take minutes instead of hours.

Revalidations per working dayRevalidation requests handled per working day with an open endpoint subscribed to entry.update, and with a secret header and publish events only.Open, entry.update5200 revalidations per daySecret, publish events140 revalidations per day
Filtering events and rejecting unauthenticated requests left only real publishes.

Testing the Endpoint

Webhook handlers are easy to test because the whole contract is a header and a JSON body. Keep fixture payloads for each handled event and model, captured from a development instance, and write tests that send them with the right secret, with a wrong secret and without one. Assert the status codes and the set of tags revalidated, with revalidateTag mocked. For end-to-end checks, point a development Strapi at a tunnel to the local frontend, publish an entry and confirm the log line. When the content model changes, such as a new relation that appears on article pages, add the model to the mapping and a fixture for it in the same pull request, so revalidation keeps pace with the model instead of drifting behind it.

Rollout Checklist

  • Add a secret header through webhooks.defaultHeaders, read from the environment.
  • Subscribe to publish, unpublish and delete events only.
  • Check the header in constant time before parsing the body.
  • Map payloads to entry, list and relation tags that match the frontend’s fetches.
  • Schedule a backstop that revalidates recent publishes.
  • Test with fixtures for valid, invalid and missing secrets.

Frequently Asked Questions

Why not verify a signature?

Strapi does not sign webhook bodies. A secret header over HTTPS is the supported mechanism; add IP allow-listing if Strapi has a fixed egress address.

Can the secret be in the URL instead?

It works, but URLs end up in logs of proxies and load balancers. Headers are logged far less often, so prefer them for any secret value.

Does Strapi retry failed webhooks?

Do not rely on it. Treat each delivery as possibly lost and keep the scheduled backstop. Failed deliveries show up in Strapi’s server logs, so alert on them as well.

Should the handler trigger a full rebuild?

Only for static sites without on-demand revalidation, and then with debouncing, so a bulk publish of dozens of entries becomes a single build.