For the Xero webhooks, one needs to validate the hashed payload against the signature in the header. This should be simple, but I cannot get the hashed and base64 encoded payload to match the signature (as per their docs: https://developer.xero.com/documentation/guides/webhooks/configuring-your-server/#intent-to-receive)
My code:
<?php
namespace App\Http\Controllers\Xero;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class ContactsController extends Controller
{
const WEBHOOKKEY = '+3mCdl4IykKfH+Y9CZTy5TpvqB4ir3RsdwJmdpKXnR9xCTIQrKCQAKYii5IioTaujGYojiKfV7IUu7cywypuSg==';
public function index(Request $request)
{
$signature = $request->header('x-xero-signature');
$hash = base64_encode(hash_hmac('sha256', $request->getContent(), self::WEBHOOKKEY)); // returns a string much longer than the signature
// $hash = base64_encode(hash('sha256', $request->getContent(), self::WEBHOOKKEY)); // returns correct length, but never matches
if ($signature === $hash) {
return response('OK', 200);
}
return response('Unauthorized', 401);
}
}
This has driven me nuts for days... Any suggestions would be appreciated!
Binary Output for HMAC: Added true as the third argument to hash_hmac to get the raw binary output instead of a hexadecimal string. Xero expects the binary HMAC result to be Base64-encoded.
Raw Payload: Ensure $request->getContent() returns the raw, unaltered payload. Laravel's getContent() typically handles this correctly, but double-check that no middleware is modifying the payload (e.g., trimming or encoding).
Secure Comparison: Used hash_equals for comparing the signature and calculated hash to prevent timing attacks.
No Key Decoding: The webhook key is used as-is (Base64-encoded) since Xero provides it in this format for HMAC calculation.
<?php
namespace App\Http\Controllers\Xero;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class ContactsController extends Controller
{
const WEBHOOKKEY = '+3mCdl4IykKfH+Y9CZTy5TpvqB4ir3RsdwJmdpKXnR9xCTIQrKCQAKYii5IioTaujGYojiKfV7IUu7cywypuSg==';
public function index(Request $request)
{
// Get the signature from the header
$signature = $request->header('x-xero-signature');
// Calculate HMAC-SHA256 of the raw payload
$payload = $request->getContent();
$hash = hash_hmac('sha256', $payload, self::WEBHOOKKEY, true); // true for binary output
$encodedHash = base64_encode($hash);
// Compare the calculated hash with the signature
if (hash_equals($signature, $encodedHash)) {
return response('OK', 200);
}
return response('Unauthorized', 401);
}
}