Webhook Verification
Deep dive into HMAC-SHA256 webhook signature verification in Node.js, Python, PHP, and Go — with production hardening tips.
Webhook Verification
Every Nodukan webhook delivery is signed using HMAC-SHA256. Verifying the signature before processing ensures you only act on events that genuinely came from Nodukan — protecting against spoofed or replayed requests.
How signing works#
When Nodukan dispatches a webhook, it:
- Records the current Unix timestamp
t - Constructs the signed payload:
${t}.${rawBody} - Computes
HMAC-SHA256(signingSecret, signedPayload) - Sends the result in the
X-Nodukan-Signatureheader:
X-Nodukan-Signature: t=1715000130,v1=3c5e9a2f7d4b1a8e9c...
Your server must reconstruct steps 2–3 using the same signingSecret (from webhook creation) and compare against the v1 value using a constant-time comparison to prevent timing attacks.
Always use the raw request body bytes — before any JSON parsing. Re-serializing parsed JSON can change whitespace, alter key ordering, or escape characters differently, which breaks the HMAC.
Node.js#
// verify.js
import crypto from 'crypto';
/**
* Verify a Nodukan webhook signature.
*
* @param {string | Buffer} rawBody - Raw request body (before JSON.parse)
* @param {string} signatureHeader - Value of X-Nodukan-Signature header
* @param {string} signingSecret - Your webhook's signing secret (whsec_...)
* @param {number} [toleranceSeconds=300] - Max age of event to accept (replay protection)
* @returns {boolean}
*/
export function verifyNodukansignature(rawBody, signatureHeader, signingSecret, toleranceSeconds = 300) {
// Parse the header
const parts = Object.fromEntries(
signatureHeader.split(',').flatMap((part) => {
const idx = part.indexOf('=');
return idx === -1 ? [] : [[part.slice(0, idx), part.slice(idx + 1)]];
}),
);
const { t, v1 } = parts;
if (!t || !v1) return false;
// Replay protection: reject stale events
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (age > toleranceSeconds) return false;
// Reconstruct the signed payload
const body = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
const signedPayload = `${t}.${body}`;
// Compute expected HMAC
const expected = crypto
.createHmac('sha256', signingSecret)
.update(signedPayload, 'utf8')
.digest('hex');
// Constant-time comparison — CRITICAL: prevents timing oracle attacks
try {
const vBuf = Buffer.from(v1, 'hex');
const eBuf = Buffer.from(expected, 'hex');
if (vBuf.length !== eBuf.length) return false;
return crypto.timingSafeEqual(vBuf, eBuf);
} catch {
return false;
}
}
Express integration#
// routes/webhook.js
import express from 'express';
import { verifyNodukansignature } from '../verify.js';
const router = express.Router();
router.post(
'/nodukan',
express.raw({ type: 'application/json' }), // ← raw body, not express.json()
(req, res) => {
const rawBody = req.body;
const sig = req.headers['x-nodukan-signature'];
if (!sig || !verifyNodukansignature(rawBody, sig, process.env.NODUKAN_WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized');
}
res.status(200).send('OK'); // respond fast
const event = JSON.parse(rawBody);
processEvent(event).catch(console.error); // async queue
},
);
export default router;
Next.js App Router (Edge or Node)#
// app/api/webhooks/nodukan/route.ts
import { verifyNodukansignature } from '@/lib/verify';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs'; // Required — Edge runtime lacks crypto.timingSafeEqual
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sig = req.headers.get('x-nodukan-signature') ?? '';
if (!verifyNodukansignature(rawBody, sig, process.env.NODUKAN_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(rawBody);
// Process event...
return NextResponse.json({ received: true });
}
Python#
# verify.py
import hashlib
import hmac
import time
def verify_nodukan_signature(
raw_body: bytes | str,
signature_header: str,
signing_secret: str,
tolerance_seconds: int = 300,
) -> bool:
"""
Verify a Nodukan webhook signature.
Args:
raw_body: Raw request body bytes (before json.loads)
signature_header: Value of X-Nodukan-Signature header
signing_secret: Your webhook's signing secret (whsec_...)
tolerance_seconds: Maximum age of event to accept
Returns:
True if signature is valid, False otherwise
"""
parts = {}
for part in signature_header.split(","):
if "=" in part:
k, _, v = part.partition("=")
parts[k] = v
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return False
# Replay protection
if abs(time.time() - int(t)) > tolerance_seconds:
return False
body_str = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
signed_payload = f"{t}.{body_str}"
expected = hmac.new(
signing_secret.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(v1, expected)
# Flask integration
from flask import Flask, request, abort
import json
app = Flask(__name__)
@app.post("/webhooks/nodukan")
def nodukan_webhook():
raw = request.get_data() # raw bytes — before Flask parses JSON
sig = request.headers.get("X-Nodukan-Signature", "")
if not verify_nodukan_signature(raw, sig, app.config["NODUKAN_WEBHOOK_SECRET"]):
abort(401)
event = json.loads(raw)
# Respond immediately, process async
process_event.delay(event) # e.g., Celery
return {"received": True}, 200
# Django integration
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def nodukan_webhook(request):
sig = request.headers.get("X-Nodukan-Signature", "")
if not verify_nodukan_signature(request.body, sig, settings.NODUKAN_WEBHOOK_SECRET):
return HttpResponseForbidden("Invalid signature")
event = json.loads(request.body)
process_event.delay(event)
return HttpResponse("OK")
PHP#
<?php
// verify.php
/**
* Verify a Nodukan webhook signature.
*
* @param string $rawBody Raw request body string
* @param string $sigHeader X-Nodukan-Signature header value
* @param string $signingSecret Webhook signing secret (whsec_...)
* @param int $tolerance Maximum event age in seconds (default 300)
* @return bool
*/
function verifyNodukansignature(
string $rawBody,
string $sigHeader,
string $signingSecret,
int $tolerance = 300
): bool {
$parts = [];
foreach (explode(',', $sigHeader) as $part) {
[$k, $v] = explode('=', $part, 2) + [null, null];
if ($k && $v) $parts[$k] = $v;
}
$t = $parts['t'] ?? null;
$v1 = $parts['v1'] ?? null;
if (!$t || !$v1) return false;
// Replay protection
if (abs(time() - (int)$t) > $tolerance) return false;
$signedPayload = "{$t}.{$rawBody}";
$expected = hash_hmac('sha256', $signedPayload, $signingSecret);
// Constant-time comparison
return hash_equals($expected, $v1);
}
// Laravel usage
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/nodukan', function (Request $request) {
$rawBody = $request->getContent();
$sig = $request->header('X-Nodukan-Signature', '');
if (!verifyNodukansignature($rawBody, $sig, config('services.nodukan.webhook_secret'))) {
abort(401, 'Invalid signature');
}
$event = json_decode($rawBody, true);
// Dispatch to queue
ProcessNodukansignatureEvent::dispatch($event);
return response()->json(['received' => true]);
});
Go#
// verify.go
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// VerifyNodukansignature verifies a Nodukan webhook signature.
// rawBody must be the unmodified request body bytes.
func VerifyNodukansignature(rawBody []byte, sigHeader, signingSecret string, toleranceSecs int) (bool, error) {
parts := make(map[string]string)
for _, part := range strings.Split(sigHeader, ",") {
idx := strings.Index(part, "=")
if idx == -1 {
continue
}
parts[part[:idx]] = part[idx+1:]
}
t, ok1 := parts["t"]
v1, ok2 := parts["v1"]
if !ok1 || !ok2 {
return false, fmt.Errorf("missing t or v1 in signature header")
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil {
return false, fmt.Errorf("invalid timestamp: %w", err)
}
// Replay protection
age := math.Abs(float64(time.Now().Unix() - ts))
if age > float64(toleranceSecs) {
return false, fmt.Errorf("event timestamp too old (%0.f seconds)", age)
}
// Reconstruct signed payload
signedPayload := fmt.Sprintf("%s.%s", t, string(rawBody))
// Compute expected HMAC
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(signedPayload))
expected := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
expectedBytes, err := hex.DecodeString(expected)
if err != nil {
return false, err
}
v1Bytes, err := hex.DecodeString(v1)
if err != nil {
return false, fmt.Errorf("invalid v1 hex: %w", err)
}
return hmac.Equal(v1Bytes, expectedBytes), nil
}
// net/http handler example
func NodukansignatureWebhookHandler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
sig := r.Header.Get("X-Nodukan-Signature")
valid, err := VerifyNodukansignature(body, sig, secret, 300)
if err != nil || !valid {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received":true}`))
// Process asynchronously
go processEvent(body)
}
}
Common mistakes#
| Mistake | Fix |
|---|---|
Using req.body (parsed JSON) instead of the raw body bytes | Use express.raw() / request.get_data() / $request->getContent() |
Using === instead of constant-time comparison | Always use crypto.timingSafeEqual / hmac.compare_digest / hash_equals / hmac.Equal |
Not checking the timestamp t | Add replay protection — reject events older than 5 minutes |
| Comparing truncated hex strings | Ensure both sides decode from hex to bytes before comparing |
| Using the API key instead of the signing secret | The signing secret (whsec_...) is separate from your API key (NDK_live_...) |
Last updated