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

# Security

> Verify webhook signatures and protect your endpoint from replay attacks

## Overview

Every webhook sent by StableStack is signed with **HMAC-SHA256** using your endpoint's unique signing secret. Verifying signatures ensures that:

* The request originated from StableStack
* The payload has not been tampered with in transit
* The request is not a replay of an older event

***

## The Signature Header

The `signature` field is included in every webhook payload:

```
"signature": "t=1778538982206,s=a43187f66dff73d9681a4ef43c5c93349d95fc10b6dc0482ec65e39b59564a83"
```

It contains two comma-separated components:

| Component | Description                                               |
| --------- | --------------------------------------------------------- |
| `t`       | Unix timestamp in milliseconds when the event was created |
| `s`       | HMAC-SHA256 hex digest of the signed message              |

***

## How Signatures Are Generated

StableStack generates signatures as follows:

1. Build the payload object (without the `signature` field):
   ```json theme={null}
   {
     "id": "evt_...",
     "timestamp": 1778538982206,
     "event_type": "wallet.transaction.inbound",
     "data": { ... }
   }
   ```

2. Serialize it to a JSON string (`payloadString = JSON.stringify(payload)`)

3. Build the signed message:
   ```
   message = "${timestamp}.${payloadString}"
   ```

4. Compute `HMAC-SHA256(message, signingSecret)` and hex-encode the result

5. Attach to the payload as:
   ```
   "signature": "t=${timestamp},s=${hexDigest}"
   ```

***

## Verifying Signatures

### Node.js

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

function verifyWebhookSignature(payload, signingSecret) {
  const { signature, ...payloadWithoutSignature } = payload;

  // 1. Parse the signature field
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const receivedSig = parts.find(p => p.startsWith('s=')).slice(2);

  // 2. Reject stale events (replay protection)
  const now = Date.now();
  const eventTime = parseInt(timestamp, 10);
  const FIVE_MINUTES = 5 * 60 * 1000;

  if (Math.abs(now - eventTime) > FIVE_MINUTES) {
    throw new Error('Webhook timestamp too old — possible replay attack');
  }

  // 3. Rebuild the signed message
  const payloadString = JSON.stringify(payloadWithoutSignature);
  const message = `${timestamp}.${payloadString}`;

  // 4. Compute expected signature
  const expectedSig = crypto
    .createHmac('sha256', signingSecret)
    .update(message)
    .digest('hex');

  // 5. Compare using constant-time comparison
  const expectedBuf = Buffer.from(expectedSig, 'hex');
  const receivedBuf = Buffer.from(receivedSig, 'hex');

  if (
    expectedBuf.length !== receivedBuf.length ||
    !crypto.timingSafeEqual(expectedBuf, receivedBuf)
  ) {
    throw new Error('Invalid webhook signature');
  }

  return true;
}
```

**Express.js example:**

```javascript theme={null}
app.post('/webhook', express.json(), (req, res) => {
  try {
    verifyWebhookSignature(req.body, process.env.SIGNING_SECRET);
  } catch (err) {
    return res.status(401).json({ error: err.message });
  }

  const { id, event_type, data } = req.body;

  // Respond immediately, process asynchronously
  res.status(200).json({ received: true });

  processEvent(id, event_type, data).catch(console.error);
});
```

***

### Python

```python theme={null}
import hmac
import hashlib
import json
import time

def verify_webhook_signature(payload: dict, signing_secret: str) -> bool:
    signature = payload.get("signature", "")
    payload_without_sig = {k: v for k, v in payload.items() if k != "signature"}

    # Parse t= and s= from signature string
    parts = dict(part.split("=", 1) for part in signature.split(","))
    timestamp = parts.get("t", "")
    received_sig = parts.get("s", "")

    # Replay protection: reject if older than 5 minutes
    now_ms = int(time.time() * 1000)
    event_time = int(timestamp)
    if abs(now_ms - event_time) > 5 * 60 * 1000:
        raise ValueError("Webhook timestamp too old — possible replay attack")

    # Rebuild signed message
    payload_string = json.dumps(payload_without_sig, separators=(",", ":"))
    message = f"{timestamp}.{payload_string}"

    # Compute and compare
    expected_sig = hmac.new(
        signing_secret.encode(),
        message.encode(),
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected_sig, received_sig):
        raise ValueError("Invalid webhook signature")

    return True
```

***

## Replay Attack Prevention

The `t` timestamp in the signature allows you to reject events that are replayed after a delay. StableStack recommends rejecting any event older than **5 minutes**.

```javascript theme={null}
const FIVE_MINUTES = 5 * 60 * 1000;
const eventTime = parseInt(timestamp, 10);

if (Math.abs(Date.now() - eventTime) > FIVE_MINUTES) {
  return res.status(401).json({ error: 'Stale webhook — rejected' });
}
```

If your server's clock is significantly out of sync, legitimate events may be rejected. Ensure your server uses NTP time synchronisation.

## Best Practices

<AccordionGroup>
  <Accordion title="Store your signing secret securely">
    Never hardcode your `signing_secret` in source code or expose it client-side. Store it as an environment variable and rotate it immediately if compromised.

    ```bash theme={null}
    SIGNING_SECRET=your_secret_here
    ```
  </Accordion>

  <Accordion title="Always use constant-time comparison">
    Use `crypto.timingSafeEqual` (Node.js) or `hmac.compare_digest` (Python) when comparing signatures. Standard string equality (`===`) is vulnerable to timing attacks.
  </Accordion>

  <Accordion title="Respond before processing">
    Return a `200` response as soon as signature verification passes. Process the event asynchronously to avoid timeouts that could trigger unnecessary retries.

    ```javascript theme={null}
    res.status(200).json({ received: true });
    processEvent(event).catch(console.error);
    ```
  </Accordion>

  <Accordion title="Log failed verifications">
    Track signature failures to detect misconfiguration or attack attempts:

    ```javascript theme={null}
    } catch (err) {
      logger.warn('Webhook signature verification failed', {
        error: err.message,
        event_id: req.body?.id,
      });
      return res.status(401).json({ error: 'Unauthorized' });
    }
    ```
  </Accordion>
</AccordionGroup>

***

## Testing Signature Verification

You can test your verification logic using the example payload and the signing secret from your dashboard:

```javascript theme={null}
const testPayload = {
  id: "evt_a0b8f4cc-95c4-4c74-9b18-050813546eb5",
  timestamp: 1778538982206,
  event_type: "wallet.transaction.inbound",
  signature: "t=1778538982206,s=a43187f66dff73d9681a4ef43c5c93349d95fc10b6dc0482ec65e39b59564a83",
  data: {
    id: "dd1aebfd-acec-4367-a8dd-bdecea396753",
    amount: "20.00000000",
    status: "COMPLETED",
    // ... rest of the payload
  }
};

// Should return true with the correct signing secret
verifyWebhookSignature(testPayload, process.env.SIGNING_SECRET);
```

Use the [Dashboard](https://dashboard.stablestack.com/) to send test events to your endpoint and inspect delivery logs.

***

## Rotating Your Signing Secret

If your signing secret is compromised:

1. Go to **Dashboard → Settings → Webhook**
2. Click **Signing Secret**
3. Update your environment variable with the new secret
4. The old secret is invalidated immediately

<Warning>
  Rotating your secret will cause all in-flight webhooks signed with the old secret to fail verification. Update your application before rotating in production.
</Warning>
