Webhook Signature Validation

Signature Format

X-Signature: t=timestamp,v1=current_sig,v0=old_sig

Components:

  • t: Unix timestamp when sent
  • v1: 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:

  1. Parse timestamp and v1
  2. Check timestamp (reject if >5 minutes)
  3. Compute expected signature
  4. Constant-time compare with v1
  5. 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