Search code examples
javascriptcryptographystripe-paymentscloudflare-workers

How to verify Stripe's webhook signature?


I have figured out the problem thanks to the extensive answer from @user9014097. This section in particular describes my mistake/oversight:

The formatting of the message plays a role in determining the MAC. Every difference, e.g. a line break, a blank etc. changes the signature and results in a failed verification. Check if you might have changed the message or its format a little bit.

After stringifying the request body it works like a charm! With cloudflare workers you can get the original body in plain text like this: const payload = await event.request.text();

Original post:

I am trying to manually verify the signature for Stripe webhooks. I am not working in node.js so the stripe-node package is not an option for me unfortunately. I have followed the "Verifying signatures manually" steps on https://stripe.com/docs/webhooks/signatures#verify-manually. So far I've produced the following:

  • body: event.request.body (from cloudflare workers' fetch event)
  • header: event.request.headers.get('Stripe-Signature')
const hexStringToUint8Array = hexString => {
  const bytes = new Uint8Array(Math.ceil(hexString.length / 2));
  for (let i = 0; i < bytes.length; i++)
    bytes[i] = parseInt(hexString.substr(i * 2, 2), 16);
  return bytes;
};

export const verifySignature = async (body, header, tolerance = 300) => {
  header = header.split(',').reduce((accum, x) => { 
    const [k, v] = x.split('=');
    return { ...accum, [k]: v };
  }, {});
  
  const encoder = new TextEncoder();
  
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(STRIPE_WEBHOOK_SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"]
  );

  const verified = await crypto.subtle.verify(
    "HMAC",
    key,
    hexStringToUint8Array(header.v1),
    encoder.encode(`${header.t}.${body}`)
  );

  const elapsed = Math.floor(Date.now() / 1000) - Number(header.t);
  return verified && !(tolerance && elapsed > tolerance)
}; 

However the verify function always returns false. Can anyone spot the issue here?

Thank you, Jacco

Edit: Here follows test data. Thanks @user9014097 for the request:

The body and header are supposed to be used as the parameters for verifySignature.

body

{
  "id": "evt_1JAc3EFj0bmn6HyUJpVSB1hC",
  "object": "event",
  "api_version": "2020-08-27",
  "created": 1625669316,
  "data": {
    "object": {
      "id": "prod_Jkre4DaakpOaCt",
      "object": "product",
      "active": true,
      "attributes": [

      ],
      "created": 1624892313,
      "description": null,
      "images": [
        "https://files.stripe.com/links/MDB8YWNjdF8xSXR3Y3BGajBibW42SHlVfGZsX3Rlc3RfaHlGSVhUVHFmOHVLSzFhUUVGV0FWNlc300bY6GO8NE"
      ],
      "livemode": false,
      "metadata": {
        "brand": "DOM",
        "series": "1D",
        "key_codes_start": "1",
        "key_codes_end": "114"
      },
      "name": "DOM 1D serie 1-114",
      "package_dimensions": null,
      "shippable": null,
      "statement_descriptor": null,
      "type": "service",
      "unit_label": "sleutel",
      "updated": 1625669316,
      "url": null
    },
    "previous_attributes": {
      "description": "test",
      "updated": 1625665952
    }
  },
  "livemode": false,
  "pending_webhooks": 1,
  "request": {
    "id": "req_SxhB93mIUlcaKW",
    "idempotency_key": null
  },
  "type": "product.updated"
}

header

t=1625700981,v1=08a60e9f42416808d1fbd3efb852695830af8f7e0da71d351dd5fbbf135d7974,v0=3ff951917ae810ac14236a0db7ce046011a1ce4d949f267650766b1c9bb1b3e2

The STRIPE_WEBHOOK_SECRET variable inside the verifySignature function is used to import/create the key. Which is then used to verify the payload/body. In order to test it you can swap out the variable name for the secret string below.

STRIPE_WEBHOOK_SECRET

whsec_bPYHe4WqVVG9jri3FwIHBLZgQSHTIS6j

Solution

  • On my machine, the actual verification of the signature is successful!

    However, your verification also takes into account the timestamp. If the verification time differs from this timestamp by more than a given tolerance value (default 300s), the verification fails. It is this last condition that causes the verification to fail.

    If the tolerance is enough or the message timestamp is within the tolerance, the verification succeeds:

    (async () => {
            
    const hexStringToUint8Array = hexString => {
      const bytes = new Uint8Array(Math.ceil(hexString.length / 2));
      for (let i = 0; i < bytes.length; i++)
        bytes[i] = parseInt(hexString.substr(i * 2, 2), 16);
      return bytes;
    };
    
    const verifySignature = async (body, header, tolerance = 300) => {
      header = header.split(',').reduce((accum, x) => { 
        const [k, v] = x.split('=');
        return { ...accum, [k]: v };
      }, {});
      
      const encoder = new TextEncoder();
      
      const key = await crypto.subtle.importKey(
        "raw",
        encoder.encode('whsec_bPYHe4WqVVG9jri3FwIHBLZgQSHTIS6j'),
        { name: "HMAC", hash: "SHA-256" },
        false,
        ["verify"]
      );
    
      const verified = await crypto.subtle.verify(
        "HMAC",
        key,
        hexStringToUint8Array(header.v1),
        encoder.encode(`${header.t}.${body}`)
      );
    
      const elapsed = Math.floor(Date.now() / 1000) - Number(header.t);
      return verified && !(tolerance && elapsed > tolerance)
    }; 
                
    var body = `{
      "id": "evt_1JAc3EFj0bmn6HyUJpVSB1hC",
      "object": "event",
      "api_version": "2020-08-27",
      "created": 1625669316,
      "data": {
        "object": {
          "id": "prod_Jkre4DaakpOaCt",
          "object": "product",
          "active": true,
          "attributes": [
    
          ],
          "created": 1624892313,
          "description": null,
          "images": [
            "https://files.stripe.com/links/MDB8YWNjdF8xSXR3Y3BGajBibW42SHlVfGZsX3Rlc3RfaHlGSVhUVHFmOHVLSzFhUUVGV0FWNlc300bY6GO8NE"
          ],
          "livemode": false,
          "metadata": {
            "brand": "DOM",
            "series": "1D",
            "key_codes_start": "1",
            "key_codes_end": "114"
          },
          "name": "DOM 1D serie 1-114",
          "package_dimensions": null,
          "shippable": null,
          "statement_descriptor": null,
          "type": "service",
          "unit_label": "sleutel",
          "updated": 1625669316,
          "url": null
        },
        "previous_attributes": {
          "description": "test",
          "updated": 1625665952
        }
      },
      "livemode": false,
      "pending_webhooks": 1,
      "request": {
        "id": "req_SxhB93mIUlcaKW",
        "idempotency_key": null
      },
      "type": "product.updated"
    }`                  
    
    var header = `t=1625700981,v1=08a60e9f42416808d1fbd3efb852695830af8f7e0da71d351dd5fbbf135d7974,v0=3ff951917ae810ac14236a0db7ce046011a1ce4d949f267650766b1c9bb1b3e2`;
             
    const elapsed = Math.floor(Date.now() / 1000) - Number(1625700981);
    console.log("Elapsed time in s:", elapsed)
    console.log("Verification without considering tolerance:", await verifySignature(body, header, null));
    console.log("Verification with enough tolerance:        ", await verifySignature(body, header, elapsed));
    console.log("Verification with default tolerance:       ", await verifySignature(body, header)); // default: tolerance = 300
    
    })();

    A failure of the verification in your environment could have e.g. the following reasons:

    • A too small tolerance (the default value of 300s is meanwhile (!) too small for the timestamp of the posted nessage).
    • The formatting of the message plays a role in determining the MAC. Every difference, e.g. a line break, a blank etc. changes the signature and results in a failed verification. Check if you might have changed the message or its format a little bit.