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:

  1. Verify the signature before you read the body. Reject anything that fails.
  2. De-duplicate on the envelope id. The same event can legitimately arrive more than once.
  3. 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:

ComponentValue
@methodPOST
@authorityThe host you registered, as it appears in the request
@request-targetThe path and query you registered
content-digestRFC 9530 SHA-256 digest of the exact request body
webhook-delivery-idThe Webhook-Delivery-Id header
webhook-attemptThe Webhook-Attempt header
webhook-group-idThe 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

HeaderMeaning
Content-TypeAlways application/json
Webhook-Delivery-IdThe delivery record's id. Your correlation handle when you contact support
Webhook-AttemptAttempt counter for this delivery, starting at 1. Replays increment it
Webhook-Group-IdThe auth group the endpoint belongs to
Content-Digestsha-256=:<base64 of SHA-256(body)>:
Signature-InputCovered components, created, keyid and alg, under the label sig1
Signaturesig1=:<base64 signature>:

Deliveries carry no internal trace headers and no customer data beyond the payload.

Parameters

ParameterValue
Labelsig1
algrsa-pss-sha256 (RSASSA-PSS with SHA-256, MGF1, salt length 32; spelled PS256 in the JWK)
keyidThe RFC 7638 JWK thumbprint of the signing public key
createdUnix seconds at the moment that attempt was signed
Key sizeRSA, 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.json
GET https://api.ibanq.com/.well-known/jwks.json

It 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) and ETag/If-None-Match.
  • Never pin a single key. During a rotation, and for 30 days after it, two keys are published.
  • On a keyid you 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 503 from 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:

StepWhat is publishedWhat signs
Staging, 30 days aheadOld key and new keyOld key
SwitchBoth keysNew key
Retire, 30 days laterNew keyNew 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

  1. 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.
  2. Check Content-Digest. Compute sha-256 over the raw body, base64-encode, and compare with a constant-time comparison against the value between the colons.
  3. Parse Signature-Input. Take the covered component list, created, keyid and alg from the sig1 entry.
  4. Check alg is rsa-pss-sha256 and that created is within ±5 minutes of your clock. Every attempt is freshly signed, so this window only has to absorb clock skew, not delivery delay.
  5. Find the key whose kid equals keyid. If you do not have it, refetch the key set once, then reject.
  6. 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: @method uppercased, @authority the host, @request-target the path including any query string. Header components take the header value as received. Then one last line, "@signature-params": followed by everything after sig1= in the Signature-Input header, with no trailing newline. Verify that string, UTF-8 encoded, with RSASSA-PSS/SHA-256 against the base64 value between the colons in Signature.

Reject with 401 and process nothing if any step fails.

❗

A verification bug looks exactly like an attack

A 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

@authority is rebuilt from the Host header your application sees. A load balancer, tunnel or ingress that rewrites Host will 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 None

In 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 counts

Replay 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, higher Webhook-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 → https redirect 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 keyid into a hot loop.
  • Fail closed. If verification cannot run (key set unreachable, no key configured), reject with 5xx and let the retry ladder bring the event back. Never process an unverified delivery.
❗

One URL, one purpose

Do not point your webhook URL at a shared inbox that also accepts unsigned traffic. Every handler behind that path must enforce the same verification, or the weakest one defines your security.


Did this page help you?