Verifying notifications
Every credit status notification is signed. Don't act on a request until it passes these checks:
- Read
tand everyv1from theFinMatch-Signatureheader, for examplet=1790000000,v1=5257a869…. - Reject it if
tis more than 5 minutes from your clock, before or after.tis Unix seconds, UTC. - Compute HMAC-SHA256, keyed with your signing secret, over the timestamp, a full stop, and the request body exactly as the bytes arrived:
t + "." + raw_body. - Compare the result with each
v1using a constant-time comparison. Accept the request if any one matches.
Many frameworks parse JSON automatically. If you verify parsed and re-serialised JSON, the bytes differ and every signature fails. Read the raw body, verify it, and only then parse it.
Each example below was tested against FinMatch's signing code with the same cases:
- a valid signature,
- both keys during a key change,
- a tampered body and a wrong secret,
- timestamps 301 seconds old and 301 seconds ahead,
- a missing header,
- a body re-serialised before verifying,
- a body containing
£and accented characters.
Each example server also received a real signed test notification and answered 204, and answered 401 with the wrong secret.
Node.js
verify.js:
const crypto = require('crypto');
// Returns true only if the request was signed by FinMatch in the last 5 minutes.
// header: the FinMatch-Signature header value
// rawBody: the exact request body bytes, as a string or Buffer, BEFORE any JSON parsing
// secrets: your signing secret(s) for this environment (two during a rotation)
function verifyFinMatchSignature(header, rawBody, secrets, nowSeconds = Math.floor(Date.now() / 1000)) {
let t = null;
const signatures = [];
for (const part of String(header || '').split(',')) {
const [key, value] = part.trim().split('=');
if (key === 't' && /^\d+$/.test(value || '')) t = Number(value);
if (key === 'v1' && /^[0-9a-f]{64}$/.test(value || '')) signatures.push(Buffer.from(value, 'hex'));
}
if (t === null || signatures.length === 0) return false;
if (Math.abs(nowSeconds - t) > 300) return false;
for (const secret of secrets) {
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
if (signatures.some((sig) => sig.length === expected.length && crypto.timingSafeEqual(sig, expected))) {
return true;
}
}
return false;
}
module.exports = { verifyFinMatchSignature };
An Express endpoint using it (express.raw keeps the exact bytes):
const express = require('express');
const { verifyFinMatchSignature } = require('./verify');
const app = express();
const SECRETS = [process.env.FINMATCH_SIGNING_SECRET]; // add the new secret here during a rotation
// express.raw keeps the exact bytes; do not use express.json() on this route.
app.post('/finmatch/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyFinMatchSignature(req.get('FinMatch-Signature'), req.body, SECRETS)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(204); // answer first, well inside 10 seconds
setImmediate(() => handleEvent(event)); // then do the work (better: put it on your own queue)
});
function handleEvent(event) {
// Dedupe on event.event_id; apply only if event.timestamp is newer than the
// last one you applied for event.finmatch_reference. See step 4.
console.log('FinMatch event', event.event_id, event.event_type);
}
app.listen(process.env.PORT || 3000);
PHP
verify.php uses only PHP's standard functions, so it needs no Composer package or extension. Tested on PHP 8.3; it uses nothing newer than PHP 7.1.
<?php
// Returns true only if the request was signed by FinMatch in the last 5 minutes.
// $header: the FinMatch-Signature header value
// $rawBody: the exact request body, from file_get_contents('php://input'), BEFORE json_decode
// $secrets: your signing secret(s) for this environment (two during a rotation)
function verify_finmatch_signature(string $header, string $rawBody, array $secrets, ?int $now = null): bool
{
$now = $now ?? time();
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
[$key, $value] = $pair;
if ($key === 't' && preg_match('/^\d+$/', $value)) {
$t = (int) $value;
}
if ($key === 'v1' && preg_match('/^[0-9a-f]{64}$/', $value)) {
$signatures[] = $value;
}
}
if ($t === null || count($signatures) === 0) {
return false;
}
if (abs($now - $t) > 300) {
return false;
}
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
}
return false;
}
A plain PHP endpoint using it. With a framework such as Laravel or Symfony, read the raw body with $request->getContent() instead:
<?php
require __DIR__ . '/verify.php';
$secrets = [getenv('FINMATCH_SIGNING_SECRET')]; // add the new secret here during a rotation
$rawBody = file_get_contents('php://input'); // read the raw body first, before any json_decode
$header = $_SERVER['HTTP_FINMATCH_SIGNATURE'] ?? '';
if (!verify_finmatch_signature($header, $rawBody, $secrets)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// Store the event for a background job (dedupe on event_id there), then answer.
// Answer well inside 10 seconds; do the real work after responding.
file_put_contents(sys_get_temp_dir() . '/finmatch-' . basename($event['event_id']) . '.json', $rawBody);
http_response_code(204);
Python
verify.py uses the standard library only (Python 3.8 and later):
import hashlib
import hmac
import re
import time
def verify_finmatch_signature(header, raw_body, secrets, now=None):
"""Return True only if the request was signed by FinMatch in the last 5 minutes.
header: the FinMatch-Signature header value
raw_body: the exact request body bytes, BEFORE any JSON parsing
secrets: your signing secret(s) for this environment (two during a rotation)
"""
now = int(time.time()) if now is None else now
t, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
if key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
signatures.append(value)
if t is None or not signatures:
return False
if abs(now - t) > 300:
return False
for secret in secrets:
signed = str(t).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, sig) for sig in signatures):
return True
return False
A Flask endpoint using it:
import json
import os
from flask import Flask, request
from verify import verify_finmatch_signature
app = Flask(__name__)
SECRETS = [os.environ["FINMATCH_SIGNING_SECRET"]] # add the new secret here during a rotation
@app.post("/finmatch/webhooks")
def finmatch_webhook():
raw_body = request.get_data() # the exact bytes; do not use request.json before verifying
if not verify_finmatch_signature(request.headers.get("FinMatch-Signature"), raw_body, SECRETS):
return "", 401
event = json.loads(raw_body)
# Put the event on your own queue here (dedupe on event_id), then answer at once.
return "", 204
Troubleshooting
| What we see | Likely cause |
|---|---|
401 or 403 from your side | Something in front of your code blocked it. A firewall, WAF, bot filter or login wall rejected our request before your code ran. Allow POST from User-Agent: FinMatch-Webhooks/1 on that path. We don't publish fixed IP addresses. |
401 from your code | Wrong secret. The test and live secrets are swapped, or a secret was copied with extra spaces or a newline. |
401 from your code, with the right secret | The body was parsed before verifying. Your framework read the JSON first, and you are verifying re-serialised bytes. Read the raw body. |
401 from your code, now and then | Clock skew. Your server's clock is more than 5 minutes out. Turn on NTP time sync. |
3xx | A redirect (http to https, a trailing slash, or a login page). Give us the final URL. |
| No reply within 10 seconds | Your handler does the work before replying. Reply first. |
| We can't connect | The URL isn't public, the certificate is invalid or expired, or the URL resolves to a private address. |