WordPress Tagged Revalidation
Per-object cache-tag invalidation between WordPress and Next.js/Astro — save in the CMS, and only that object cache entry purges, verified end to end.
cp revalidate-hooks.php wp-content/mu-plugins/Headless WordPress without the regret argues that time-based revalidation is a guess and that every fetch should be tagged with the content object it came from. This is that pattern, written out in full: the WordPress side that fires on save, and the receiving route that verifies the request is genuinely from that WordPress install before it purges anything.
Nothing here is exotic cryptography — it’s the boring, well-understood shape (HMAC + timestamp) applied carefully. The three guards below (signature verification, constant-time comparison, replay window) are exactly the details that get skipped when this is thrown together under deadline, and exactly the details that turn “cache invalidation webhook” into “unauthenticated cache-poisoning endpoint” if they’re missing.
Why the WordPress side signs every request
The mu-plugin hooks save_post, deleted_post, and the three term-edit hooks (created_term, edited_term, delete_term), and on each one POSTs a single tag — post_type:id or taxonomy:term_id — to a revalidation endpoint. The request carries an HMAC-SHA256 signature computed over the timestamp and body, using a secret that lives only in wp-config.php and the front end’s environment variables, never in the request itself. Anyone can see the endpoint URL; nobody can forge a valid signature without the secret.
The wp_remote_post() call is non-blocking ('blocking' => false). An editor hitting Publish should never wait on the front end’s cache layer — if the revalidation call is slow or the endpoint is briefly down, the save still completes instantly, and the object is simply revalidated a little late.
Why the comparison is constant-time
The route handler recomputes the expected signature and compares it to the one on the request with crypto.timingSafeEqual, not === or ==. A naive string comparison returns as soon as it finds the first mismatched character, which means it takes measurably longer to reject a signature that gets the first sixteen characters right than one that gets none right. Over enough requests an attacker can use that timing difference to recover the correct signature one byte at a time — a fully practical attack over a network, not a theoretical one. timingSafeEqual always compares every byte, so the response time carries no information about how close a forged signature was to correct.
Two details make this safe rather than merely present: the buffers are checked for equal length before the constant-time compare runs (timingSafeEqual throws on a length mismatch, and the length check itself is fine to do in variable time — length isn’t the secret), and the expected signature is always recomputed from the request body rather than trusted from anywhere else.
Why there’s a replay window
A valid, correctly-signed request captured off the wire once — by a logging proxy, a misconfigured CDN, a browser extension, doesn’t matter how — is otherwise valid forever. Signing the payload stops forgery; it does nothing about replay. The handler also rejects any request whose X-Revalidate-Timestamp is more than 60 seconds away from the server’s clock, in either direction. That turns a captured request into a token usable for about a minute instead of a token usable indefinitely, at the cost of requiring the WordPress host and the front end to have roughly synchronized clocks — true of essentially every real host today (NTP is not optional infrastructure).
The window is deliberately generous rather than tight: 60 seconds absorbs normal clock drift and slow DNS/TLS handshakes on the WordPress side without meaningfully widening the replay opportunity for an attacker who doesn’t already have the secret.
Files
<?php
/**
* Plugin Name: Tagged Revalidate
* Description: Notifies the front end to purge its cache tag for a single
* content object whenever that object changes, instead of
* relying on time-based revalidation.
* Author: Alexander Talaat
*/
if (!defined('ABSPATH')) {
exit;
}
// Set in wp-config.php:
// define('REVALIDATE_ENDPOINT', 'https://example.com/api/revalidate');
// define('REVALIDATE_SECRET', getenv('REVALIDATE_SECRET'));
/**
* Sign and send one tag invalidation. Fire-and-forget on purpose — an
* editor's save should never block on the front end's cache layer.
*/
function tr_send_revalidation(string $tag): void
{
if (!defined('REVALIDATE_SECRET') || REVALIDATE_SECRET === '' || !defined('REVALIDATE_ENDPOINT')) {
error_log('[tagged-revalidate] REVALIDATE_SECRET or REVALIDATE_ENDPOINT is not set — skipping.');
return;
}
$timestamp = (string) time();
$payload = wp_json_encode(['tag' => $tag]);
$signature = hash_hmac('sha256', $timestamp . '.' . $payload, REVALIDATE_SECRET);
wp_remote_post(REVALIDATE_ENDPOINT, [
'timeout' => 5,
'blocking' => false,
'headers' => [
'Content-Type' => 'application/json',
'X-Revalidate-Timestamp' => $timestamp,
'X-Revalidate-Signature' => $signature,
],
'body' => $payload,
]);
}
/** Skip revisions, autosaves, and anything not actually published. */
function tr_tag_for_post(int $post_id): ?string
{
$post = get_post($post_id);
if (!$post || $post->post_status !== 'publish') {
return null;
}
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return null;
}
return $post->post_type . ':' . $post_id;
}
add_action('save_post', function (int $post_id): void {
$tag = tr_tag_for_post($post_id);
if ($tag !== null) {
tr_send_revalidation($tag);
}
});
add_action('deleted_post', function (int $post_id, WP_Post $post): void {
tr_send_revalidation($post->post_type . ':' . $post_id);
}, 10, 2);
foreach (['created_term', 'edited_term', 'delete_term'] as $hook) {
add_action($hook, function (int $term_id, int $tt_id, string $taxonomy): void {
tr_send_revalidation($taxonomy . ':' . $term_id);
}, 10, 3);
}import { createHmac, timingSafeEqual } from 'node:crypto';
import { revalidateTag } from 'next/cache';
const SECRET = process.env.REVALIDATE_SECRET;
const MAX_SKEW_SECONDS = 60;
interface RevalidatePayload {
tag: string;
}
export async function POST(request: Request): Promise<Response> {
if (!SECRET) {
// Fail closed: an unconfigured secret must never be treated as "no
// verification required."
return new Response('Revalidation is not configured', { status: 500 });
}
const signature = request.headers.get('x-revalidate-signature');
const timestamp = request.headers.get('x-revalidate-timestamp');
if (!signature || !timestamp) {
return new Response('Missing signature', { status: 401 });
}
// Reject stale (or replayed) requests before doing anything else — this
// is what turns a captured signature into a request usable for about a
// minute instead of a request usable forever.
const requestTime = Number(timestamp);
const skew = Math.abs(Date.now() / 1000 - requestTime);
if (!Number.isFinite(requestTime) || skew > MAX_SKEW_SECONDS) {
return new Response('Stale request', { status: 401 });
}
const body = await request.text();
const expected = createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest();
let provided: Buffer;
try {
provided = Buffer.from(signature, 'hex');
} catch {
return new Response('Malformed signature', { status: 401 });
}
// timingSafeEqual throws on a length mismatch rather than returning
// false, and a naive `===` on hex strings leaks timing information
// proportional to how many leading characters match — enough for an
// attacker to recover a valid signature byte by byte over many requests.
// Check length first (safe to do in variable time; length isn't secret),
// then compare in constant time.
if (provided.length !== expected.length || !timingSafeEqual(expected, provided)) {
return new Response('Invalid signature', { status: 401 });
}
let payload: RevalidatePayload;
try {
payload = JSON.parse(body);
} catch {
return new Response('Invalid payload', { status: 400 });
}
if (!payload.tag) {
return new Response('Missing tag', { status: 400 });
}
revalidateTag(payload.tag);
return Response.json({ revalidated: true, tag: payload.tag });
}/**
* Typed fetch wrapper for the WordPress REST API that attaches a cache tag
* per content object, matching the tags the mu-plugin invalidates by.
* Pass `revalidate: false` for content that's only ever invalidated by tag.
*/
interface WpFetchOptions extends Omit<RequestInit, 'cache'> {
tags?: string[];
revalidate?: number | false;
}
const WORDPRESS_API_URL = process.env.WORDPRESS_API_URL;
export async function wpFetch<T>(path: string, options: WpFetchOptions = {}): Promise<T> {
if (!WORDPRESS_API_URL) {
throw new Error('WORDPRESS_API_URL is not set');
}
const { tags = [], revalidate = 3600, ...init } = options;
const url = new URL(path, WORDPRESS_API_URL);
const response = await fetch(url, {
...init,
next: { tags, revalidate }
});
if (!response.ok) {
throw new Error(`WordPress fetch failed: ${response.status} ${response.statusText} — ${url}`);
}
return response.json() as Promise<T>;
}
export interface WpPost {
id: number;
slug: string;
title: { rendered: string };
content: { rendered: string };
modified: string;
}
/** Tagged `post:<slug>` — invalidated the moment this post is saved or trashed. */
export async function getPostBySlug(slug: string): Promise<WpPost | undefined> {
const posts = await wpFetch<WpPost[]>(`/wp-json/wp/v2/posts?slug=${encodeURIComponent(slug)}&_embed=1`, {
tags: [`post:${slug}`]
});
return posts[0];
}The tag naming convention (post_type:id, taxonomy:term_id) is deliberately the same on both sides — the PHP side never needs to know how the front end routes a URL to that object, and the front end never needs to know the PHP side’s internal post IDs versus slugs. It only needs to tag every fetch for an object with the same string the plugin sends when that object changes.
Alexander built this resource for a real project first — the write-up covers why it's shaped the way it is.
No spam. One or two emails a month, unsubscribe anytime.