Securing Paystack Webhooks with HMAC and crypto.timingSafeEqual
2026-04-07
Every payment integration has a moment that terrifies you: the webhook endpoint. It is a public URL that receives POST requests claiming "this payment was successful", and if you do not verify those requests properly, an attacker can hit your endpoint directly and trigger order fulfilment for payments that never happened.
Paystack signs every webhook with HMAC-SHA512. Here is how we verify it in Storich, and why we specifically use crypto.timingSafeEqual.
HMAC (Hash-based Message Authentication Code) is a mechanism for verifying both the integrity and authenticity of a message. Paystack uses your secret key to sign the raw request body with SHA-512 and sends the result in the x-paystack-signature header. On your server, you compute the same signature using the same secret and compare them. If they match, the request is genuine.
HMAC = SHA512(secretKey, rawBody)
Most tutorials show this:
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(rawBody)
.digest('hex');
if (hash === req.headers['x-paystack-signature']) {
// process webhook
}
This works, but it has a subtle security vulnerability: timing attacks.
JavaScript's === operator compares strings character by character and returns false the moment it finds a mismatch. This means a string that matches on the first 60 characters takes slightly longer to reject than one that mismatches on character 1. An attacker making thousands of requests can measure these tiny time differences to progressively guess the correct signature one character at a time.
For a 128-character hex string, that is exploitable.
Node's crypto.timingSafeEqual compares two buffers in constant time, it always takes the same amount of time regardless of where the mismatch occurs, eliminating the timing signal entirely.
import * as crypto from 'crypto';
function verifyPaystackSignature(
rawBody: Buffer,
signature: string,
secretKey: string,
): boolean {
const expected = crypto
.createHmac('sha512', secretKey)
.update(rawBody)
.digest('hex');
const expectedBuffer = Buffer.from(expected, 'utf8');
const signatureBuffer = Buffer.from(signature, 'utf8');
// Buffers must be the same length, timingSafeEqual throws if they differ
if (expectedBuffer.length !== signatureBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, signatureBuffer);
}
Note the length check before calling timingSafeEqual, the function throws a RangeError if the buffers are different lengths, so you must handle that case first.
@Post('webhook')
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('x-paystack-signature') signature: string,
@Res() res: Response,
) {
const rawBody = req.rawBody;
if (!rawBody || !signature) {
return res.status(400).send('Bad Request');
}
const isValid = verifyPaystackSignature(
rawBody,
signature,
this.configService.get('PAYSTACK_SECRET_KEY'),
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody.toString());
if (event.event === 'charge.success') {
await this.paymentsService.handleChargeSuccess(event.data);
}
return res.status(200).send('OK');
}
One important NestJS detail: you need raw body access, not the parsed JSON body, because the HMAC is computed over the exact bytes Paystack sent. Enable it in your bootstrap:
app.use(
json({
verify: (req: any, _res, buf) => {
req.rawBody = buf;
},
}),
);
crypto.timingSafeEqual instead of === for comparing security-sensitive strings.timingSafeEqual to avoid the RangeError.