Securing your webhook endpoint
Your webhook URL is public. Anyone who finds it can POST to it. The signature on every delivery is what separates a real event from a forgery, so verifying it is not optional. It is the security model.
Three rules cover almost everything:
- Verify the signature before you read the body. Reject anything that fails.
- De-duplicate on the envelope
id. The same event can legitimately arrive more than once. - Treat the body as a notification, not as truth. Fetch the object from the API before you act on money.
There are no shared secrets in this scheme. We never send you a token, an API key or a password, and you never store one for us.
What is signed
Every delivery carries an RFC 9421 HTTP Message Signature over these components, in this order:
| Component | Value |
|---|---|
@method | POST |
@authority | The host you registered, as it appears in the request |
@request-target | The path and query you registered |
content-digest | RFC 9530 SHA-256 digest of the exact request body |
webhook-delivery-id | The Webhook-Delivery-Id header |
webhook-attempt | The Webhook-Attempt header |
webhook-group-id | The Webhook-Group-Id header |
Because the body's digest is signed, the body is tamper-evident. Because the three Webhook-* headers are signed, so are they: the delivery id you quote to support cannot have been forged.
Headers on every delivery
| Header | Meaning |
|---|---|
Content-Type | Always application/json |
Webhook-Delivery-Id | The delivery record's id. Your correlation handle when you contact support |
Webhook-Attempt | Attempt counter for this delivery, starting at 1. Replays increment it |
Webhook-Group-Id | The auth group the endpoint belongs to |
Content-Digest | sha-256=:<base64 of SHA-256(body)>: |
Signature-Input | Covered components, created, keyid and alg, under the label sig1 |
Signature | sig1=:<base64 signature>: |
Deliveries carry no internal trace headers and no customer data beyond the payload.
Parameters
| Parameter | Value |
|---|---|
| Label | sig1 |
alg | rsa-pss-sha256 (RSASSA-PSS with SHA-256, MGF1, salt length 32; spelled PS256 in the JWK) |
keyid | The RFC 7638 JWK thumbprint of the signing public key |
created | Unix seconds at the moment that attempt was signed |
| Key size | RSA, 2048–4096 bits (3072 today). Read the size from the key set rather than hard-coding it |
This is the same signing scheme, and the same algorithm, that you already use when you sign requests to the Client API. If you have implemented request signing, you have most of this code.
Getting our public keys
Fetch the key set from the Client API host:
GET https://api.sandbox.ibanq.com/.well-known/jwks.jsonGET https://api.ibanq.com/.well-known/jwks.jsonIt is anonymous (no token, no subscription key) and returns an RFC 7517 JWK Set:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "PS256",
"kid": "70WOedgzv6hJ63CiLF9TDh6L5XBoL7c71svrGglv65o",
"n": "sN1Yq...",
"e": "AQAB"
}
]
}kid is the same string the delivery puts in keyid, so matching one to the other is a dictionary lookup. If you compute RFC 7638 thumbprints yourself, you get the identical value.
Rules for handling the key set:
- Cache it, honouring
Cache-Control(300 seconds by default) andETag/If-None-Match. - Never pin a single key. During a rotation, and for 30 days after it, two keys are published.
- On a
keyidyou do not recognise, refetch once (rate-limited; once a minute is plenty) rather than failing permanently. A new key is always published before it starts signing, so an unknown id means your cache is stale. - Fetch per environment. Sandbox and production keys are different keys.
- A
503from this endpoint means no signing key is configured in that environment. Do not cache it.
Key rotation
Rotation is annual, with a 30-day overlap, and needs nothing from you if you follow the rules above:
| Step | What is published | What signs |
|---|---|---|
| Staging, 30 days ahead | Old key and new key | Old key |
| Switch | Both keys | New key |
| Retire, 30 days later | New key | New key |
An emergency rotation compresses the first two steps and waits one cache lifetime before the new key signs. A client that caches the key set correctly never sees an interruption.
Verifying a delivery
- Read the raw request body as bytes. Do not parse and re-serialise it first. The digest is over the exact bytes we sent, and any re-encoding breaks it, down to a JSON library rewriting
&as\u0026. - Check
Content-Digest. Computesha-256over the raw body, base64-encode, and compare with a constant-time comparison against the value between the colons. - Parse
Signature-Input. Take the covered component list,created,keyidandalgfrom thesig1entry. - Check
algisrsa-pss-sha256and thatcreatedis within ±5 minutes of your clock. Every attempt is freshly signed, so this window only has to absorb clock skew, not delivery delay. - Find the key whose
kidequalskeyid. If you do not have it, refetch the key set once, then reject. - Rebuild the signature base and verify. One line per covered component, in the order they are listed, each line
"<component>": <value>terminated by a newline. Derived components come from the request:@methoduppercased,@authoritythe host,@request-targetthe path including any query string. Header components take the header value as received. Then one last line,"@signature-params":followed by everything aftersig1=in theSignature-Inputheader, with no trailing newline. Verify that string, UTF-8 encoded, with RSASSA-PSS/SHA-256 against the base64 value between the colons inSignature.
Reject with 401 and process nothing if any step fails.
A verification bug looks exactly like an attackA rejected delivery is a failed delivery. Ten consecutive failures open the circuit breaker, and five days without a single success disables the endpoint until your IFX Payments representative re-enables it. Prove verification end to end in the sandbox before you register a production URL.
The signature base, in full
For the example delivery on the Webhooks page, the exact string that gets signed is:
"@method": POST
"@authority": webhooks.example.com
"@request-target": /your/webhook/path
"content-digest": sha-256=:rPoQRJ3l9Wiod0Y+++RfM+DU/KrKF19OAVD+7e8F4k4=:
"webhook-delivery-id": 4a2f8c61-9b3d-4e77-a0c5-5d1e8f2b7c30
"webhook-attempt": 1
"webhook-group-id": 7c9e5b12-4f68-4a3d-9e21-0b5c6d7e8f90
"@signature-params": ("@method" "@authority" "@request-target" "content-digest" "webhook-delivery-id" "webhook-attempt" "webhook-group-id");created=1758100462;keyid="70WOedgzv6hJ63CiLF9TDh6L5XBoL7c71svrGglv65o";alg="rsa-pss-sha256"
Every line except the last ends in \n. The last line does not.
Mind the proxy
@authorityis rebuilt from theHostheader your application sees. A load balancer, tunnel or ingress that rewritesHostwill break verification for every delivery, because the signature was computed over the host we sent to. If everything fails with "signature invalid" but the digest checks out, this is almost always why. The same applies to anything that rewrites the path, and to middleware that trims or re-encodes the body.
Example: Node.js
import crypto from 'node:crypto';
import express from 'express';
const KEY_SET_URL = 'https://api.sandbox.ibanq.com/.well-known/jwks.json';
const TOLERANCE_SECONDS = 300;
let keys = new Map();
let lastAttemptAt = 0;
let etag = null;
async function loadKeys() {
if (Date.now() - lastAttemptAt < 60_000) return; // at most one fetch a minute, success or failure
lastAttemptAt = Date.now(); // stamp before the call: a failing key set must not hot-loop
const response = await fetch(KEY_SET_URL, etag ? { headers: { 'if-none-match': etag } } : undefined);
if (response.status === 304 || !response.ok) return; // 304 means the cache is still good
etag = response.headers.get('etag');
const document = await response.json();
keys = new Map(document.keys.map(jwk => [jwk.kid, crypto.createPublicKey({ key: jwk, format: 'jwk' })]));
}
function parseSignatureInput(header) {
const params = header.slice('sig1='.length); // ("@method" ...);created=...;keyid="...";alg="..."
const close = params.indexOf(')');
const components = params.slice(1, close).split(' ').map(c => c.replaceAll('"', ''));
const values = Object.fromEntries(
params.slice(close + 2).split(';').map(pair => {
const eq = pair.indexOf('='); // split on the first = only: a value can contain =
return [pair.slice(0, eq), pair.slice(eq + 1).replaceAll('"', '')];
}));
return { params, components, ...values };
}
function digestMatches(body, header) {
const expected = `sha-256=:${crypto.createHash('sha256').update(body).digest('base64')}:`;
const a = Buffer.from(header ?? '');
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
async function verify(req) {
const body = req.body; // a Buffer: the raw bytes, unparsed
if (!digestMatches(body, req.get('content-digest'))) return 'digest mismatch';
const input = req.get('signature-input');
const signature = req.get('signature');
if (!input?.startsWith('sig1=') || !signature?.startsWith('sig1=:') || !signature.endsWith(':')) {
return 'missing sig1';
}
const { params, components, created, keyid, alg } = parseSignatureInput(input);
if (alg !== 'rsa-pss-sha256') return 'unexpected alg';
if (Math.abs(Math.floor(Date.now() / 1000) - Number(created)) > TOLERANCE_SECONDS) return 'created outside tolerance';
if (!keys.has(keyid)) await loadKeys(); // a rotation publishes before it signs
const key = keys.get(keyid);
if (!key) return `unknown keyid ${keyid}`;
let base = '';
for (const component of components) {
const value = component === '@method' ? req.method.toUpperCase()
: component === '@authority' ? req.get('host')
: component === '@request-target' ? req.originalUrl
: req.get(component);
if (value === undefined) return `covered header ${component} missing`;
base += `"${component}": ${value}\n`;
}
base += `"@signature-params": ${params}`;
const ok = crypto.verify(
'sha256',
Buffer.from(base, 'utf8'),
{ key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST },
Buffer.from(signature.slice('sig1=:'.length, -1), 'base64'));
return ok ? null : 'signature invalid';
}
const app = express();
await loadKeys();
app.post('/your/webhook/path', express.raw({ type: 'application/json' }), async (req, res) => {
const failure = await verify(req);
if (failure) {
console.warn('rejected webhook', failure);
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
await enqueue(event); // persist by event.id, process out of band
res.sendStatus(200); // answer fast; any 2xx counts
});Example: Python
from __future__ import annotations
import base64, hashlib, hmac, json, time, urllib.error, urllib.request
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
KEY_SET_URL = "https://api.sandbox.ibanq.com/.well-known/jwks.json"
TOLERANCE_SECONDS = 300
_keys: dict[str, rsa.RSAPublicKey] = {}
_attempted_at = 0.0
_etag: str | None = None
def _b64url(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def load_keys() -> None:
global _keys, _attempted_at, _etag
if time.time() - _attempted_at < 60: # at most one fetch a minute, success or failure
return
_attempted_at = time.time() # stamp before the call: a failing key set must not hot-loop
request = urllib.request.Request(KEY_SET_URL)
if _etag:
request.add_header("If-None-Match", _etag)
try:
with urllib.request.urlopen(request, timeout=5) as response:
_etag = response.headers.get("ETag")
document = json.load(response)
except urllib.error.HTTPError as error:
if error.code == 304: # the cache is still good
return
raise
_keys = {
jwk["kid"]: rsa.RSAPublicNumbers(
int.from_bytes(_b64url(jwk["e"]), "big"),
int.from_bytes(_b64url(jwk["n"]), "big"),
).public_key()
for jwk in document["keys"]
}
def verify(method: str, authority: str, request_target: str, header, body: bytes) -> str | None:
"""Returns None when the delivery is genuine, otherwise the reason it is not."""
expected = "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
if not hmac.compare_digest(header("content-digest") or "", expected):
return "digest mismatch"
signature_input = header("signature-input") or ""
signature = header("signature") or ""
if not signature_input.startswith("sig1=") or not signature.startswith("sig1=:") or not signature.endswith(":"):
return "missing sig1"
params = signature_input[len("sig1="):]
close = params.index(")")
components = [c.strip('"') for c in params[1:close].split()]
values = dict(
pair.split("=", 1) for pair in params[close + 2:].split(";")
)
values = {k: v.strip('"') for k, v in values.items()}
if values.get("alg") != "rsa-pss-sha256":
return "unexpected alg"
if abs(time.time() - int(values.get("created", 0))) > TOLERANCE_SECONDS:
return "created outside tolerance"
key_id = values.get("keyid")
if key_id not in _keys:
try:
load_keys() # a rotation publishes before it signs
except Exception:
return "key set unavailable" # fail closed: answer 5xx and let the retry ladder bring it back
key = _keys.get(key_id)
if key is None:
return f"unknown keyid {key_id}"
lines = []
for component in components:
if component == "@method":
value = method.upper()
elif component == "@authority":
value = authority
elif component == "@request-target":
value = request_target
else:
value = header(component)
if value is None:
return f"covered header {component} missing"
lines.append(f'"{component}": {value}')
base = "\n".join(lines) + f'\n"@signature-params": {params}'
try:
key.verify(
base64.b64decode(signature[len("sig1=:"):-1]),
base.encode("utf-8"),
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32),
hashes.SHA256(),
)
except Exception:
return "signature invalid"
return NoneIn Flask, wire it up like this. Note the call to get_data() before anything parses the request, so you are holding the original bytes:
from flask import Flask, request
app = Flask(__name__)
load_keys()
@app.post("/your/webhook/path")
def receive():
failure = verify(
request.method,
request.host, # the host we sent to; see "Mind the proxy" above
request.full_path.rstrip("?"), # path and query, exactly as registered
request.headers.get,
request.get_data(), # raw bytes, before any JSON parsing
)
if failure:
app.logger.warning("rejected webhook: %s", failure)
return "", 401
event = json.loads(request.get_data())
enqueue(event) # persist by event["id"], process out of band
return "", 200 # answer fast; any 2xx countsReplay and duplicate protection
created is stamped at the moment of each attempt, not when the business event happened, so a captured delivery cannot be re-sent to you later: it falls outside the five-minute window. That is the protection against an outsider replaying traffic.
Duplicates from us are a normal, expected thing and are not an attack:
- an attempt that timed out may have succeeded on your side, and we will send it again;
- support can replay a delivery (same
id, higherWebhook-Attempt); - during a version migration you receive the same business event under both type names, with the same
id.
Key on the envelope id and make your handler idempotent. See Building a receiver.
Hardening your endpoint
- Terminate TLS with a publicly trusted certificate. We will not connect over plain HTTP, and we do not follow redirects, so an
http→httpsredirect is a failed delivery, not a recovered one. - Do not allowlist by source IP. We do not publish a fixed source address range; the signature is the authentication.
- Do not put credentials or tokens in the URL. URLs with user info are rejected at registration, and query-string secrets end up in your own access logs.
- Answer fast. Verify, persist, return
2xx, then do the work. The timeout is 15 seconds. - Do not log the raw body at debug level in production. Payloads carry account identifiers and amounts.
- Rate-limit your key-set refetch. An endpoint that refetches per delivery turns a bad
keyidinto a hot loop. - Fail closed. If verification cannot run (key set unreachable, no key configured), reject with
5xxand let the retry ladder bring the event back. Never process an unverified delivery.
Updated about 2 months ago