P
imvdmolen.nl
Blog

Waarom ik altijd HMAC signatures controleer bij webhook endpoints in PHP

Webhook endpoints zijn een geliefd doelwit voor aanvallers. Een e-commerce platform dat plotseling duizenden valse bestellingen ontvangt binnen enkele minuten is een klassieker: de aanvaller heeft het endpoint gevonden en stuurt massaal nep-berichten. Het onderliggende probleem is vrijwel altijd hetzelfde: er wordt niet gecontroleerd of de berichten daadwerkelijk van de verwachte bron komen. HMAC signature verificatie lost dit direct op.

Het principe van HMAC signatures begrijpen

Hash-based Message Authentication Code werkt volgens een simpel principe. De verzender gebruikt een geheim dat alleen hij en de ontvanger kennen om een hash te genereren van de bericht inhoud. Deze hash wordt meegestuurd met het bericht. De ontvanger genereert vervolgens dezelfde hash met hetzelfde geheim en vergelijkt beide hashes. Als ze identiek zijn, weet je zeker dat het bericht van de juiste verzender komt en niet is aangepast.

function generateHmacSignature($payload, $secret) {
    return hash_hmac('sha256', $payload, $secret);
}

$payload = '{"order_id": 12345, "status": "completed"}';
$secret = 'mijn-super-geheime-webhook-key';
$signature = generateHmacSignature($payload, $secret);

echo $signature; // 8f7e4... (64 karakters)

Moderne payment providers zoals Stripe, Mollie en PayPal gebruiken allemaal dit systeem. Ze sturen de signature mee in een HTTP header, meestal als X-Signature of Signature. Sommige providers gebruiken een prefix zoals sha256= voor de signature om het gebruikte algoritme aan te geven. Dit maakt het voor ontwikkelaars duidelijk welke hash functie gebruikt is.

De kracht van HMAC zit in het feit dat je de secret nooit over het netwerk hoeft te versturen. Beide partijen kennen de secret vooraf, meestal verkregen via de provider's dashboard of API. Aanvallers kunnen wel de signature zien in network logs, maar zonder de secret kunnen ze geen geldige signatures genereren voor hun eigen berichten.

Implementatie in Laravel controllers

class WebhookController extends Controller
{
    public function handlePaymentWebhook(Request $request)
    {
        $payload = $request->getContent();
        $signature = $request->header('X-Signature');
        $secret = config('services.payment.webhook_secret');
        
        if (!$this->verifyHmacSignature($payload, $signature, $secret)) {
            Log::warning('Invalid webhook signature', [
                'ip' => $request->ip(),
                'user_agent' => $request->userAgent(),
                'payload_length' => strlen($payload)
            ]);
            
            return response('Invalid signature', 401);
        }
        
        $data = json_decode($payload, true);
        // Verwerk de webhook data...
        
        return response('OK', 200);
    }
    
    private function verifyHmacSignature($payload, $receivedSignature, $secret)
    {
        // Strip prefix indien aanwezig (bijv. "sha256=")
        $receivedSignature = str_replace('sha256=', '', $receivedSignature);
        
        $expectedSignature = hash_hmac('sha256', $payload, $secret);
        
        // Gebruik hash_equals voor timing-attack beveiliging
        return hash_equals($expectedSignature, $receivedSignature);
    }
}

Een cruciaal detail is het gebruik van hash_equals() in plaats van gewone string vergelijking. Normale vergelijking stopt zodra het eerste verschillende karakter wordt gevonden, wat timing-based attacks mogelijk maakt. Aanvallers kunnen microseconde verschillen in response tijd gebruiken om karakter voor karakter de juiste signature te raden. hash_equals() neemt altijd exact dezelfde tijd, ongeacht waar de strings verschillen.

Laravel's Request object biedt getContent() om de raw body te krijgen, wat essentieel is voor signature verificatie. Als je $request->all() of $request->json() gebruikt, heeft Laravel de data al geparsed en mogelijk gemodificeerd, wat een andere hash oplevert dan wat de verzender heeft berekend. Werk altijd met de originele, ongeparsde payload.

Logging van gefaalde verificaties is belangrijk voor security monitoring. Ik log altijd het IP adres en user agent van verdachte requests. Patterns in deze logs kunnen wijzen op systematische aanvallen. Soms ontdek ik zo dat bots random webhook URLs proberen te raden door veel verschillende endpoints aan te roepen.

Verschillende provider formaten hanteren

