> ## Documentation Index
> Fetch the complete documentation index at: https://docs.leapfinancial.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Security

> Verify webhook signatures to ensure authenticity and prevent replay attacks

## Why verify signatures?

Webhook endpoints are public URLs. Without verification, anyone could send fake webhooks to your server. M2M signs every webhook with your unique secret, allowing you to verify that:

1. The webhook came from M2M (authenticity)
2. The payload wasn't modified (integrity)
3. The webhook isn't a replay of an old request (freshness)

<Warning>
  Always verify webhook signatures before processing. Never trust the payload without verification.
</Warning>

## How signatures work

M2M uses HMAC-SHA256 to sign webhooks. The signature is computed from:

* A timestamp (to prevent replay attacks)
* The raw JSON payload

### Signature format

The signature is sent in the `X-M2M-Signature` header:

```
sha256=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6
```

### Algorithm

```
signed_payload = timestamp + "." + raw_json_body
signature = HMAC-SHA256(signed_payload, webhook_secret)
header_value = "sha256=" + hex(signature)
```

## Verification steps

<Steps>
  <Step title="Extract the headers">
    Get the signature and timestamp from the request headers:

    ```javascript theme={null}
    const signature = req.headers['x-m2m-signature'];
    const timestamp = req.headers['x-m2m-timestamp'];
    ```
  </Step>

  <Step title="Get the raw body">
    You must use the raw request body before any JSON parsing. Most frameworks provide a way to access this:

    ```javascript theme={null}
    // Express.js - use raw body parser
    app.use('/webhooks', express.raw({ type: 'application/json' }));

    // The raw body is now in req.body as a Buffer
    const rawBody = req.body.toString();
    ```
  </Step>

  <Step title="Compute the expected signature">
    Construct the signed payload and compute the HMAC:

    ```javascript theme={null}
    const signedPayload = `${timestamp}.${rawBody}`;
    const expectedSignature = crypto
      .createHmac('sha256', webhookSecret)
      .update(signedPayload)
      .digest('hex');
    ```
  </Step>

  <Step title="Compare signatures">
    Use a timing-safe comparison to prevent timing attacks:

    ```javascript theme={null}
    const receivedSignature = signature.replace('sha256=', '');
    const isValid = crypto.timingSafeEqual(
      Buffer.from(receivedSignature),
      Buffer.from(expectedSignature)
    );
    ```
  </Step>

  <Step title="Validate timestamp (optional but recommended)">
    Reject webhooks with old timestamps to prevent replay attacks:

    ```javascript theme={null}
    const webhookTime = parseInt(timestamp, 10) * 1000;
    const now = Date.now();
    const tolerance = 5 * 60 * 1000; // 5 minutes

    if (Math.abs(now - webhookTime) > tolerance) {
      throw new Error('Webhook timestamp too old');
    }
    ```
  </Step>
</Steps>

## Implementation examples

### Node.js (Express)

```javascript theme={null}
const express = require('express');
const crypto = require('crypto');

const app = express();

// IMPORTANT: Use raw body parser for webhook route
app.use('/webhooks/m2m', express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = process.env.M2M_WEBHOOK_SECRET;

function verifyWebhookSignature(req) {
  const signature = req.headers['x-m2m-signature'];
  const timestamp = req.headers['x-m2m-timestamp'];
  const rawBody = req.body.toString();
  
  if (!signature || !timestamp) {
    return false;
  }
  
  // Verify timestamp is recent (within 5 minutes)
  const webhookTime = parseInt(timestamp, 10) * 1000;
  const tolerance = 5 * 60 * 1000;
  if (Math.abs(Date.now() - webhookTime) > tolerance) {
    console.error('Webhook timestamp too old');
    return false;
  }
  
  // Compute expected signature
  const signedPayload = `${timestamp}.${rawBody}`;
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedPayload)
    .digest('hex');
  
  // Timing-safe comparison
  const receivedSignature = signature.replace('sha256=', '');
  try {
    return crypto.timingSafeEqual(
      Buffer.from(receivedSignature),
      Buffer.from(expectedSignature)
    );
  } catch {
    return false;
  }
}

app.post('/webhooks/m2m', (req, res) => {
  if (!verifyWebhookSignature(req)) {
    console.error('Invalid webhook signature');
    return res.status(401).send('Invalid signature');
  }
  
  const event = JSON.parse(req.body);
  console.log('Verified webhook:', event.event);
  
  // Process the webhook...
  
  res.status(200).send('OK');
});

app.listen(3000);
```

### Python (Flask)

```python theme={null}
import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)

WEBHOOK_SECRET = os.environ.get('M2M_WEBHOOK_SECRET')

def verify_webhook_signature(request):
    signature = request.headers.get('X-M2M-Signature', '')
    timestamp = request.headers.get('X-M2M-Timestamp', '')
    raw_body = request.get_data(as_text=True)
    
    if not signature or not timestamp:
        return False
    
    # Verify timestamp is recent (within 5 minutes)
    webhook_time = int(timestamp)
    tolerance = 5 * 60
    if abs(time.time() - webhook_time) > tolerance:
        print('Webhook timestamp too old')
        return False
    
    # Compute expected signature
    signed_payload = f'{timestamp}.{raw_body}'
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    # Timing-safe comparison
    received_signature = signature.replace('sha256=', '')
    return hmac.compare_digest(received_signature, expected_signature)

@app.route('/webhooks/m2m', methods=['POST'])
def handle_webhook():
    if not verify_webhook_signature(request):
        print('Invalid webhook signature')
        abort(401)
    
    event = request.get_json()
    print(f'Verified webhook: {event["event"]}')
    
    # Process the webhook...
    
    return 'OK', 200
```

