Skip to content

Instantly share code, notes, and snippets.

@fjahn
Last active March 6, 2025 13:44
Facebook Data Deletion Callback

Implementation for the Data Deletion Callback in JavaScript for Node with Expressjs.

JSON formatting

The most stupid pitfall for this callback is that the response has to look like this:

{ url: '<url>', confirmation_code: '<code>' }

This and other more reasonable variations are invalid:

{ "url": "<url>", "confirmation_code": "<code>" }

This means you cannot use JSON.stringify or Response.json(object), but this glorious snippet:

res.type('json')
res.send(`{ url: '${url}', confirmation_code: '${confirmationCode}' }`)

Signature validation

Another pitfall is that Facebook's signature always has a = appended (see line 43 in parseSignedRequest.mjs).

import crypto from 'crypto'
import parseSignedRequest from './parseSignedRequest.mjs'
import { instance as fileStorage } from '../ConfirmingFileStorage.mjs'
import { getLogger } from '../logger.mjs'
import { toAbsoluteUrl } from '../util.mjs'
const logger = getLogger('deletionEndpoint')
export function registerHandles (app) {
app.post('/facebook/deletion', [parseSignedRequest, async (req, res) => {
console.log('Received facebook deletion request')
const userId = req.body['user_id']
// Remove all data for user here
const confirmationCode = getConfirmationCode()
const path = `/facebook/deletion-status?code=${confirmationCode}`
const url = toAbsoluteUrl(req, path)
// Facebook requires the JSON to be non-quoted and formatted like this, so we need to create the JSON by hand:
res.type('json')
res.send(`{ url: '${url}', confirmation_code: '${confirmationCode}' }`)
}])
app.get('/facebook/deletion-status', (req, res) => {
const code = req.query.code
res.render('facebook/deletionStatus', { code })
})
}
function getConfirmationCode () {
return crypto.randomBytes(10).toString('hex')
}
import * as base64url from 'base64-url'
import crypto from 'crypto'
import config from 'config'
import { getLogger } from '../logger.mjs'
import { getOrThrowClientError } from '../util.mjs'
import ResponseError from '../ResponseError.mjs'
const logger = getLogger('parseSignedRequest')
export default function parseSignedRequest (req, res, next) {
const signedRequest = getSignedRequest(req)
logger.debug('Validating signature', {
signedRequest
})
const [signature, payload] = getSignatureAndPayloadFromSignedRequest(signedRequest)
validateSignature(signature, payload)
replaceBodyWithDecodedPayload(req, payload)
next()
}
function getSignedRequest (req) {
return getOrThrowClientError(req, 'body', 'signed_request')
}
function getSignatureAndPayloadFromSignedRequest (signedRequest) {
const [encodedSignature, payload] = signedRequest.split('.', 2)
if (encodedSignature == null || payload == null) {
throw new ResponseError(400, 'Signed request has invalid format')
}
const signature = decodeSignature(encodedSignature)
return [signature, payload]
}
function replaceBodyWithDecodedPayload (req, payload) {
const body = decodePayload(payload)
req.body = body
}
function validateSignature (actualSignature, payload) {
const expectedSignature = getExpectedSignature(payload)
// For some reason, the actual signature always has a '=' appended
const actualSignatureWithEqualsSign = actualSignature + '='
if (actualSignatureWithEqualsSign !== expectedSignature) {
throw new ResponseError(401, 'Invalid signature')
}
}
function decodeSignature (encodedSignature) {
return urlDecode(encodedSignature)
}
function decodePayload (payload) {
const bodyJson = base64url.decode(urlDecode(payload))
try {
return JSON.parse(bodyJson)
} catch (error) {
// If the JSON is invalid, this is the client's fault
error.code = 400
throw error
}
}
function urlDecode (string) {
return string.replace(/-/g, '+').replace(/_/g, '/')
}
function getExpectedSignature (payload) {
const secret = config.get('services.facebook.appSecret')
const hmac = crypto.createHmac('sha256', secret)
hmac.update(payload)
return hmac.digest('base64')
}
@learntheropes
Copy link

