To verify the x-paystack-signature header, compute an HMAC SHA512 hash of the raw request body using your Paystack secret key, then compare the result to the header value. If they match, the request is authentic. Use crypto.createHmac in Node.js, hmac.new in Python, or hash_hmac in PHP. Always hash the raw body bytes, not a re-serialized object.
Every webhook request from Paystack includes a header called x-paystack-signature. This header contains a hex-encoded HMAC SHA512 hash. The hash is computed over the entire raw request body using your Paystack secret key as the HMAC key.
This serves two purposes:
If you skip this check, your webhook endpoint is open to spoofing. Anyone who discovers your webhook URL (through logs, error messages, code repositories, or brute force) can POST fake events to it. A fake charge.success event could trick your system into granting value for a payment that never happened.
Signature verification is not optional. It is the primary security mechanism for your webhook endpoint.
The process is the same regardless of your programming language or framework:
sk_live_ or sk_test_ key from your Paystack Dashboard. Store it in an environment variable. Never hardcode it.That is the entire process. The complexity comes from step 1 (getting the raw body before your framework parses it) and step 5 (using timing-safe comparison). We will cover both.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Here is the complete, production-ready verification in Express:
const crypto = require('crypto');
const express = require('express');
const app = express();
// IMPORTANT: Do NOT apply express.json() globally before this route.
// If you need express.json() on other routes, apply it selectively.
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.PAYSTACK_SECRET_KEY;
if (!secret) {
console.error('PAYSTACK_SECRET_KEY is not set');
return res.status(500).send('Server configuration error');
}
const signature = req.headers['x-paystack-signature'];
if (!signature) {
return res.status(400).send('Missing signature header');
}
// Step 1: Compute the HMAC SHA512 hash of the raw body
const hash = crypto
.createHmac('sha512', secret)
.update(req.body) // req.body is a Buffer because of express.raw()
.digest('hex');
// Step 2: Compare using timing-safe comparison
const hashBuffer = Buffer.from(hash, 'utf8');
const signatureBuffer = Buffer.from(signature, 'utf8');
if (hashBuffer.length !== signatureBuffer.length) {
return res.status(400).send('Invalid signature');
}
if (!crypto.timingSafeEqual(hashBuffer, signatureBuffer)) {
return res.status(400).send('Invalid signature');
}
// Signature verified. Parse the body and process.
const event = JSON.parse(req.body.toString());
console.log('Verified event:', event.event);
// Return 200 immediately, queue heavy work
res.status(200).send('OK');
}
);
Key details in this code:
express.raw({ type: 'application/json' }) tells Express to keep the body as a Buffer instead of parsing it as JSON. This is the raw body you need for the hash.crypto.createHmac('sha512', secret) creates an HMAC instance using SHA512 and your secret key..update(req.body) feeds the raw body into the hash. Since req.body is a Buffer, the bytes are exactly what Paystack sent..digest('hex') outputs the hash as a lowercase hex string, which matches the format Paystack uses.crypto.timingSafeEqual prevents timing attacks (explained in the next section).If you are using express.json() as global middleware, your webhook route will break. See the raw body problem for why and how to fix it.
A simple string comparison (hash === signature) technically works. It will correctly tell you if the hashes match or not. But it leaks information through timing.
When you compare two strings character by character, the comparison stops at the first mismatch. If the first character is wrong, it returns false almost instantly. If the first 127 characters match and only the last one differs, it takes measurably longer. An attacker who can measure response times (down to microseconds) can use this to guess the correct hash one character at a time.
This is called a timing attack. It is not theoretical. It has been used in real exploits against web applications.
crypto.timingSafeEqual always takes the same amount of time regardless of where the mismatch occurs. It compares every byte, every time. This removes the timing signal.
// WRONG: vulnerable to timing attacks
if (hash === signature) {
// process event
}
// RIGHT: timing-safe comparison
const hashBuffer = Buffer.from(hash, 'utf8');
const signatureBuffer = Buffer.from(signature, 'utf8');
// Buffers must be the same length for timingSafeEqual
if (hashBuffer.length !== signatureBuffer.length) {
return res.status(400).send('Invalid signature');
}
if (crypto.timingSafeEqual(hashBuffer, signatureBuffer)) {
// process event
}
The length check before timingSafeEqual is necessary because the function throws an error if the buffers have different lengths. In practice, both the computed hash and the Paystack signature should always be 128 hex characters (512 bits / 4 bits per hex character), so the length check is a safety net rather than a common branch.
Is this overkill for most applications? Probably. But it costs nothing to implement, and it removes an entire class of attack. Do it.
Django gives you the raw body through request.body, which returns bytes. This is straightforward because Django does not automatically parse JSON request bodies the way Express does.
import hmac
import hashlib
import json
import os
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt # Paystack webhook POST will not carry a CSRF token
@require_POST
def paystack_webhook(request):
secret = os.environ['PAYSTACK_SECRET_KEY'].encode('utf-8')
signature = request.headers.get('X-Paystack-Signature', '')
# request.body is already raw bytes
computed = hmac.new(
secret,
msg=request.body,
digestmod=hashlib.sha512
).hexdigest()
# Python's hmac.compare_digest is timing-safe
if not hmac.compare_digest(computed, signature):
return HttpResponse('Invalid signature', status=400)
event = json.loads(request.body)
# process event...
return HttpResponse('OK', status=200)
Key differences from Express:
request.body in Django is already raw bytes. No special middleware configuration needed.hmac.compare_digest is Python's built-in timing-safe comparison. Use it instead of ==.@csrf_exempt decorator is required. Without it, Django rejects the webhook POST with a 403 because it has no CSRF token. See webhooks and CSRF in Django..encode('utf-8')) before passing it to hmac.new.In Laravel, the request body is typically consumed by middleware before your controller runs. To get the raw body for HMAC verification, use file_get_contents('php://input') or access it through the request object's getContent() method.
// In your controller or route handler
use Illuminate\Http\Request;
public function handleWebhook(Request $request)
{
$secret = env('PAYSTACK_SECRET_KEY');
$signature = $request->header('X-Paystack-Signature');
// Get the raw body
$rawBody = $request->getContent();
$computed = hash_hmac('sha512', $rawBody, $secret);
if (!hash_equals($computed, $signature)) {
return response('Invalid signature', 400);
}
$event = json_decode($rawBody, true);
// process event...
return response('OK', 200);
}
Key differences:
$request->getContent() returns the raw body as a string.hash_hmac('sha512', $rawBody, $secret) computes the HMAC in one call.hash_equals is PHP's timing-safe comparison function. Use it instead of ===.$except array in VerifyCsrfToken middleware. See webhooks and CSRF in Laravel.Mistake 1: Hashing the parsed body instead of the raw body. This is the most common failure. If your framework parsed the JSON into an object and you re-serialize it with JSON.stringify, the output may differ from the original (different whitespace, different key order, different Unicode escaping). The hash will not match. Always hash the raw bytes. See the raw body problem for the full explanation.
Mistake 2: Using the wrong key. Use your secret key (sk_live_ or sk_test_), not your public key (pk_live_ or pk_test_). The public key is for client-side checkout initialization. The secret key is for server-side operations including webhook verification.
Mistake 3: Using the test key in production or vice versa. Test mode events are signed with your test secret key. Live mode events are signed with your live secret key. If your production server is configured with the test key, every live webhook will fail verification. Double-check your environment variables after deployment.
Mistake 4: Not checking for the header at all. If x-paystack-signature is missing from the request, the request did not come from Paystack (or something stripped the header in transit). Reject it. Do not compare your computed hash against undefined or an empty string.
Mistake 5: Logging the secret key. When debugging signature failures, developers sometimes log the secret key. Do not. Log the computed hash and the received signature (both are safe to log), but never the key itself. If your logs are compromised, the attacker can forge any webhook.
Mistake 6: Hardcoding the secret key. Store it in an environment variable. If you hardcode it in your source code and your repository is public (or gets leaked), your webhook endpoint is compromised.
When your signature check fails and you cannot figure out why, follow this checklist:
# Simulate a webhook locally
SECRET="sk_test_your_secret_key"
BODY='{"event":"charge.success","data":{"reference":"test-ref-001","amount":50000}}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/paystack -H "Content-Type: application/json" -H "x-paystack-signature: $SIGNATURE" -d "$BODY"
If your handler accepts this curl request but rejects real Paystack webhooks, the problem is almost certainly the raw body issue. A proxy or middleware is modifying the body between Paystack and your verification code. See the raw body problem and webhooks behind Nginx and a reverse proxy.
This article is part of the Paystack webhooks engineering guide.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course