Verifying the x-paystack-signature Header Correctly
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.
What the x-paystack-signature Header Is
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:
- Authentication. It proves the request came from Paystack (or someone who has your secret key). No one else can produce a valid hash without knowing the key.
- Integrity. It proves the request body was not modified in transit. If a middlebox, proxy, or attacker changes even one byte of the body, the hash will not match.
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 Verification Process Step by Step
The process is the same regardless of your programming language or framework:
- Read the raw request body. You need the exact bytes that Paystack sent. Not a parsed JSON object. Not a re-serialized string. The raw bytes.
- Get your Paystack secret key. This is the
sk_live_orsk_test_key from your Paystack Dashboard. Store it in an environment variable. Never hardcode it. - Compute the HMAC SHA512 hash. Use the secret key as the HMAC key and the raw body as the message. Output the result as a lowercase hexadecimal string.
- Read the x-paystack-signature header. This is the expected hash value from Paystack.
- Compare the two values. If they match, the request is authentic. If they do not match, reject the request.
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.
Express (Node.js) Implementation
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. Sincereq.bodyis 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.timingSafeEqualprevents 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.
Why You Need Timing-Safe Comparison
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 (Python) Notes
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.bodyin Django is already raw bytes. No special middleware configuration needed.hmac.compare_digestis Python's built-in timing-safe comparison. Use it instead of==.- The
@csrf_exemptdecorator is required. Without it, Django rejects the webhook POST with a 403 because it has no CSRF token. See webhooks and CSRF in Django. - The secret key must be encoded to bytes (
.encode('utf-8')) before passing it tohmac.new.
Laravel (PHP) Notes
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_equalsis PHP's timing-safe comparison function. Use it instead of===.- You must exempt the webhook route from CSRF verification. Add it to the
$exceptarray inVerifyCsrfTokenmiddleware. See webhooks and CSRF in Laravel.
Common Verification Mistakes
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.
Debugging Signature Failures
When your signature check fails and you cannot figure out why, follow this checklist:
- Log the raw body length and the first 100 characters. Compare them to what you see in the Paystack Dashboard webhook logs. If the lengths differ, something is modifying the body (middleware, proxy, or encoding).
- Log the computed hash and the received signature. If the computed hash is all zeros or empty, your secret key is probably not loaded correctly.
- Check your secret key environment variable. Print its length (not its value) to confirm it is loaded and not truncated.
- Send a test webhook from the Paystack Dashboard. Go to Settings, API Keys and Webhooks, and click the test button. This lets you compare a known payload against your verification logic.
- Simulate a webhook locally with curl. Compute the signature yourself and send it. If your handler accepts this but rejects real Paystack webhooks, the body is being modified somewhere between Paystack and your handler.
# 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.
Key Takeaways
- ✓The x-paystack-signature header contains an HMAC SHA512 hash of the raw request body, signed with your Paystack secret key.
- ✓You must hash the raw body bytes exactly as Paystack sent them. Parsing and re-serializing the JSON will change the bytes and break the signature.
- ✓Use crypto.createHmac("sha512", secret).update(rawBody).digest("hex") in Node.js to compute the hash.
- ✓Use timing-safe comparison (crypto.timingSafeEqual) instead of simple string equality to prevent timing attacks.
- ✓Never skip signature verification. Without it, anyone who knows your webhook URL can send fake payment confirmations.
- ✓Django uses request.body, Laravel uses file_get_contents("php://input"), and Flask uses request.get_data() for the raw body.
Frequently Asked Questions
- Which key do I use to verify the Paystack webhook signature?
- Use your Paystack secret key (the one starting with sk_live_ or sk_test_). Do not use your public key (pk_live_ or pk_test_). The secret key is the HMAC key that Paystack uses to sign the webhook body. Test mode webhooks are signed with your test secret key, and live mode webhooks are signed with your live secret key.
- What hashing algorithm does Paystack use for webhook signatures?
- Paystack uses HMAC SHA512. The output is a 128-character lowercase hexadecimal string. In Node.js, compute it with crypto.createHmac("sha512", secret).update(rawBody).digest("hex"). In Python, use hmac.new(secret, msg=body, digestmod=hashlib.sha512).hexdigest(). In PHP, use hash_hmac("sha512", $body, $secret).
- Why does my Paystack signature verification fail even though my code looks correct?
- The most common cause is that your framework parsed the request body before you could hash it. Express with express.json(), Django REST Framework with parsers, and similar middleware all modify the raw bytes. When you re-serialize the parsed object, the output differs from what Paystack sent. Hash the raw body, not a re-serialized version. See the raw body problem article for framework-specific fixes.
- Do I really need timing-safe comparison for Paystack signatures?
- Strictly speaking, a timing attack against a webhook signature is difficult because the attacker needs to measure response times precisely over the network. But timing-safe comparison costs nothing to implement (one extra function call) and eliminates the attack entirely. Use crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, or hash_equals in PHP.
- What should I return when the Paystack signature does not match?
- Return a 400 status code or simply ignore the request. Do not return detailed error messages that reveal your verification logic. A simple "Invalid signature" or an empty 400 response is sufficient. Do not return 200, because that tells Paystack the delivery succeeded when it did not actually reach a valid handler.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon