Facebook's deletion request callback with Rails

June 03, 2021

I've been adding social sign-in buttons, aka "Sign In With Facebook/Twitter/Google" for IdeaFit, and stumbled upon this.

Facebook won't allow you to make an OAuth app live until you provide a Data Deletion Request Callback. The requirement seems to be coming from GDPR policy (on the other hand, Twitter doesn't force it).

Anyways, let me walk you through the implementation of the data deletion request callback in a typical Ruby on Rails application.

Deletion request callback

What is this callback thing?

It's a URL on your website that Facebook can post a request to in order to delete a user's data.

Facebook sends a POST request, and you should delete all the data associated with a particular user (personal data, posts, comments, etc.). It can be done asynchronously and even manually.

In any case, it should return a URL, which the user can visit later to see the status of the removal request.

From the user's perspective, it looks like this.

After she removes your application on Facebook, she is provided with an optional button labeled "Request to delete data". Clicking it results in Facebook sending you a signed request and then displaying the returned URL to the user to check the status later.

Let's code!

We are going to create a dedicated model that will store the data and encapsulate some logic.

But first let's add the routes.

# Facebook requires this in order to make OAUTH app live
resources :deletion_requests, only: [:show] do
  collection do
    post :facebook
  end
end

It generates two paths:

  • GET /deletion_request/<id>
  • POST /deletion_request/facebook

The first one will be used to check the status of the deletion request, and the second is the webhook we'll submit to Facebook.

While we're at it, let's also create a thin controller.

# app/controllers/deletion_requests_controller.rb
class DeletionRequestsController < ApplicationController

  # disable CSRF protection, as it doesn't make sense in this case
  protect_from_forgery with: :null_session

  def facebook
    DeletionRequest
      .from_signed_fb(params['signed_request'])
      .run

    render json: {
      url: deletion_request_url(dr.pid),
      confirmation_code: dr.pid,
    }
  end

  def show
    dr = DeletionRequest.find_by_pid!(params[:id])
    render text: dr.deleted? ?
      "Your data has been completely deleted" :
      "Your deletion request is still in progress"
  end
end

According to documentation, Facebook sends its encrypted request as a param named signed_request. We'll see how to decrypt it in a moment; for now, notice that we delegate it to a "factory method" of our model from_signed_fb.

Once we create the deletion request, we immediately call run on it, which a method that actually should delete the user and all related data. The exact implementation of this method is specific to your application.

Note that we return a hash with two keys url and confirmation_code as required by Facebook.

The URL to check the status would look like this /deletion_requests/a34avcv3. As you can see in show method, we simply load the model by pid and display a text message (Facebook requires "human-readable message", so I assume JSON won't do) depending on whether or not the job is done.

Model

Let's generate migration.

bundle exec rails generate migration CreateDeletionRequest \
  provider:string \
  uid:string \
  pid:string:index

Our model is going to have three columns:

  • provider ("facebook", "twitter", etc. — assuming there are more providers who requires the deletion callback)
  • uid (a unique identifier within a given provider. Typically, you should already store it on your database after the user signed in)
  • pid (public id, just a random unique string we'll use as ID)

And, finally, let's create our model:

class DeletionRequest < ApplicationRecord

  validates_presence_of :uid, :provider, :pid

  # there can only be one entry with given provider + uid
  validates_uniqueness_of :uid, scope: :provider

  before_validation :set_pid

  def run
    u = User.where(provider: provider, uid: uid).first
    if u.present?
      u.destroy!
    end
  end

  def deleted?
    User.where(provider: provider, uid: uid).count == 0
  end

  # ============
  # UTIL
  # ============

  def self.from_signed_fb(req)
    data = DeletionRequest.parse_fb_request(req)
    DeletionRequest.create(provider: 'facebook', uid: data['user_id'])
  end

  def self.parse_fb_request(req)
    encoded, payload = req.split('.', 2)
    decoded = Base64.urlsafe_decode64(encoded)
    data = JSON.load(Base64.urlsafe_decode64(payload))

    # we need to verify the digest is the same
    exp = OpenSSL::HMAC.digest("SHA256", ENV['FACEBOOK_APP_SECRET'], payload)

    if decoded != exp
      puts 'FB deletion callback called with weird data'
      return nil
    end

    data
  end

  private

  def set_pid
    if self.pid.blank?
      self.pid = random_pid
    end
  end

  def random_pid
    SecureRandom.hex(4)
  end
  
end

Whoa, there's a lot to unpack there.

The "before validation" callback sets the pid, a unique random string.

In the controller, we create a new request with from_signed_fb method. It takes a request, decodes it with parse_fb_request, and then fetches "user_id" out of it.

This field is user's ID in Facebook. If user signed through Facebook before, we store her uid in our database already. It allows us to find the user in our database by uid and destroy it (see run method).

The run method doesn't have to be synchronous, you could fire a Sidekiq job instead, or send an automatically letter to another employer to manually remove the user later.

How exactly you destroy the data depends on your app and the context. In my case, I always use dependent: :destroy on associations which means that all the related data will get destroyed automatically.

For example,

  has_many :boards, inverse_of: :user, dependent: :destroy

Now, parse_fb_request is just a ripoff from the official documentation. We decode the request, and validate its authenticity using FACEBOOK_APP_SECRET of our Facebook app.

What's next?

I hope some of you guys found this article helpful. Feel free to reach out via Twitter if you have any questions (my DMs are always open).

And here are some useful links to learn more on the topic.