Securing Stripe webhooks without authentication

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:

  1. 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.
  2. 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).
  3. Storage in Stripe: This metadata, i.e., the ‘authorisation’ string, will be stored with the Stripe Checkout Session object.

Validating Webhooks:

  1. User Lookup: Search your database for the User/Enterprise/Billing Account where customerID = customerID received in the webhook’s Request Data.
  2. 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!

24 Likes

Thanks for sharing this. One of the best tips I’ve seen in recent months. :clap:

4 Likes

Thanks for sharing ! +1 :ok_hand:

How does that work? Can you explain a little more for anyone that stumbles across the thread (and for myself :grin: )?

Weird, they must have removed it from the docs recently…

@georgecollier I think he’s saying just add ?api_token=[api key generated in app settings] to the end of your webhook URL in Stripe and it authenticates as Admin

Well that is beautiful. Just tested and it works. My guide is now rendered useless but I will add it to the top of the guide and begin worshipping @NigelG.

Now why would Bubble have removed it from the docs…

3 Likes

Okay, I’ve put that solution at the top of the post. Blows my mind that Bubble would remove that. There are hundreds (probably thousands) of apps with unauthenticated / completely unprotected webhooks which could easily be protected with this tidbit of information which isn’t in the documentation.

I’ll ask Bubble.

Yes if you find anything out that would be good. I know the docs used to have it, but they had some sidenote/suggestion that it was considered less secure than the Bearer token header.

But if Bearer token header isn’t an option then… :man_shrugging:

The whole network security thing is a little beyond my scope but if it’s an encrypted HTTPS call the api_token should be secured, and it’s only being sent between Stripe and Bubble servers anyway… maybe you could argue that the token would be saved in logs somewhere but that’s still way better than nothing…

1 Like

Also do you have swagger docs disabled on your notquiteunicorns app?

Of course, I’ve never found a reason to keep them public. Don’t want nosy people looking at my app backend naked.

That’s interesting - it’s not really a problem as they’re all secure anyway. I just take whatever measures I can (e.g disabling Swagger docs) so that if I do make a mistake somewhere, it’s still hard for an attacker to find out that mistake.

Again, another silly thing that Bubble should at least give an option to disable.

Leave that post up by the way :wink: It’s a good example of why obfuscation alone isn’t security…

1 Like

You can just send it in PMs :wink:

1 Like

Yea really this just reinforces that Obfuscation isn’t really a thing here :rofl:

1 Like

Bubble should follow your advice :rofl:

Just for fun I poked around https://bubble.io/api/1.1/meta
They have some endpoints with "auth_unecessary": true, and I tried one and got a 200 response (-1 WU for them :rofl:)

I know like our Postmark endpoints would also be "auth_unecessary": true cause we verify with IP address, but it wouldn’t reply a 200

Anyways just some fun playing around :grinning_face_with_smiling_eyes: This is a very useful thread for Stripe integration for sure

1 Like

Once just to prove a point I spammed file uploads on the official Bubble app (just a few hundred files that are identical called ‘pleasefixthis’ or something along those lines). Another I spammed search API requests to see how much WU someone could use if they abused one of my apps / if there were any rate limits / to prove a point (again) before promptly realising WU’s cost Bubble virtually nothing :laughing:

The only time I’ll leave an endpoint unsecured (I mean unsecured as in no authentication AND no other checks within the endpoint - no authentication with checks is secured) is if I know that a user can only affect their own data… like if you want to screw over your own data then sure haha

1 Like

tempted to bug report the spelling error

2 Likes

Why would they possibly leave their creditaffiliate & refund endpoints auth_unecessary": true

Gotta imagine both of those flows deal with sensitive data

3 Likes

so that we can refund users who schedule infinite loops on bubble’s behalf

3 Likes

In Stripe the url of the webhook is not secret data. I wouldn’t be surprised if the url was saved in clear text in Stripe’s database. Are you ok with storing the admin api token of your app there?
I still prefer to use the webhook as a trigger, refetch the data from Stripe and use only the data that I fetched because I know that is legitimate.
Maybe one day bubble will let us access the real raw body text so that we can actually verify the webhook securely following the Stripe documentation.

4 Likes