Real time
Verifying a webhook signature
How to check that a delivery comes from SafeOnuba: HMAC-SHA256 over the raw body, constant-time comparison, a 5-minute window and secret rotation.
On this page
Your receiver is at a public URL, so anyone can send it a POST. The signature is what proves a delivery comes from SafeOnuba and that nobody has tampered with it along the way. Don’t process any event without verifying it.
How it’s signed
Each delivery carries two headers:
X-SafeOnuba-Timestamp: the moment of signing, in Unix seconds.X-SafeOnuba-Signature:v1=followed by the signature in hexadecimal.
The signature is an HMAC-SHA256, with the destination’s secret (the whsec_… the API gave you when you created it) as the key, over the timestamp, a full stop and the body exactly as it arrived:
v1=hex( HMAC_SHA256( secret, timestamp + "." + raw_body ) )What your receiver has to do
- Read the raw body, as bytes, before any framework turns it into JSON. If you parse it and serialise it again, the whitespace or the order of the keys changes and the signature no longer matches.
- Reject it if the timestamp is more than 5 minutes off your clock, in either direction. This stops someone replaying an old event they captured. Keep your server’s clock synchronised by NTP.
- Calculate the signature with your secret over
timestamp + "." + body. - Compare it in constant time against each signature in the header. An ordinary comparison (
==) takes a different time depending on how many characters match, and that leaks information. - Accept it if any of the signatures in the header matches. During a secret rotation two arrive, separated by a comma.
If the signature isn’t valid, respond 401 and discard the body.
A complete receiver
The three examples do the same thing: they verify, put the event on your queue (enqueue, which you provide) and respond 204 straight away.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRET = process.env.SAFEONUBA_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_S = 5 * 60;
export function isValidSignature(body, timestamp, header, secret = SECRET) {
if (!body || !timestamp || !header) return false;
// 1. The timestamp, within 5 minutes of our clock.
const t = Number(timestamp);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false;
// 2. The expected signature, over the raw body (a Buffer).
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest();
// 3. Any of the signatures in the header will do, compared in constant time.
return header.split(',').some((part) => {
const [version, hex] = part.trim().split('=');
if (version !== 'v1' || !/^[0-9a-f]{64}$/i.test(hex ?? '')) return false;
return timingSafeEqual(Buffer.from(hex, 'hex'), expected);
});
}
createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/safeonuba') {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks); // raw: no JSON.parse before verifying
const valid = isValidSignature(
body,
req.headers['x-safeonuba-timestamp'],
req.headers['x-safeonuba-signature'],
);
if (!valid) {
res.writeHead(401).end();
return;
}
enqueue(JSON.parse(body.toString('utf8'))); // processed later, outside the request
res.writeHead(204).end();
});
}).listen(8080);import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["SAFEONUBA_WEBHOOK_SECRET"].encode() # whsec_…
TOLERANCE_S = 5 * 60
app = Flask(__name__)
def is_valid_signature(body: bytes, timestamp: str | None, header: str | None) -> bool:
if not timestamp or not header:
return False
# 1. The timestamp, within 5 minutes of our clock.
try:
t = int(timestamp)
except ValueError:
return False
if abs(time.time() - t) > TOLERANCE_S:
return False
# 2. The expected signature, over the raw body.
expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
# 3. Any of the signatures in the header will do, compared in constant time.
for part in header.split(","):
version, _, signature = part.strip().partition("=")
if version == "v1" and hmac.compare_digest(signature.lower(), expected):
return True
return False
@app.post("/safeonuba")
def receive():
body = request.get_data() # raw, before touching request.json
if not is_valid_signature(
body,
request.headers.get("X-SafeOnuba-Timestamp"),
request.headers.get("X-SafeOnuba-Signature"),
):
abort(401)
enqueue(request.get_json()) # processed later, outside the request
return "", 204using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Encoding.UTF8.GetBytes(builder.Configuration["SAFEONUBA_WEBHOOK_SECRET"]!); // whsec_…
app.MapPost("/safeonuba", async (HttpRequest req) =>
{
// Raw: the whole body is read before deserialising anything.
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
var body = ms.ToArray();
if (!IsValidSignature(body, req.Headers["X-SafeOnuba-Timestamp"], req.Headers["X-SafeOnuba-Signature"], secret))
return Results.Unauthorized();
Enqueue(JsonDocument.Parse(body)); // processed later, outside the request
return Results.NoContent();
});
app.Run();
static bool IsValidSignature(byte[] body, string? timestamp, string? header, byte[] secret)
{
if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(header)) return false;
// 1. The timestamp, within 5 minutes of our clock.
if (!long.TryParse(timestamp, out var t)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 5 * 60) return false;
// 2. The expected signature, over the raw body.
var signed = Encoding.UTF8.GetBytes(timestamp + ".").Concat(body).ToArray();
var expected = HMACSHA256.HashData(secret, signed);
// 3. Any of the signatures in the header will do, compared in constant time.
foreach (var part in header.Split(','))
{
var pieces = part.Trim().Split('=', 2);
if (pieces.Length != 2 || pieces[0] != "v1") continue;
byte[] received;
try { received = Convert.FromHexString(pieces[1]); }
catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(received, expected)) return true;
}
return false;
}Rotating the secret
Rotate the secret if you think it has leaked, when someone who knew it leaves, or simply every so often. Nothing is interrupted:
-
Call
POST /v1/webhooks/{id}/rotate-secret. The response carries the new secret (secret, this once only) and how long the previous one keeps signing (previous_valid_until). -
For the next 24 hours, each delivery carries two signatures in
X-SafeOnuba-Signature, one with each secret, separated by a comma:X-SafeOnuba-Signature v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v1=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39 -
Switch your receiver’s secret to the new one at any point in those 24 hours. Since the code above accepts the delivery if any one of the signatures matches, it works the same before and after the switch.
-
After the 24 hours, the previous secret stops signing.
A second rotation retires the first secret: there are never more than two at once.
Common mistakes
- Verifying over the re-serialised JSON. The signature is calculated over the exact bytes that arrived.
- Comparing with
==. UsetimingSafeEqual,hmac.compare_digestorCryptographicOperations.FixedTimeEquals. - Ignoring the timestamp. Without the 5-minute window, a captured event can be replayed whenever someone likes.
- Keeping only the first signature. During a rotation two arrive, and the one that matches your secret may be the second.
- Responding after processing. If your control room takes more than 5 seconds, the delivery counts as failed and is retried. Verify, enqueue and respond.