Guide to FusionAuth Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Configuration | Settings > Webhooks; sign events via the Security tab (opt-in, since v1.48.0) |
| Signature header | X-FusionAuth-Signature-JWT (a JWT, not a bare HMAC) |
| Signature scheme | JWT with a request_body_sha256 claim (base64 SHA-256 of the raw body) |
| Key type | Symmetric HMAC, or asymmetric RS256 / EC / EdDSA via JWKS |
| Event field | Nested event.type (dotted) |
| Delivery | Transactional: a threshold of webhooks must succeed or the triggering operation fails |
| Also supports | Basic Auth, custom headers, mutual TLS on the same endpoint |
Common events
FusionAuth event names are dotted and live at the nested path event.type:
| Event | Fires when |
|---|---|
user.create / user.update / user.delete | A user record changes |
user.login.success / user.login.failed / user.login.suspicious | A login is attempted |
user.password.reset.success | A password reset completes |
user.registration.create | A user registers for an application |
user.email.verified | A user verifies their email |
jwt.public-key.update / jwt.refresh-token.revoke | JWT key or refresh-token changes |
audit-log.create / event-log.create / kickstart.success | System-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_sha256claim.
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.