Verifying Clerk Webhook Requests
3 min read

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.

Why should you verify webhook requests?

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.

An example webhook flow
Simplified diagram of a basic webhook flow

How does it work?

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.

How do I verify a Clerk webhook request?

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).

Here is an example server and some example logic to handle the incoming request and verify it.

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.

Clerk webhook signing secret location
Clerk webhook 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