Skip to content
← Back to Functions
Code

Webhook Replay Guard

Checks timestamp, nonce, and signature metadata to prevent webhook replay attacks.

Function signature

ogGuardWebhookReplay(event = array(), options = array())

Categories

  • Security

Parameters

eventWebhook metadata and raw body. Recognized keys: `event_id`, `nonce`, `raw_body`, `signature`, `timestamp`.optionsSecret, tolerance, and seen event/nonce lists. Recognized keys: `secret`, `seen_event_ids`, `seen_nonces`, `tolerance_seconds`.

Return value

Short public-safe status message.

  • allow
  • event_id
  • nonce
  • timestamp

Compatibility

Existing function name and call order preserved; metadata signature corrected to source.

Minimum PHP version: 7.4

Security notes

Validate request method, identity, permissions, policy arrays, URLs, signatures, and caller-owned allowlists before use; keep secrets and internal paths out of public output.

Code

<?php

/*
 * Copyright (c) 2026 Jeffery L. Paris <jparis@phpog.com>.
 * Free for personal and internal use. Paid project use requires visible credit
 * to Jeffery L. Paris. Corporate use requires a paid license fee unless a
 * separate written license states otherwise.
 */

/**
 * Checks webhook replay metadata before processing an inbound event.
 *
 * The caller supplies previously seen event IDs/nonces from durable storage.
 * This function does not trust timestamp, nonce, or signature data alone.
 *
 * @param array $event Webhook metadata and raw body.
 * @param array $options Secret, tolerance, and seen event/nonce lists.
 * @return array Replay guard decision.
 */
function ogGuardWebhookReplay($event = array(), $options = array()) {
	$result = array(
		'success' => false,
		'message' => '',
		'data' => array()
	);

	if (!is_array($event)) {
		$result['message'] = 'Webhook event must be an array.';
		return $result;
	}

	if (!is_array($options)) {
		$options = array();
	}

	$event_id = '';
	if (!empty($event['event_id'])) {
		$event_id = trim((string)$event['event_id']);
	}

	$nonce = '';
	if (!empty($event['nonce'])) {
		$nonce = trim((string)$event['nonce']);
	}

	$timestamp = 0;
	if (!empty($event['timestamp'])) {
		$timestamp = (int)$event['timestamp'];
	}

	$signature = '';
	if (!empty($event['signature'])) {
		$signature = trim((string)$event['signature']);
	}

	$raw_body = '';
	if (array_key_exists('raw_body', $event)) {
		$raw_body = (string)$event['raw_body'];
	}

	$secret = '';
	if (!empty($options['secret'])) {
		$secret = (string)$options['secret'];
	}

	$tolerance = 300;
	if (!empty($options['tolerance_seconds'])) {
		$tolerance = (int)$options['tolerance_seconds'];
	}
	if ($tolerance < 30) {
		$tolerance = 30;
	}

	if (empty($event_id) || empty($nonce) || empty($timestamp) || empty($signature) || empty($secret)) {
		$result['message'] = 'Webhook replay guard metadata is incomplete.';
		return $result;
	}

	if (abs(time() - $timestamp) > $tolerance) {
		$result['message'] = 'Webhook timestamp is outside the accepted window.';
		return $result;
	}

	$seen_event_ids = array();
	if (!empty($options['seen_event_ids']) && is_array($options['seen_event_ids'])) {
		$seen_event_ids = $options['seen_event_ids'];
	}
	$seen_nonces = array();
	if (!empty($options['seen_nonces']) && is_array($options['seen_nonces'])) {
		$seen_nonces = $options['seen_nonces'];
	}

	if (in_array($event_id, $seen_event_ids, true)) {
		$result['message'] = 'Webhook event ID has already been seen.';
		return $result;
	}
	if (in_array($nonce, $seen_nonces, true)) {
		$result['message'] = 'Webhook nonce has already been seen.';
		return $result;
	}

	$base = $timestamp . '.' . $nonce . '.' . $raw_body;
	$expected = hash_hmac('sha256', $base, $secret);
	if (!hash_equals($expected, $signature)) {
		$result['message'] = 'Webhook signature mismatch.';
		return $result;
	}

	$result['success'] = true;
	$result['message'] = 'Webhook replay guard passed.';
	$result['data'] = array(
		'allow' => true,
		'event_id' => $event_id,
		'nonce' => $nonce,
		'timestamp' => $timestamp
	);

	return $result;
}