Verify signatures
Verify the HMAC-SHA256 signature of the X-Novafisko-Signature header in PHP, Node.js, Python and C#, with clock tolerance and secret rotation.
Your receiving URL is public: anyone can send a request to it. The signature proves that a call really comes from NovaFisko and that its content was not altered in transit. Never process a webhook without verifying it.
The principle
Every request carries a header of the form:
X-Novafisko-Signature: t=1791191400,v1=5f2b8c1e0a7d4f6b9c3e2a1d8f7b6c5a4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b
| Item | Meaning |
|---|---|
t |
Unix timestamp of the send, in seconds |
v1 |
HMAC-SHA256 signature in lowercase hexadecimal, 64 characters |
The signature is calculated as follows:
v1 = HMAC_SHA256(secret, t + "." + raw_body)
where secret is the whsec_... value received when the endpoint was created and raw_body is the raw body of the request, byte for byte.
The four steps
- Read the raw body. Get the bytes received before any JSON parsing.
- Extract
tandv1from the header. - Check the timestamp. Refuse if
tdiffers from your clock by more than 5 minutes. - Recompute and compare. Compute the HMAC and compare it with
v1using a constant-time function.
Sign the body as received. If you parse the JSON and then serialise it again, the key order, the whitespace or the encoding of accented characters will change and the signature will no longer match. This is the most common mistake.
Why a clock tolerance
The timestamp is part of the signed data. An attacker who intercepted a valid request could therefore not replay it later: after 5 minutes, you refuse it. Keep your server's clock synchronised through NTP.
Each new delivery attempt is signed with a fresh timestamp. A delivery retried one hour later therefore remains valid.
Why a constant-time comparison
An ordinary comparison stops at the first differing character. By measuring the response time, an attacker can guess the signature character by character. The hash_equals, timingSafeEqual, compare_digest and FixedTimeEquals functions always take the same time.
PHP
<?php
function verifyNovafiskoSignature(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
if ($header === null) {
return false;
}
// Header format: t=<unix ts>,v1=<hex hmac>
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
$parts[$key] = $value;
}
$timestamp = $parts['t'] ?? '';
$signature = $parts['v1'] ?? '';
if (! ctype_digit($timestamp) || $signature === '') {
return false;
}
// Reject stale or future-dated requests (replay protection)
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
return hash_equals($expected, $signature); // constant-time comparison
}
$rawBody = file_get_contents('php://input'); // raw bytes, before any json_decode
$header = $_SERVER['HTTP_X_NOVAFISKO_SIGNATURE'] ?? null;
if (! verifyNovafiskoSignature($rawBody, $header, getenv('NOVAFISKO_WEBHOOK_SECRET'))) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
// Acknowledge first, process asynchronously
http_response_code(200);
echo 'ok';
With Laravel, use $request->getContent() for the raw body and $request->header('X-Novafisko-Signature') for the header.
Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.NOVAFISKO_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyNovafiskoSignature(rawBody, header, secret) {
if (!header) return false;
// Header format: t=<unix ts>,v1=<hex hmac>
const parts = Object.fromEntries(
header.split(",").map((pair) => {
const index = pair.indexOf("=");
return [pair.slice(0, index).trim(), pair.slice(index + 1).trim()];
})
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
// Reject stale or future-dated requests (replay protection)
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, "hex");
// timingSafeEqual throws when lengths differ: check first
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// express.raw keeps the body as a Buffer: never use express.json() on this route
app.post("/hooks/novafisko", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyNovafiskoSignature(req.body, req.get("X-Novafisko-Signature"), SECRET)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).send("ok"); // acknowledge first
queue.push(event); // then process asynchronously
});
app.listen(3000);
Python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["NOVAFISKO_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def verify_novafisko_signature(raw_body: bytes, header: str | None, secret: bytes) -> bool:
if not header:
return False
# Header format: t=<unix ts>,v1=<hex hmac>
parts = dict(pair.strip().split("=", 1) for pair in header.split(",") if "=" in pair)
timestamp, signature = parts.get("t", ""), parts.get("v1", "")
if not timestamp.isdigit() or not signature:
return False
# Reject stale or future-dated requests (replay protection)
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature) # constant-time comparison
@app.post("/hooks/novafisko")
def novafisko_webhook():
raw_body = request.get_data() # raw bytes, before any JSON parsing
if not verify_novafisko_signature(raw_body, request.headers.get("X-Novafisko-Signature"), SECRET):
abort(400, "Invalid signature")
event = json.loads(raw_body)
enqueue(event) # process asynchronously
return "ok", 200
With FastAPI, read the body with await request.body(). With Django, use request.body.
C#
using System.Security.Cryptography;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Environment.GetEnvironmentVariable("NOVAFISKO_WEBHOOK_SECRET")!;
const int ToleranceSeconds = 300;
static bool VerifyNovafiskoSignature(byte[] rawBody, string? header, string secret, int tolerance)
{
if (string.IsNullOrEmpty(header)) return false;
// Header format: t=<unix ts>,v1=<hex hmac>
string? timestamp = null, signature = null;
foreach (var pair in header.Split(','))
{
var index = pair.IndexOf('=');
if (index < 0) continue;
var key = pair[..index].Trim();
var value = pair[(index + 1)..].Trim();
if (key == "t") timestamp = value;
if (key == "v1") signature = value;
}
if (!long.TryParse(timestamp, out var unix) || string.IsNullOrEmpty(signature)) return false;
// Reject stale or future-dated requests (replay protection)
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - unix) > tolerance) return false;
var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
var signed = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
byte[] received;
try { received = Convert.FromHexString(signature); }
catch (FormatException) { return false; }
return CryptographicOperations.FixedTimeEquals(expected, received); // constant-time comparison
}
app.MapPost("/hooks/novafisko", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer); // raw bytes, before any JSON parsing
var rawBody = buffer.ToArray();
if (!VerifyNovafiskoSignature(rawBody, request.Headers["X-Novafisko-Signature"], secret, ToleranceSeconds))
{
return Results.BadRequest("Invalid signature");
}
// Acknowledge first, process asynchronously
return Results.Ok("ok");
});
app.Run();
Check your implementation
Use this test vector to validate your code without waiting for a real event. Remember to disable the timestamp check during this test.
| Item | Value |
|---|---|
| Secret | whsec_test_secret |
| Timestamp | 1791191400 |
| Body | {"id":"evt_test","event":"webhook.test"} |
| Signed string | 1791191400.{"id":"evt_test","event":"webhook.test"} |
Compute the expected signature on the command line, then compare it with the result of your function:
printf '%s' '1791191400.{"id":"evt_test","event":"webhook.test"}' \
| openssl dgst -sha256 -hmac 'whsec_test_secret' -hex
For an end-to-end trial, trigger a real send with POST /v1/firms/{firm}/webhooks/{id}/test.
Secret rotation
A secret cannot be read again or modified. To renew it without losing any event:
- Create a second endpoint with the same URL and the same events. You receive a new secret.
- Deploy your receiver so that it accepts both secrets: the request is valid if either of them produces the right signature.
- Delete the old endpoint.
- Remove the old secret from your configuration.
During the short period when both coexist, each event reaches you twice, with the same id. Your deduplication on id absorbs it.
def verify_any(raw_body, header, secrets):
# Accept the current and the previous secret during a rotation
return any(verify_novafisko_signature(raw_body, header, secret) for secret in secrets)
Renew the secret without delay if you suspect a leak, for example if it appeared in an application log or in a code repository.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| The signature never matches | The body was parsed and then serialised again. Sign the raw bytes |
| The signature fails on accented text | The body was transcoded. It is in UTF-8, with accented characters unescaped |
| Everything is refused because of the timestamp | The server clock is drifting. Synchronise it through NTP |
| Failure after a proxy change | An intermediary modifies the body (compression, rewriting). Pass it through intact |
| Failure on one endpoint only | Wrong secret. Compare its last four characters with secret_hint |