Webhook Signature Validation
Signature Format
X-Signature: t=timestamp,v1=current_sig,v0=old_sig
Components:
t: Unix timestamp when sentv1: Current signature (HMAC SHA256)v0: Expiring signature (during rotation only)
Algorithm
signed_payload = timestamp + "." + request_body
expected_sig = HMAC-SHA256(signed_payload, webhook_secret)
Validation steps:
- Parse timestamp and v1
- Check timestamp (reject if >5 minutes)
- Compute expected signature
- Constant-time compare with v1
- During rotation, also check v0
C#
public static bool VerifyWebhook(
string payload,
string signatureHeader,
string secret,
int toleranceSeconds = 300)
{
var parts = signatureHeader
.Split(',')
.Select(p => p.Trim().Split('=', 2))
.Where(kv => kv.Length == 2)
.ToDictionary(kv => kv[0], kv => kv[1]);
if (!parts.TryGetValue("t", out var timestampStr) ||
!long.TryParse(timestampStr, out var timestamp) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > toleranceSeconds)
{
return false;
}
var signedPayload = $"{timestampStr}.{payload}";
var expectedSig = Convert.ToHexString(
HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret),
Encoding.UTF8.GetBytes(signedPayload)
)
).ToLowerInvariant();
return (parts.TryGetValue("v1", out var v1) &&
CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedSig),
Encoding.UTF8.GetBytes(v1)))
|| (parts.TryGetValue("v0", out var v0) &&
CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedSig),
Encoding.UTF8.GetBytes(v0)));
}
PHP
function verifyWebhook(
string $payload,
string $signatureHeader,
string $secret,
int $toleranceSeconds = 300
): bool {
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
$keyValue = explode('=', trim($part), 2);
if (count($keyValue) === 2) {
[$key, $value] = $keyValue;
$parts[$key] = $value;
}
}
if (!isset($parts['t'])) {
return false;
}
$timestamp = (int)$parts['t'];
if (abs(time() - $timestamp) > $toleranceSeconds) {
return false;
}
$signedPayload = $parts['t'] . '.' . $payload;
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);
return (isset($parts['v1']) && hash_equals($expectedSig, $parts['v1']))
|| (isset($parts['v0']) && hash_equals($expectedSig, $parts['v0']));
}
TypeScript
export function verifyWebhook(
payload: string,
signatureHeader: string,
secret: string,
toleranceSeconds: number = 300
): boolean {
const parts: Record<string, string> = {};
for (const part of signatureHeader.split(',')) {
const [key, value] = part.trim().split('=', 2);
if (key && value) {
parts[key] = value;
}
}
if (!parts.t) {
return false;
}
const timestamp = parseInt(parts.t, 10);
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - timestamp) > toleranceSeconds) {
return false;
}
const signedPayload = `${parts.t}.${payload}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
const constantTimeCompare = (a: string, b: string): boolean => {
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(
Buffer.from(a, 'utf-8'),
Buffer.from(b, 'utf-8')
);
};
return (parts.v1 !== undefined && constantTimeCompare(expectedSig, parts.v1))
|| (parts.v0 !== undefined && constantTimeCompare(expectedSig, parts.v0));
}
Kotlin
fun verifyWebhook(
payload: String,
signatureHeader: String,
secret: String,
toleranceSeconds: Int = 300
): Boolean {
val parts = signatureHeader
.split(',')
.mapNotNull { it.trim().split('=', limit = 2).takeIf { kv -> kv.size == 2 } }
.associate { it[0] to it[1] }
val timestamp = parts["t"]?.toLongOrNull() ?: return false
val currentTime = System.currentTimeMillis() / 1000
if (abs(currentTime - timestamp) > toleranceSeconds) return false
val expectedSig = hmacSha256("${parts["t"]}.$payload", secret)
return parts["v1"]?.let { constantTimeEquals(expectedSig, it) } == true ||
parts["v0"]?.let { constantTimeEquals(expectedSig, it) } == true
}
private fun hmacSha256(message: String, secret: String): String =
Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
}.doFinal(message.toByteArray()).joinToString("") { "%02x".format(it) }
private fun constantTimeEquals(a: String, b: String): Boolean =
a.length == b.length && a.indices.fold(0) { acc, i ->
acc or (a[i].code xor b[i].code)
} == 0