Gareth Wilson Gareth Wilson

Guide to FusionAuth Webhooks: Features and Best Practices

Published


FusionAuth webhooks notify your application about identity events: a user is created, logs in, resets a password, verifies an email. If you're building on FusionAuth, webhooks are how you react to these events without polling.

This guide covers how FusionAuth webhooks work, the events you'll handle, how to verify the X-FusionAuth-Signature-JWT correctly (the part most implementations get wrong), the transactional delivery model, and the best practices for production.

What are FusionAuth webhooks?

FusionAuth webhooks are JSON POSTs. Signing is opt-in (added in v1.48.0), so an out-of-the-box webhook is unsigned. When signing is enabled, the signature is not a bare HMAC hex string: the X-FusionAuth-Signature-JWT header carries a JWT whose single claim, request_body_sha256, is the base64-encoded SHA-256 of the raw request body. Verification is therefore two steps: validate the JWT, then compare its request_body_sha256 claim against your own hash of the raw body. The signing key may be symmetric (HMAC) or asymmetric (RS256 / EC / EdDSA, with the public key published at the instance's /.well-known/jwks.json).

FusionAuth webhook features

FeatureDetails
ConfigurationSettings > Webhooks; sign events via the Security tab (opt-in, since v1.48.0)
Signature headerX-FusionAuth-Signature-JWT (a JWT, not a bare HMAC)
Signature schemeJWT with a request_body_sha256 claim (base64 SHA-256 of the raw body)
Key typeSymmetric HMAC, or asymmetric RS256 / EC / EdDSA via JWKS
Event fieldNested event.type (dotted)
DeliveryTransactional: a threshold of webhooks must succeed or the triggering operation fails
Also supportsBasic Auth, custom headers, mutual TLS on the same endpoint

Common events

FusionAuth event names are dotted and live at the nested path event.type:

EventFires when
user.create / user.update / user.deleteA user record changes
user.login.success / user.login.failed / user.login.suspiciousA login is attempted
user.password.reset.successA password reset completes
user.registration.createA user registers for an application
user.email.verifiedA user verifies their email
jwt.public-key.update / jwt.refresh-token.revokeJWT key or refresh-token changes
audit-log.create / event-log.create / kickstart.successSystem-level events

Setting up FusionAuth webhooks

Create a signing key under Settings > Key Master (symmetric or asymmetric), enable the events per tenant under Tenants > [Tenant] > Webhooks, then create the endpoint under Settings > Webhooks and turn on Sign events in the Security tab, selecting your key. FusionAuth's own examples use the third-party jose library (Node) or PyJWT (Python) to handle the JWT.

Securing FusionAuth webhooks

Do both steps. Validate the JWT in X-FusionAuth-Signature-JWT (with the shared secret for HMAC keys, or the instance's JWKS for asymmetric keys), then compute the base64 SHA-256 of the raw body and compare it against the JWT's request_body_sha256 claim. Validating the JWT alone is not enough, without the hash comparison, a valid JWT would accept any body.

const crypto = require("crypto");
const jose = require("jose");

const SECRET = process.env.FUSIONAUTH_WEBHOOK_SECRET; // HMAC key
// For asymmetric keys, use jose.createRemoteJWKSet(new URL(`${FUSIONAUTH_URL}/.well-known/jwks.json`))

async function verify(rawBody, signatureJwt) {
  if (!signatureJwt) return false;
  try {
    const key = new TextEncoder().encode(SECRET);
    const { payload } = await jose.jwtVerify(signatureJwt, key, {
      algorithms: ["HS256", "HS384", "HS512"],
    });
    // Step 2: the JWT being valid is not enough, compare the body hash
    const bodyHash = crypto.createHash("sha256").update(rawBody).digest("base64");
    return payload.request_body_sha256 === bodyHash;
  } catch {
    return false;
  }
}

app.post("/webhooks/fusionauth", express.raw({ type: "application/json" }), async (req, res) => {
  if (!(await verify(req.body, req.headers["x-fusionauth-signature-jwt"]))) {
    return res.sendStatus(401);
  }
  res.sendStatus(200); // acknowledge within the transaction threshold
  processQueue.add(JSON.parse(req.body.toString())); // branch on event.type, async
});

The same check in Python (HMAC key shown; use the JWKS public key for asymmetric keys):

import base64
import hashlib
import os
import jwt  # PyJWT

SECRET = os.environ["FUSIONAUTH_WEBHOOK_SECRET"]


def verify(raw_body: bytes, signature_jwt: str) -> bool:
    if not signature_jwt:
        return False
    try:
        payload = jwt.decode(signature_jwt, SECRET, algorithms=["HS256", "HS384", "HS512"])
        body_hash = base64.b64encode(hashlib.sha256(raw_body).digest()).decode()
        return payload.get("request_body_sha256") == body_hash  # step 2 is mandatory
    except jwt.InvalidTokenError:
        return False

FusionAuth webhook limitations and pain points

Validating the JWT is not enough

The Problem: The signature is a JWT whose only claim is a hash of the body. A handler that validates the JWT signature and stops accepts any body at all, because it never checks that the body matches the hash.

Why It Happens: FusionAuth signs a hash of the body, not the body itself.

Workarounds:

  • Always do step two: compute the base64 SHA-256 of the raw body and compare it to the request_body_sha256 claim.

How Hookdeck Can Help: Hookdeck verifies the full scheme at the edge, so your app can't accidentally skip the body-hash comparison.

The key may be asymmetric

The Problem: The signing key can be RS256 / EC / EdDSA, not just HMAC. An implementation that assumes a shared secret fails against a correctly configured asymmetric instance.

Why It Happens: FusionAuth supports asymmetric signing keys, with the public key at /.well-known/jwks.json.

Workarounds:

  • Detect the key type; for asymmetric keys, verify the JWT against the instance's JWKS rather than a shared secret.

How Hookdeck Can Help: Hookdeck handles the key type at the edge, so your app isn't coupled to symmetric vs asymmetric.

The transactional delivery model can break login

The Problem: Per event you choose how many subscribed webhooks must succeed (none / any / majority / two-thirds / all). If the threshold isn't met, FusionAuth returns HTTP 504 to the original API caller and rolls back the database change that triggered the event. A slow or down endpoint can therefore break user login and registration.

Why It Happens: FusionAuth ties the triggering operation to webhook success.

Workarounds:

  • Keep the handler fast and highly available, verify and acknowledge quickly, and defer real work; choose the success threshold deliberately.

How Hookdeck Can Help: Hookdeck accepts and acknowledges deliveries fast at the edge and durably queues them, so your processing speed doesn't put login and registration at risk.

Signing is off by default

The Problem: Webhooks are unsigned out of the box, so an endpoint that assumes a signature will accept unauthenticated requests until signing is enabled.

Why It Happens: Signing was added in v1.48.0 and is opt-in.

Workarounds:

  • Enable signing, and reject deliveries with no valid X-FusionAuth-Signature-JWT.

How Hookdeck Can Help: Hookdeck can enforce verification centrally, so an unsigned misconfiguration doesn't reach your app.

Best practices

Verify the JWT and the body hash

Validate the JWT (shared secret or JWKS), then compare request_body_sha256 against your own base64 SHA-256 of the raw body.

Handle asymmetric keys

For RS256 / EC / EdDSA, verify against the instance's /.well-known/jwks.json.

Acknowledge fast to protect the transaction

Because failures can roll back login and registration, verify and return 2xx quickly, then process asynchronously. See why to process webhooks asynchronously.

Make handlers idempotent

The same event may be delivered more than once, so dedupe and make processing safe to repeat. See our guide to webhook idempotency.

Make FusionAuth webhooks production-ready

Hookdeck verifies the JWT signature, acknowledges fast, deduplicates, and durably queues every event

Conclusion

FusionAuth signs webhooks with an X-FusionAuth-Signature-JWT JWT whose request_body_sha256 claim is the base64 SHA-256 of the raw body, so you must validate the JWT and compare the body hash, or a valid token would accept any body. Handle symmetric and asymmetric (JWKS) keys, and acknowledge fast, because the transactional delivery model can return 504 and roll back login and registration when webhooks are slow.

Hookdeck verifies the signature, acknowledges fast, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique events, without putting logins at risk.

Get started with Hookdeck for free and handle FusionAuth webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.