This is a brief tutorial and not a full discussion about webhook security and vulnerabilities. If you would like to learn more checkout the Svix Docs for some awesome resources.
This is a pretty straighforward answer. A webhook works by sending an HTTP POST request to a given endpoint. If you create this endpoint, any other user of your app could access this endpoint as well. In order to prevent this we want to verify the incoming reuqest for this endpoint to verify that it is, in fact, coming from a webhook and not a malicious user.
Another vulnarability to consider is a replay attack. According to the Svix Docs, "A replay attack is when an attacker intercepts a valid payload (including the signature), and re-transmits it to your endpoint. This payload will pass signature validation, and will therefore be acted upon."
There are several other ways that attackers could mess with your system through a webhook endpoint such as payload tampering, sensitive data exposure, and DDoS attacks.
To address the first problem and to mitigate the risk of many other types of malicious attacks, a valid webhook request is cryptographically signed. This can be validated with a corresponding signing secret/key.
To addess the second problem, a replay attack, a valid webhook request contains a timestamp as part of the cryptographic signature of when it was valid. If this timestamp is outside of a given range of time (ex. ±5 mins) the request is automatically rejected.
In this example, we will be verify incoming Clerk Webhook requests using the svix library for Node.js due to the fact that Clerk's webhooks are built on svix's platform. This example is from a backend I wrote for a mobile app, in this case, an express server (Svix has examples for tons of frameworks and languages).
You can setup a basic express server like this.
// server.ts
import { json, urlencoded } from "body-parser";
import express from "express";
import morgan from "morgan";
import cors from "cors";
export const createServer = () => {
const app = express();
app.disable("x-powered-by")
.use(morgan("dev"))
.use(urlencoded({ extended: true }))
// .use(json())
.use(cors());
return app;
};
In this server config, note that the json function from bodyparser is commented out. This is becuase the cryptographic signature of the incoming webhook request is very sensitive and if the body is parsed before being given to the verification library, things break. In the example below, the Svix library handles the body parsing for us into JSON.
Next, you'll need to get the signing secret to your given Clerk webhook. This can be found in the dashboard under the webhooks section. You'll obviously need a webhook so create one if you haven't already. Then click into your given webhook from the list. Then you'll find the signing secret.
Make sure to copy and paste this value into an environment variable.
Next we can install the Svix library via
pnpm add svix
Below is an example endpoint that your webhook can call. This endpoint will verify the request to make sure it is actually from your Clerk webhook and it will parse the incoming request body for you to use.
// index.ts
import { Webhook } from "svix";
import { createServer } from "./server";
const port = process.env.PORT || 3000;
export const app = createServer();
app.listen(port, () => {});
...
app.post(
"/user-create-webhook",
//give the verification library a raw request body
bodyParser.raw({ type: "application/json" }),
async (req, res) => {
const payload = req.body;
const headers = req.headers;
//pass in the secret and intialize a webhook
const wh = new Webhook(env.CLERK_CREATE_USER_WEBHOOK_SECRET);
//message variable for later use with the data
let msg: any;
try {
/*the below ts-ignore is there becuase the verify method
doesn't like the default express
IncomingHttpHeaders type. */
//verifies and parses body, throws error if invalid
//@ts-ignore
msg = wh.verify(payload, headers);
} catch (err) {
//throw an error when not a webhook request
res.status(400).json(err);
}
//use the msg for anything you want
...
//example creating user record in my own db
const data = msg.data;
await prisma.user.create({
data: {
id: data.id,
first_name: data.first_name,
pfp_url: data?.image_url,
},
});
res.json({});
}
);
If the incomning request is in fact from Clerk and it is verified, the request body should follow a schema like this.
I hope this was helpful to at least future me and maybe you too.
-LPM