laravelvalidationwebhooksxero

Xero Webhooks Intent to Receive - Laravel


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!


Solution

  • 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);
        }
    }