Handling payment webhooks sounds straightforward—until network retries hit your server three times in a row, or an attacker intercepts a valid payload and tries to credit their balance twice.
Validating signatures is step one, but it won't protect you from a replay attack where a valid, signed payload gets resent. Here is how to set up a clean, multi-layer defense in Node.js with PostgreSQL to ensure every webhook payload runs exactly once.
Why Postgres for Idempotency?
You could store processed event IDs in Redis, but if your cache flushes or a container restarts during a high-traffic spike, you lose state.
Using PostgreSQL with a unique constraint guarantees database-level isolation. If two identical requests hit your backend at the exact same millisecond, Postgres handles the lock and drops the duplicate.
-- Track processed webhook events
CREATE TABLE processed_webhooks (
id SERIAL PRIMARY KEY,
event_id VARCHAR(255) UNIQUE NOT NULL,
signature VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Fast lookups on incoming events
CREATE INDEX idx_processed_webhooks_event_id ON processed_webhooks(event_id);
The Express Middleware
Our security check handles three things before touch point execution:
- Timestamp Freshness: Kills requests older than 5 minutes to prevent stale replay attempts.
-
HMAC Check: Uses
crypto.timingSafeEqualto avoid timing side-channel attacks. -
Database Check: Queries Postgres for duplicate
event_idrecords.
const crypto = require('crypto');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'super_secret_key';
// Reject requests older than 5 minutes
const MAX_AGE_SECONDS = 300;
async function verifyPaymentWebhook(req, res, next) {
const signature = req.headers['x-signature'];
const timestamp = req.headers['x-timestamp'];
const { event_id } = req.body;
if (!signature || !timestamp || !event_id) {
return res.status(400).json({ error: 'Missing security headers or payload event_id' });
}
// 1. Clock skew / freshness check
const now = Math.floor(Date.now() / 1000);
const reqTime = parseInt(timestamp, 10);
if (isNaN(reqTime) || Math.abs(now - reqTime) > MAX_AGE_SECONDS) {
return res.status(401).json({ error: 'Stale request / timestamp out of bounds' });
}
// 2. Validate HMAC Signature
const payload = `${timestamp}.${JSON.stringify(req.body)}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
// Constant-time comparison prevents timing attacks
if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) {
return res.status(403).json({ error: 'Invalid signature' });
}
// 3. PostgreSQL Idempotency Check
try {
const { rows } = await pool.query(
'SELECT id FROM processed_webhooks WHERE event_id = $1',
[event_id]
);
if (rows.length > 0) {
return res.status(409).json({ error: 'Event already processed' });
}
next();
} catch (err) {
console.error('Webhook verification error:', err);
return res.status(500).json({ error: 'Database check failed' });
}
}
module.exports = { verifyPaymentWebhook };
Wiring the Route
Now attach the middleware to your payment route and log the event inside Postgres as part of the transaction block.
const express = require('express');
const { verifyPaymentWebhook } = require('./middleware/webhookSecurity');
const app = express();
app.use(express.json());
app.post('/api/v1/webhooks/payment', verifyPaymentWebhook, async (req, res) => {
const { event_id, amount, userId } = req.body;
const signature = req.headers['x-signature'];
try {
// Mark event as processed first
await pool.query(
'INSERT INTO processed_webhooks (event_id, signature) VALUES ($1, $2)',
[event_id, signature]
);
// Core business logic (e.g., updating balance)
console.log(`Crediting $${amount} to user${userId}`);
return res.status(200).json({ status: 'ok' });
} catch (err) {
console.error('Error handling webhook payload:', err);
return res.status(500).json({ error: 'Processing error' });
}
});
How to Test This Setup
When testing locally with Postman or curl:
- Send a valid request. You should get a
200 OK. - Immediately hit send again with the exact same payload. The server should return
409 Conflictbecause Postgres caught the duplicateevent_id. - Change the
x-timestampheader to a time 10 minutes ago. The server should return401 Unauthorized.
Structuring webhook security this way protects your system even if payment providers trigger aggressive retry loops or malicious actors try resending old traffic.
Top comments (0)