DEV Community

Cover image for Secure your Stripe Webhooks and protect yourself from Captain Hook
Antoine Braconnier for Wecasa

Posted on

Secure your Stripe Webhooks and protect yourself from Captain Hook

If you handle your app payments with Stripe, there's a strong chance that at some point you end up needing using their webhooks. But payments are such a sensitive matter and you certainly don't want to expose your endpoints to every pirate sailing around, now, would you?

That is why it's really important to setup secured routes without being plundered and loot by some evil corsairs! So grab a grog, and let's find out how to write a basic Stripe webhook endpoint. Following some simple recommendations, it will safely receive and process events.

Webhook security

But what is the actual risk of having a webhook endpoint exposed, cast adrift? There's two main issues :

  • Forged requests : someone can mimic the same data structure and send forged requests with fake data to your endpoint.

An image explaining how forged requests work

  • Replay attack : provided that your endpoint is HTTPS and not HTTP, no one but you can read the events sent by Stripe to you. They can, however, capture the encrypted data sent and re-send them again and again, which definitely could cause some harm.

an image explaining metaphorically how replay attacks work

Luckily enough, Stripe sends us something really useful in the Headers called STRIPE-SIGNATURE that looks like this:

"t=1721661059,v1=ef9344dc37fe005b41d7b9d29871cfe1287b2deefbb8d3lk10b9a74b859345734"
Enter fullscreen mode Exit fullscreen mode

In this header, we have two values:

  • t , a timestamp,
  • v1 , an encrypted value generated each call that will be used in our app to verify the authenticity of the event sent.

With those two values, and the help of the Stripe SDK, we can build a Stripe::Event object in our app with the payload. This will:

  • Assert the data structure sent by Stripe;
  • Decrypt the encrypted value (v1), so we know the event is legit;
  • Assert that the timestamp is from less than 5 minutes ago, to mitigate the replay attack risk. We'll see that this 5 minutes value can be modified.

Now, let's implement a Stripe webhook!


Setting up a Stripe webhook endpoint

First, add an endpoint on your Stripe dashboard ( let's call it https://www.myapp.com/payments/events ) and add an event to listen to. Let's say we want to start shipping goods to the user once we have a successful payment intent. The event we want to listen to is thus payment_intent.succeeded.

Stripe dashboard

Now let's go to our app and let's code a route with a matching controller. The stripe webhook endpoint is supposed to be a POST.

# config/routes.rb
Rails.application.routes.draw do
  #...
  namespace :payments do
    post :events
  end
end
Enter fullscreen mode Exit fullscreen mode
# app/controllers/payments/events_controller.rb
module Payments
  module EventsController
    def create
    # ...
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Ok, great, that's a good start. We have now a controller that will listen to all of your webhooks.


Building a Stripe::Event object

Now, we actually need to transform the payload into a Stripe::Event object to make sure that it is safe.

For the signature decryption, we'll need one last thing : the signing secret of the endpoint. You can find in the stripe dashboard, in the new webhook endpoint page that you just created. You can add it in your .env files, under the name PAYMENT_WEBHOOK_SECRET for example.

Showing where the secret key is in the endpoint page

Then we have everything necessary to create a service to build this event. This service will accept as a parameter:

  • the payload, which will be a string;
  • and the signature header (with the v1 and t values, remember?) for the security check. We'll also need to use the stripe webhook secret.

The building of the event can raise two kind of errors in case of bad data, and it's important we handle these errors properly. So let's add a basic error handler with this in mind.

# app/services/payments/build_event_service.rb
module Payments
  class BuildEventService < ApplicationService
    WEBHOOK_SECRET = ENV.fetch('PAYMENT_WEBHOOK_SECRET').freeze

    def call(payload:, signature_header:)
      @payment_provider.construct_event(
        payload,
        signature_header,
        secret_key,
        # :tolerance argument will change the 5 minutes limit to one minute
        tolerance: 60
      )
    rescue JSON::ParserError, Stripe::SignatureVerificationError => e
      raise WebhookSecurityCheckError, e
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, we have a service that will effectively build and return a Stripe::Event object.

Let's add it in our controller! And let's take the opportunity to think about what the controller needs to respond here. Stripe will expect a 200 response from its webhook call, so let's return that, and let's maybe return a 401 if our security check didn't pass.

# app/controllers/payments/events_controller.rb
module Payments
  module EventsController
    def create
      event = BuildEventService.new.call(payload: payload, signature_header: signature_header)
      # do stuff with the event created
      # ...
      head :ok
    rescue WebhookSecurityCheckError => e
       head 401
    end

    private

    def payload
      request.body.read
    end

    def signature_header
      request.env['HTTP_STRIPE_SIGNATURE']
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Ok! We have now an endpoint that is all setup for a basic security check.


Dispatching Stripe events

However, this is still not entirely ideal. Stripe expects to receive an answer quickly. In fact, this controller should do the most minimal thing possible :

  • check that the event is indeed sent by Stripe,
  • delegate ASAP the responsability and the payload to a corresponding job.

So let's quickly add a dispatcher that will do just that. For this, we'll need to have a hash with the corresponding event types mapping to jobs.

# app/services/payments/dispatch_event_service.rb

module Payments
  class DispatchEventService < ApplicationService
    EVENT_TYPE_TO_JOB = {
      'payment_intent.succeeded' => SendProductToClientJob,
      #...
    }.freeze

    def call(event:)
      EVENT_TYPE_TO_JOB[event.type].perform_later(event: event.to_hash)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, we can use this dispatcher in our controller:

# app/controllers/payments/events_controller.rb
module Payments
  module EventsController
    def create
      event = BuildEventService.new.call(payload: payload, signature_header: signature_header)
      DispatchEventService.new.call(event: event)
      head :ok
    rescue WebhookSecurityCheckError => e
       head 401
    end

# [...]
Enter fullscreen mode Exit fullscreen mode

Testing

For testing, you'll probably need to stub your method call construct_event to make it return what you need. And to test manually and locally the webhook, a good and easy way to do it without having to use a tool like ngrok is to use the Stripe CLI.

This is the command to install Stripe CLI via Homebrew :

brew install stripe/stripe-cli/stripe
Enter fullscreen mode Exit fullscreen mode

Then login with your credentials :

stripe login
Enter fullscreen mode Exit fullscreen mode

And now, you can easily forward any events necessary to your local server :

stripe listen --events payment_intent.succeeded \
 --forward-to localhost:3000/payments/events
Enter fullscreen mode Exit fullscreen mode

And to trigger the webhook when necessary:

stripe trigger payment_intent.succeeded
Enter fullscreen mode Exit fullscreen mode

Of course, you can go further in securing your webhook. For example, you can use the idempotency key that each event provide to make sure that the event is unique.

With this basic setup, you can already receive pretty safely Stripe webhooks, and you can add events in the EVENT_TYPE_TO_JOB whenever you need to. Your code will safely build a Stripe::Event object and check the correct event type to delegate the attributes of the payload to a matching job.

Thank you for reading. I invite you to subscribe so you don't miss the next articles that will be released.

Happy sailing (and coding)! 🦜 🏴‍☠️

Top comments (1)

Collapse
 
pimp_my_ruby profile image
Pimp My Ruby

Thanks for sharing 😍😍😍😍