EDIT: @NigelG has commented with a feature that Bubble removed from the docs but still works. Simply append ?api_token=yourAPIToken to all of your Stripe webhook URLs. The API token is generated in the API section of the editor. Appending api_token= with a valid API token will authenticate any workflow API request as an Admin. As @dorilama points out, this is likely stored in plain text in Stripe which means if Stripe gets compromised your admin key is too.
Based on everything contributed in this post and thread, my current choice of security for Stripe in most apps is: Check event ID + creation time and validate IP addresses.
Old Post
Iâve been doing a lot of app audits lately and the most common issue is unauthenticated and easily exploitable Stripe webhooks so I thought Iâd do a little writeup.
To summarise, we need Stripe to call Bubble backend workflows so that we can do useful things when events happen on Stripe. However, thereâs not an easy way to prove that the request came from Stripe rather than an imposter sending a fake API request. An imposter can use a fake request to pretend that things happened on Stripe when they didnât - for example, subscription creations/updates, refunds, payouts and more.
The goal of this post is to provide a few useful solutions as I couldnât find a forum post specifically dedicated to Stripe webhook security solutions with clear tips. The solutions provided here do not require external plugins or APIs and can be implemented natively in Bubble.
As always, more security is better than less. Just implementing one of these solutions is less effective than implementing some or all but thatâs a judgement for you to make based on the specifics of your app and how severe any exploits could be to the business.
Check event ID
This improvement is dead simple to implement. Youâll have noticed that all Stripe webhooks begin with some data:
"id": "evt_1NlpmAK58DugTywsYmaXLkWy",
"object": "event",
"api_version": "2018-05-21",
"created": 1693645554,
Every time Stripe sends a webhook, it sends the ID of the relevant event (evt_xxxxxx). An event is essentially just something that âhappensâ inside Stripe - a subscription creation, a product description being changed, a refund being issued - these are all events.
Stripe allows us to check an event ID is valid with a simple API call:
If the event ID exists, it will return the event, and if it doesnât, it will return an error.
So, as a bare minimum, we can check if the event specified in the request is valid every time a Stripe webhook is called. Below, we check the event, and if the API call returns an error, we know the event ID is invalid so terminate the workflow.
This is easy to implement. The flaw is that in some implementations of Stripe, it may be possible for a client to discover an event ID. None of the API calls Iâve ever used in the front end return an event ID, but there could well be one so just be wary of that. If an imposter can find out just one event ID, they can use that in any API request to authenticate themselves. A way to limit the effect of this would be to check the time of the event. If the time (âcreatedâ) of the event was more than say, 60 seconds ago, we know itâs an old event and should treat it as invalid and terminate the workflow accordingly.
So, this is at least one step better than no security, but we can still add more.
IP Validation
Stripe webhooks come from a fixed list of IP addresses. You can see the list under âwebhook notificationsâ at Domains and IP addresses | Stripe Documentation.
The IP address in the Stripe webhookâs header can be checked against this list. Create an option set with all of the Stripe webhook notification IPs. Enable âinclude headers in the detected dataâ before initialising using âdetect dataâ. When the request comes in, check if the option set contains an option with the IP of the request. You can use the "cf-connecting-ip"
header to check it.
If the request is not from one of these IP addresses, then we know itâs definitely not from Stripe.
Obfuscation
Say it with me: Obfuscation is not sufficient security.
Obfuscation involves making it difficult to find out what a function does, or making it hard to find that function at all. In this case, it means making the endpoint name random. A malicious user can pretty easily guess names like âsubscriptioncreatedâ or âcreatesubscriptionâ or âsubscriptioncreateâ to eventually discover your backend endpoint. However, if you make it a random string, it becomes infeasible for an imposter to discover which endpoint to call.
To reiterate, this is not sufficient on its own. This also requires that you disable the Swagger API documentation access in your API settings. The Swagger documentation is essentially a list of all public API endpoints so if you donât disable it your imposter can just look up that documentation and find out what your endpoint is called. I have no clue why this is not hidden by default as Iâve never found a use case thatâs required it to be public.
Stripe Metadata
Iâll refer to checkout sessions in this example for clarity but the same principles apply to any object on Stripe, as (I believe) all can have metadata.
Creating Metadata for a Stripe object:
- API Call: When initiating a checkout session, make a standard API call to Stripe. Alongside this, include a âmetadataâ field named âauthorisationâ. The value for this field should be a randomly generated string.
- Database Update: Following the creation of this string, add a new entry to your Bubble database under the âAuthorisationâ data type. This entry should store the generated key and associate it with the relevant User (or Enterprise/Billing Account, based on your payment setup).
- Storage in Stripe: This metadata, i.e., the âauthorisationâ string, will be stored with the Stripe Checkout Session object.
Validating Webhooks:
- User Lookup: Search your database for the User/Enterprise/Billing Account where customerID = customerID received in the webhookâs Request Data.
- Authorisation Search: With the result of step 1, search for an âAuthorisationâ where the User matches and the key is equivalent to the metadata âauthorisationâ in the webhookâs Request Data. This validates that the session was genuinely created by that user.
Ensure that the âAuthorisationâ data type in your Bubble database has privacy rules that prevent anyone from seeing it.
There are is an issue with this method. An imposter could find out the randomly generated âauthorisationâ string created in the front end. However, the risk is minimal. At most, they can manipulate their own payment or subscription. They cannot interfere with othersâ transactions since Authorisation keys are user/account-specific.
If you do want to solve this, you can create the random key and checkout URL through an API request to a backend workflow. Use âreturn data from APIâ to return the checkout URL, and keep the key confidential.
Closing Thoughts
This list is by no means exhaustive. The more security, the better. Iâm sure others have other creative solutions which are also useful and Iâd encourage you to submit them below for future users.
Iâm on a bit of a security crusade after finding apps ranging from small businesses to 7-figure VC funded companies with exposed endpoints, missing (or even no privacy rules), exposed documents⌠I learned some stuff myself when putting this together so hope others might find it useful too. Iâm still doing free audits here which you might be interested in for your own app as a second pair of eyes can really help. Itâs just free whilst I finalise the product offering for future paying customers!