Verifying Signatures
Anyone who knows your URL can send a forged request. Always verify the signature before trusting an event.
Header format
X-Etalasse-Signature: t=1760151845,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
tis the send time in Unix seconds.v1is the hexadecimal signature. While a secret rotation overlap is open, the same header carries twov1values.- Every retry is re-signed with a fresh
t.
How to compute it
- Take
tand allv1values from the header. - Build the text
t+.+ the raw body (exactly the bytes you received). - Compute
HMAC_SHA256(secret, text)as hex. The secret is the wholewhsec_...string as UTF-8. - Compare it with each
v1using a constant-time comparison. One match is enough. - Reject if
|now - t|is more than 300 seconds (5 minutes), to prevent replay.
Node.js example (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.ETALASSE_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
if (!header) return false;
const parts = header.split(',').map((p) => p.trim());
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
const signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!t || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'utf8');
return signatures.some((sig) => {
const sigBuf = Buffer.from(sig, 'utf8');
return sigBuf.length === expectedBuf.length && crypto.timingSafeEqual(sigBuf, expectedBuf);
});
}
// Important: read the RAW body, not parsed JSON.
app.post('/hooks/etalasse', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verify(rawBody, req.get('X-Etalasse-Signature'))) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(rawBody);
// TODO: skip if event.id was already processed, then handle the event.
res.status(200).send('ok');
});
app.listen(3000);
PHP example
<?php
$secret = getenv('ETALASSE_WEBHOOK_SECRET'); // whsec_...
$tolerance = 300;
$rawBody = file_get_contents('php://input'); // raw body
$header = $_SERVER['HTTP_X_ETALASSE_SIGNATURE'] ?? '';
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$part = trim($part);
if (strpos($part, 't=') === 0) {
$t = substr($part, 2);
} elseif (strpos($part, 'v1=') === 0) {
$signatures[] = substr($part, 3);
}
}
$valid = false;
if ($t !== null && count($signatures) > 0 && abs(time() - (int) $t) <= $tolerance) {
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $sig) {
if (hash_equals($expected, $sig)) {
$valid = true;
break;
}
}
}
if (!$valid) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($rawBody, true);
// TODO: skip if $event['id'] was already processed, then handle the event.
http_response_code(200);
echo 'ok';
Common pitfalls
- Parsing the JSON and serializing it again. Whitespace, key order, or escaping change, so the signature no longer matches. Use the raw body.
- Middleware that alters the body (for example
express.json()before your route). Putexpress.rawon the webhook route. - A plain comparison (
==). Usecrypto.timingSafeEqualorhash_equals. - Forgetting to check
t. Without the 5-minute limit, old requests can be replayed. - Reading only one
v1. During rotation the matching signature may be the second one. Check them all. - A wrong or truncated secret. Use the whole
whsec_...string, not just the part after the prefix, and watch for stray spaces or newlines in environment variables. - Server clock drift. Sync with NTP; otherwise every valid request can be rejected.
- Replying slowly. Reply 2xx quickly (8-second limit) and do the work in the background.