class WebhookSignatureVerifier
{
    public static function verifyStripe($payload, $signature, $secret)
    {
        $elements = explode(',', $signature);
        $signatureHash = null;
        
        foreach ($elements as $element) {
            if (strpos($element, 'v1=') === 0) {
                $signatureHash = substr($element, 3);
                break;
            }
        }
        
        if (!$signatureHash) {
            return false;
        }
        
        $expectedSignature = hash_hmac('sha256', $payload, $secret);
        return hash_equals($expectedSignature, $signatureHash);
    }
    
    public static function verifyGitHub($payload, $signature, $secret)
    {
        $signature = str_replace('sha256=', '', $signature);
        $expectedSignature = hash_hmac('sha256', $payload, $secret);
        
        return hash_equals($expectedSignature, $signature);
    }
    
    public static function verifyMollie($payload, $signature, $secret)
    {
        // Mollie gebruikt een andere aanpak met URL signing
        $parsedUrl = parse_url($_SERVER['REQUEST_URI']);
        $signString = $parsedUrl['path'];
        
        if (isset($parsedUrl['query'])) {
            parse_str($parsedUrl['query'], $params);
            ksort($params);
            $signString .= '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
        }
        
        $expectedSignature = hash_hmac('sha256', $signString, $secret);
        return hash_equals($expectedSignature, $signature);
    }
}

Verschillende providers hebben hun eigen signature formaten en conventies. Stripe gebruikt bijvoorbeeld een timestamp-based systeem waar meerdere signatures in één header kunnen staan. GitHub houdt het simpel met een sha256= prefix. Mollie heeft weer een eigen systeem waarbij ze de gehele URL path inclusieven query parameters gebruiken voor de signature berekening.

Het is verleidelijk om één generieke functie te schrijven, maar ik heb geleerd dat elke provider net anders genoeg is om specifieke implementaties te rechtvaardigen. Door aparte methoden te maken voor elke provider vermijd je edge cases en bugs die ontstaan door het proberen generiek te maken van iets wat inherent specifiek is.

Een praktische tip: test altijd je webhook handlers met echte data van de provider voordat je live gaat. Gebruik hun test environments of webhook testing tools om te verifiëren dat je signature verificatie correct werkt. Ik heb te vaak gezien dat developers alleen testen met zelfgemaakte test data, om er later achter te komen dat de provider net iets anders doet dan verwacht.

Beveiligingsoverwegingen en best practices

Webhook secrets moeten worden behandeld als wachtwoorden. Sla ze nooit op in je code repository, gebruik altijd environment variabelen of een dedicated secrets management systeem. In Laravel betekent dit ze in je .env bestand zetten en via config() benaderen. Voor productie omgevingen overweeg ik vaak services zoals AWS Secrets Manager of Azure Key Vault.

// In config/services.php
'payment' => [
    'webhook_secret' => env('PAYMENT_WEBHOOK_SECRET'),
],

// In je controller
$secret = config('services.payment.webhook_secret');

if (!$secret) {
    Log::critical('Webhook secret not configured');
    return response('Configuration error', 500);
}

Rotatie van webhook secrets is een ondergewaardeerde security practice. Net zoals je database wachtwoorden moet roteren, moeten webhook secrets regelmatig worden vernieuwd. Veel providers ondersteunen meerdere actieve secrets tegelijk, wat naadloze rotatie mogelijk maakt. Je configureert de nieuwe secret, test hem, en schakelt dan de oude uit.

Rate limiting op webhook endpoints is cruciaal. Zelfs met signature verificatie kunnen aanvallers je server belasten door massaal requests te sturen met ongeldige signatures. Ik implementeer altijd strenge rate limits op webhook routes, vaak strenger dan op reguliere API endpoints omdat legitimate webhooks meestal een voorspelbaar patroon hebben.

Monitoring en alerting zijn onmisbaar. Ik stel altijd alerts in voor spikes in gefaalde signature verificaties, omdat dit kan wijzen op aanvallen of configuratieproblemen. Ook monitor ik de response tijden van webhook endpoints, omdat lange processing tijden kunnen leiden tot duplicate deliveries van sommige providers.

Webhook endpoints moeten altijd snel reageren. Providers hebben meestal timeouts van 10-30 seconden, en als je endpoint niet op tijd reageert, sturen ze het bericht opnieuw. Dit kan leiden tot duplicate processing als je niet voorzichtig bent. Gebruik background jobs voor zware verwerking en reageer direct met een 200 status naar de webhook provider.