can you share also the '../logger.mjs', '../util.mjs' and '../ResponseError.mjs' files?

@fjahn
Copy link
Author

fjahn commented Dec 31, 2021

I left those out intentionally because the gist is just supposed to show how the data deletion request works.

If you implement this yourself, you can just leave out the calls to the log functions.

If you want to know how to get the full URI of a request, check out this Stackoverflow post.

ResponseError is simply a plain JavaScript error with a status property, and causes Node to respond with the corresponding HTTP error code.

@learntheropes
Copy link

Can you please clarify what this the scope of this function?
function getSignedRequest (req) { return getOrThrowClientError(req, 'body', 'signed_request') }

@fjahn
Copy link
Author

fjahn commented Jan 3, 2022

Basically, it tries to find signed_request within the body and returns it. If the key is missing, clients get the response code 400. Here is the implementation:

/**
 * @param req {Request}
 * @param type {'query'|'body'}
 * @param key {string}
 * @returns {*}
 */
export function getOrThrowClientError (req, type, key) {
  const getError = () => {
    return new ResponseError(400, `Missing ${type} parameter '${key}'`)
  }
  let array
  switch (type) {
    case 'query':
      array = req.query
      break
    case 'body':
      array = req.body
      break
    default:
      throw new Error(`Unknown type: ${type}`)
  }
  if (!(key in array)) {
    throw getError()
  }
  const value = array[key]
  if (value == null) {
    throw getError()
  }
  return value
}

@chr4ss12
Copy link

chr4ss12 commented Feb 6, 2025

am going to add my version, this was tested in production and works, but thank you for the tip about .json(),


const crypto = require('crypto');

function parseSignedRequest(signedRequest, appSecret) {
    const [encodedSig, payload] = signedRequest.split('.');
    const decodedPayload = JSON.parse(
        Buffer.from(payload, 'base64').toString('utf-8')
    );

    // Optional: Verify the signature
    const expectedSig = crypto
        .createHmac('sha256', appSecret)
        .update(payload)
        .digest('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');

    if (expectedSig !== encodedSig) {
        throw new Error('Invalid signed request signature');
    }

    return decodedPayload; // Contains user_id
}

export function registerFacebookDataDeletionRoute(app) {
    app.get('/facebook-data-deletion-status', async function (req, res) {
        const { user_id } = req.query;

        if (!user_id || typeof user_id !== 'string') {
            return res.status(400).json({ error: 'Missing user_id' });
        }

        // Check if user exists in active users
        const activeUser = ...
        const isDeleted = !activeUser;

        return res.json({
            user_id: user_id,
            status: isDeleted ? 'deleted' : 'pending',
            message: isDeleted
                ? 'Your data has been successfully deleted.'
                : 'Your data deletion request is being processed.',
        });
    });

    app.post('/facebook-data-deletion', async function (req, res) {

        let facebookId;

        try {
            const signedRequest = req.body.signed_request;

            if (!signedRequest) {
                return res
                    .status(400)
                    .json({ error: 'Missing signed_request' });
            }

            const appSecret = process.env.FBAPPSECRET; // Your Facebook App Secret
            const data = parseSignedRequest(signedRequest, appSecret);
            facebookId = data?.user_id;


            if (!facebookId) {
                return res.status(400).json({ error: 'FBID missing' });
            }

            // Find user by Facebook ID
            const user = ... find user from fb ID....

            if (user) {
                // Delete user account
                ......
            }

            // Always return success confirmation URL as per Facebook requirements
            const confirmationURL = `https://....../facebook-data-deletion-status?user_id=${facebookId}`;
            const confirmationCode = uuidv1() + facebookId;

            // Facebook requires the JSON to be non-quoted and formatted like this, so we need to create the JSON by hand:
            res.type('json');
            res.send(`{ url: '${confirmationURL}', confirmation_code: '${confirmationCode}' }`);
            return;
        } catch (error) {
            logService.error(
                error,
                { facebookId: facebookId },
                { path: '/facebook-data-deletion' }
            );

            return res.status(400).json({ error: 'generic error' });
        }
    });
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment