Verify Stripe webhooks in Remix on Cloudflare Pages
November 15, 2022
Remix.run is a great framework that just gets out of your way, and one of the things I love about Remix is that it allows you to choose from many different hosting providers. I'm a big fan of what they're doing at Cloudflare, so I decided to deploy to Cloudflare Pages.
That decision has, at times, made me question my life choices 😅 the leap from Node.js to Workers has been a big one. The main problem I've faced has been finding solutions to my issues. Community usage of Cloudflare Pages is well below that of fly.io for Remix sites. Fly is a Node runtime, whereas Cloudflare Pages is a Worker runtime.
One of the things I struggled and scratched my head about was how to verify a Stripe webhook. I found a few posts about how to make it work with Cloudflare Workers. So that's the first step. But then I ran into some more errors I couldn't solve the usual way. So I thought I'd gather it in one place in case anyone else runs into these issues.
My solution is a bit roundabout, I'll first show you the wrong way to do things, and then I'll show you the right way to solve it.
Init Stripe library
First, to initialise the Stripe library in Remix on Cloudflare Pages, you'll have to do some things differently. Since environment variables aren't picked up globally like they'd be in Node.js on the good old process.env
object, you'll have to use the context
object that's passed into every loader and action function.
Start by adding your Stripe secrets to the .dev.vars
file.
PUBLIC_STRIPE_KEY=<your-stripe-public-key>
PRIVATE_STRIPE_SECRET=<your-stripe-secret>
These variables will come in on the context
object like this: context.PUBLIC_STRIPE_KEY
and context.PRIVATE_STRIPE_SECRET
. You must pass these into a function that initialises Stripe rather than just reading the environment variables directly.
Create a stripe.ts file
Create a folder called lib
and add a new file there called stripe.ts
(or .js if you're not on the TS train). You will need two functions, one to initialise Stripe on the server and the other for the client. In this walkthrough, I'll only show you the server version; the client one is more or less the same but without the httpClient
property.
import Stripe from "stripe";
export function initServerStripe({ stripeSecret }: { stripeSecret: string }) {
return new Stripe(stripeSecret, {
apiVersion: "2022-08-01",
httpClient: Stripe.createFetchHttpClient(), // <-- important for Worker support
});
}
Create the action function
To handle webhook events, you need to create an action function. I placed mine in a folder inside the routes
directory and called it api
in that folder, I made another folder called events
and finally made a file called payments.ts
. That gives the following URL path: /api/events/payments
.
A Remix action will accept POST requests, so you can use this one to handle webhook events from Stripe. Now initialise Stripe in that action.
import type { ActionArgs } from "@remix-run/cloudflare";
export async function action({ request, context }: ActionArgs) {
const stripeSecret = context.PRIVATE_STRIPE_SECRET as string;
const stripe = initServerStripe({ stripeSecret });
// more code to come
}
Verify the webhook signature
For security reasons, you should verify the webhook signature before you handle the event body. Otherwise, anyone could start messing with your payment system.
This is where I take you for a spin and show you the wrong way before I show you the right way. You can skip it if you want, but I include it because I kept chasing down that path, using the official Stripe lib to verify the webhook. Stripe claims to have support for Workers after all, but it seems they've missed a spot.
So here goes. The "official" way, which should be the right way, but is...
The wrong way
To verify the webhook signature, you'll need to override the default crypto lib that Stripe uses. First, go back to the stripe.ts
file and add the following export:
export const webCrypto = Stripe.createSubtleCryptoProvider();
Import that export in your payments.ts
file. Also, add your webhook secret to the .dev.vars
file. I called it PRIVATE_STRIPE_WEBHOOK_SECRET
in the example below. Then you can verify the webhook with the following:
export async function action({ request, context }: ActionArgs) {
const stripeSecret = context.PRIVATE_STRIPE_SECRET as string;
const webhookSecret = context.PRIVATE_STRIPE_WEBHOOK_SECRET as string;
// Retrieve the event by verifying the signature using the raw body and secret.
let event;
let signature = request.headers.get("stripe-signature") as string;
const payload = await request.text();
const stripe = initServerStripe({ stripeSecret });
try {
event = await stripe.webhooks.constructEventAsync(
payload,
signature,
webhookSecret,
undefined,
webCrypto, // <-- this is where you override the crypto implementation
);
} catch (err) {
return json(null, 400);
}
// Extract the object from the event.
const data = event.data;
const eventType = event.type;
// handle the Stripe event data based on type. The rest should be more or less like the official Stripe docs
// don't forget to return a success response to Stripe so they don't disconnect the webhook.
}
Easy hey? Well, nope. That's when I started getting errors about Buffer not being defined.
Uncaught ReferenceError: Buffer is not defined
Great.
After some digging, it turns out you can add this flag to the dev:wrangler
script in package.json
: --node-compat
and the error goes away for development, at least.
But what about production?
Of course, that didn't work for production. So how do you get it working in prod? Well, that's why the above is the wrong way. Adding a `--node-compat` flag to something that isn't a node environment is a bit risky. I also tried using some node polyfills for Remix, but none seemed to solve the problem.
So, it's time to dig into...
The right way
You will need to verify the webhook signatures manually. 😱
It's alright. I'll walk you through it. First, read this section over at the official Stripe docs. Helpful? Somewhat. But it lacks some code examples. That's what this whole post is about.
First, go back over to your payments.ts
file and create an async function called constructEventAsync
. We give it the same name as Stripe's internal lib for 2 reasons: 1) we want to show it works more or less the same way, and 2) it makes it easier to drop the Stripe lib back in if they end up fixing Worker support for this issue.
I'll give you some code here. A fair bit is going on, so read it carefully. You want to understand this before you copy-paste it into your project.
/**
* A manual implementation of Stripe's signature verification. Works with Workers.
* @param payload the request payload (derived from request.text())
* @param signature the provided webhook signature from the request headers
* @param webhookSecret the webhook secret provided by Stripe.
* @returns reconstructed payload
*/
async function constructEventAsync(
payload: string,
signature: string,
webhookSecret: string
) {
const elements = signature.split(","); // split the signature on the comma (,) to get each element. You then go on to split it on = to get the key value pairs. I used a forEach because I find them more readable than Array.reduce, but if that's more your thing, go for it.
const parts: Record<string, string> = {}; // you can type this out more strictly if you like. It does the trick though.
elements.forEach((element: string) => {
// get the key value pairs and build out the parts object
const elementParts = element.split("=");
parts[elementParts[0]] = elementParts[1];
});
// prepare the payload for signing. The payload should be a string of the request payload. It should be prefixed by the provided timestamp and separated by a dot (.).
const signedPayload = `${parts.t}.${payload}`;
// now verify the payload.
const verified = await verifyData(signedPayload, webhookSecret, parts.v1);
// Calculate how much time has passed. Feel free to change this up to match your needs.
const elapsed = Math.floor(Date.now() / 1000) - Number(parts.t);
const tolerance = 300; // tolerance value for how much time you tolerate to have passed since the webhook was signed and sent.
// if the webhook is verified and within time tolerance parse and return the JSON payload.
if (verified && !(tolerance && elapsed > tolerance)) {
const jsonPayload = JSON.parse(payload);
return jsonPayload;
}
// otherwise throw an error
throw new Error("Couldn't verify signature");
}
/**
* @param secret webhook secret. used to import the key to sign and verify the webhook signature
*/
async function importKey(secret: string) {
// You'll use WebCrypto which is supported by Workers, it doesn't even require an import statement.
return await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
/**
* Verifies the provided signature by signing the payload with the timestamp provided
* and comparing the signature to the provided signature.
* @param signedPayload the payload from Stripe prefixed with the timestamp and `.`
* @param secret Webhook secret.
* @param providedSig Webhook signature provided by the caller
* @returns boolean
*/
async function verifyData(
signedPayload: string,
secret: string,
providedSig: string
) {
const key = await importKey(secret);
// the verify function returns a Promise<boolean>
const verified = await crypto.subtle.verify(
"HMAC",
key,
hexToUint8Array(providedSig), // transform the provided signature to a Uint8 Array
new TextEncoder().encode(signedPayload) // encode the signed payload
);
return verified;
}
/**
* Transforms a hex into a Uint8Array
* @param hex a hex string to turn into Uint8Array
* @returns
*/
function hexToUint8Array(hex: string) {
const bytes = new Uint8Array(Math.ceil(hex.length / 2));
for (let i = 0; i < bytes.length; i++)
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
return bytes;
}
Now in your loader function, you need to change from using the Stripe implementation of constructEventAsync
to the one you just created.
import type { ActionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
export async function action({ request, context }: ActionArgs) {
const webhookSecret = context.PRIVATE_STRIPE_WEBHOOK_SECRET as string;
// Retrieve the event by verifying the signature using the raw body and secret.
let event;
let signature = request.headers.get("stripe-signature") as string;
const payload = await request.text();
try {
event = await constructEventAsync(payload, signature, webhookSecret);
} catch (err: any) {
return json({ error: JSON.stringify(err), message: err.message }, 400);
}
// Extract the object from the event.
const data = event.data;
const eventType = event.type;
// handle stripe event
return json(null, 200);
}
Wrap up
This is where I'd normally do a summary, but since most of the useful stuff here is in code, I'll leave you with a few parting words. Remix is a lot of fun, and Cloudflare is awesome, but the community is lagging, and so is official support by many big libs. There's very little help online. So I'm committing to improving that as much as I can. When I run into Worker-related issues (both using Remix and Shopify Hydrogen), I'll write about my solution to the issue here. Hopefully, it will help someone else in the future.
Disclaimer
I'm not a security or web crypto expert. Please use this at your own risk and let me know if you find any issues with this solution. Come at me on Twitter (if it still exists) @BeppeKarlsson.