### Python (FastAPI)

```python theme={null}
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

WEBHOOK_SECRET = os.environ.get('M2M_WEBHOOK_SECRET')

async def verify_webhook_signature(request: Request) -> bool:
    signature = request.headers.get('X-M2M-Signature', '')
    timestamp = request.headers.get('X-M2M-Timestamp', '')
    raw_body = await request.body()
    
    if not signature or not timestamp:
        return False
    
    # Verify timestamp is recent (within 5 minutes)
    webhook_time = int(timestamp)
    tolerance = 5 * 60
    if abs(time.time() - webhook_time) > tolerance:
        return False
    
    # Compute expected signature
    signed_payload = f'{timestamp}.{raw_body.decode()}'
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    received_signature = signature.replace('sha256=', '')
    return hmac.compare_digest(received_signature, expected_signature)

@app.post('/webhooks/m2m')
async def handle_webhook(request: Request):
    if not await verify_webhook_signature(request):
        raise HTTPException(status_code=401, detail='Invalid signature')
    
    body = await request.json()
    print(f'Verified webhook: {body["event"]}')
    
    return {'status': 'OK'}
```

### Go

```go theme={null}
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "math"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var webhookSecret = os.Getenv("M2M_WEBHOOK_SECRET")

func verifyWebhookSignature(r *http.Request, body []byte) bool {
    signature := r.Header.Get("X-M2M-Signature")
    timestamp := r.Header.Get("X-M2M-Timestamp")

    if signature == "" || timestamp == "" {
        return false
    }

    // Verify timestamp is recent (within 5 minutes)
    webhookTime, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    tolerance := int64(5 * 60)
    if math.Abs(float64(time.Now().Unix()-webhookTime)) > float64(tolerance) {
        return false
    }

    // Compute expected signature
    signedPayload := fmt.Sprintf("%s.%s", timestamp, string(body))
    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write([]byte(signedPayload))
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    // Timing-safe comparison
    receivedSignature := strings.TrimPrefix(signature, "sha256=")
    return hmac.Equal([]byte(receivedSignature), []byte(expectedSignature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Error reading body", http.StatusBadRequest)
        return
    }

    if !verifyWebhookSignature(r, body) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    fmt.Println("Verified webhook received")
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

func main() {
    http.HandleFunc("/webhooks/m2m", webhookHandler)
    http.ListenAndServe(":3000", nil)
}
```

## Common mistakes

<AccordionGroup>
  <Accordion title="Parsing JSON before verification">
    If you parse the JSON body before verifying the signature, the serialization might differ from the original payload.

    **Wrong:**

    ```javascript theme={null}
    app.use(express.json());
    app.post('/webhook', (req, res) => {
      const body = JSON.stringify(req.body); // Re-serialized - may differ!
      verify(body, signature);
    });
    ```

    **Correct:**

    ```javascript theme={null}
    app.use('/webhook', express.raw({ type: 'application/json' }));
    app.post('/webhook', (req, res) => {
      const body = req.body.toString(); // Original raw body
      verify(body, signature);
    });
    ```
  </Accordion>

  <Accordion title="Not using timing-safe comparison">
    Regular string comparison (`===`) can leak information through timing differences.

    **Wrong:**

    ```javascript theme={null}
    if (receivedSignature === expectedSignature) // Vulnerable!
    ```

    **Correct:**

    ```javascript theme={null}
    crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
    ```
  </Accordion>

  <Accordion title="Ignoring the timestamp">
    Without timestamp validation, attackers can replay old webhooks.

    Always check that the timestamp is within an acceptable window (e.g., 5 minutes).
  </Accordion>

  <Accordion title="Logging the webhook secret">
    Never log your webhook secret. If you need to debug, log a hash or the last few characters.
  </Accordion>
</AccordionGroup>

## Regenerating your secret

If your webhook secret is compromised:

1. Go to the [Partner Portal](https://partner.m2m.leapfinancial.com)
2. Navigate to **Settings** > **Webhooks**
3. Click **Regenerate Secret**
4. Update your server with the new secret immediately

<Warning>
  The old secret becomes invalid immediately. Deploy your new secret before regenerating to avoid downtime.
</Warning>

## Testing signature verification

You can test your implementation by generating a signature locally:

```javascript theme={null}
const crypto = require('crypto');

const secret = 'your_webhook_secret';
const timestamp = Math.floor(Date.now() / 1000).toString();
const payload = JSON.stringify({
  event: 'link.opened',
  timestamp: new Date().toISOString(),
  data: { linkId: 'test', referenceId: 'user_123' }
});

const signature = crypto
  .createHmac('sha256', secret)
  .update(`${timestamp}.${payload}`)
  .digest('hex');

console.log('X-M2M-Timestamp:', timestamp);
console.log('X-M2M-Signature:', `sha256=${signature}`);
console.log('Payload:', payload);

// Use these to test your endpoint with curl:
// curl -X POST http://localhost:3000/webhooks/m2m \
//   -H "Content-Type: application/json" \
//   -H "X-M2M-Timestamp: ${timestamp}" \
//   -H "X-M2M-Signature: sha256=${signature}" \
//   -d '${payload}'
```

## Next steps

<CardGroup cols={2}>
  <Card title="Webhook Events" icon="list" href="/m2m/webhooks/events">
    See all event types and their payloads.
  </Card>

  <Card title="Data Requests" icon="database" href="/m2m/integration/data-requests">
    Learn how to respond to data request webhooks.
  </Card>
</CardGroup>
