Test Signal Sciences webhooks locally
Receive Fastly Next-Gen WAF (Signal Sciences) webhook notifications on localhost with a Horizon tunnel, and verify X-SigSci-Signature.
Receive Fastly Next-Gen WAF notifications on your laptop while you build, with a URL the WAF can reach. Next-Gen WAF is the product formerly sold as Signal Sciences. Fastly calls the integration "generic webhooks".
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Fastly account with Next-Gen WAF and a workspace
- A Next.js App Router app
Start your app
Every notification carries an X-SigSci-Signature header. It holds the HMAC-SHA256 (a keyed hash) hex digest of the JSON payload body. The key is the signing key generated when you create the webhook. Fastly doesn't ship a Node helper, so the handler computes the digest with node:crypto and compares in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const signingKey = process.env.SIGSCI_SIGNING_KEY;
if (!signingKey) {
return new Response("Missing SIGSCI_SIGNING_KEY", { status: 500 });
}
const body = await request.text();
const received = request.headers.get("x-sigsci-signature") ?? "";
const expected = createHmac("sha256", signingKey).update(body).digest("hex");
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const notification = JSON.parse(body);
console.log(`Received Next-Gen WAF notification: ${notification.type}`);
return new Response("ok", { status: 200 });
}Notifications are JSON with created, type, payload, link and workspaceId fields.
Store the signing key in an environment variable. You copy it from Fastly in a later step.
SIGSCI_SIGNING_KEY=replace-with-your-signing-keyStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your webhook would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. Keep this terminal open.
Add the webhook in Fastly
- Log in to the Fastly control panel and go to Security, Next-Gen WAF, Workspaces.
- Select the gear next to the workspace.
- Select Alerts, then Add alert.
- From the Integration type menu, select Webhook.
- In the Webhook URL field, enter
https://my-app.hrzn.run/api/webhooks/signalsciences. - In the Select activities menu, leave Flagged IPs checked or pick the activities you want.
- Select Add workspace alert.
If you use the Next-Gen WAF control panel instead, add a Generic Webhook site integration from Site Integrations.
Copy the signing key
Open the alert again. Select Reveal to see the full value of the signing key, and copy it into SIGSCI_SIGNING_KEY. Restart npm run dev.
The Rotate button creates a new key. You don't need to select Update workspace alert to save a rotated key. Update SIGSCI_SIGNING_KEY after you rotate.
Trigger a notification
Fastly documents no test button and no resend for generic webhooks. A notification fires when the WAF records one of the activities you selected, for example a flagged IP.
To check your handler without waiting, send a signed request yourself. This uses the openssl command from Fastly's guide, and the request goes through the tunnel like Fastly's would.
BODY='{"type":"flag","payload":{},"workspaceId":"test"}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SIGSCI_SIGNING_KEY" | awk '{print $NF}')
curl -X POST https://my-app.hrzn.run/api/webhooks/signalsciences \
-H "Content-Type: application/json" \
-H "X-SigSci-Signature: $SIGNATURE" \
-d "$BODY"Check it works
Your Horizon terminal prints one line for each notification:
POST 200 /api/webhooks/signalsciencesYour app terminal prints Received Next-Gen WAF notification: and the type. The curl command above prints ok.
Troubleshooting
The signature doesn't match
- Check that
SIGSCI_SIGNING_KEYis the current signing key of this alert. Rotating creates a new one. - Compute the digest over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - Restart
npm run devafter you edit.env.local.
No notification arrives
Fastly sends one only when an activity you selected happens. Check the activities on the alert. Then check the Horizon request log to see whether a request reached the tunnel.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.
Nothing reaches your app
- Check that the Horizon terminal is still running. If its last line is
Connection lost. Reconnecting…, wait forReconnected. - Check that the Webhook URL ends with
/api/webhooks/signalsciences.
Next steps
- Read Fastly's generic webhooks guide for the full list of activity types.
Test Okta event hooks locally
Receive Okta Event Hook requests on localhost with a Horizon tunnel, answer the one-time verification GET, and check the Authorization header.
Test Trend Micro webhooks locally
Receive Trend Micro Cloud One Conformity webhook notifications on localhost with a Horizon tunnel, and verify X-TrendMicro-Signature.