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

# Webhook Security

AccessOwl signs every webhook request using [RFC 9421 HTTP Message Signatures](https://datatracker.ietf.org/doc/html/rfc9421). The signing scheme used is **Ed25519** (asymmetric).

Each request includes three headers you can use for verification:

| Header            | Description                                                                                                                                                                                                      |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Digest`  | SHA-512 hash of the request body, formatted as `sha-512=:BASE64:`                                                                                                                                                |
| `Signature-Input` | Describes which request components were signed, the creation timestamp, and the key ID. Example: `sig=("@target-uri" "content-digest" "content-type" "idempotency-key");created=1718884473;keyid="whsec_abc123"` |
| `Signature`       | The actual signature over the covered components, formatted as `sig=:BASE64:`                                                                                                                                    |

The key ID in `Signature-Input` matches the `whsec_...` identifier shown in [Settings → Webhooks](https://app.accessowl.io/settings/webhooks), where you can also retrieve the **public key** to verify signatures.

<Warning>It's advised to verify the signature before processing a webhook payload. Reject any request where verification fails.</Warning>

## Constructing the signature base

To verify the signature you must reconstruct the same signature base that AccessOwl signed. The procedure follows [RFC 9421 §2.5](https://datatracker.ietf.org/doc/html/rfc9421#section-2.5):

1. For each component listed in `Signature-Input`, **in order**, emit one line formatted as:
   ```
   "<component-name>": <component-value>
   ```
2. Append a final line using the exact string value of the `Signature-Input` header:
   ```
   "@signature-params": <Signature-Input value>
   ```
3. Join all lines with a single newline character (`\n`). There is no trailing newline.

AccessOwl always covers four components in this fixed order: `@target-uri`, `content-digest`, `content-type`, `idempotency-key`.

**Example signature base:**

```
"@target-uri": https://your-endpoint.example.com/webhook
"content-digest": sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:
"content-type": application/json
"idempotency-key": 018f1e2a-3b4c-7d8e-9f0a-1b2c3d4e5f6a
"@signature-params": ("@target-uri" "content-digest" "content-type" "idempotency-key");created=1718884473;keyid="whsec_abc123"
```

Take `@target-uri` from the full request URL (including scheme and path) and `content-digest` directly from the `Content-Digest` request header. Once assembled, verify the `Signature` header value against this base using the Ed25519 public key for your webhook endpoint, which is available in [Settings → Webhooks](https://app.accessowl.io/settings/webhooks).

<Note>We recommend using an HTTP Message Signatures library for your language rather than implementing RFC 9421 verification from scratch.</Note>

## Manual verification

If you prefer not to use a library, the examples below show how to verify signatures manually using standard cryptographic libraries.

### Setup

The public key from [Settings → Webhooks](https://app.accessowl.io/settings/webhooks) is provided as a JWK. Base64-encode it and set as an environment variable:

```bash theme={null}
export ACCESSOWL_WEBHOOK_PUBLIC_KEY=$(echo '{"crv":"Ed25519","kid":"whsec_xxx","kty":"OKP","x":"..."}' | base64)
```

### Verification function

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createPublicKey, verify } from 'node:crypto';

  const PUBLIC_KEY_JWK = JSON.parse(
    Buffer.from(process.env.ACCESSOWL_WEBHOOK_PUBLIC_KEY, 'base64').toString()
  );
  const publicKey = createPublicKey({ key: PUBLIC_KEY_JWK, format: 'jwk' });

  export function verifyWebhook(req) {
    const signatureInput = req.headers['signature-input'];
    const signature = req.headers['signature'];
    const contentDigest = req.headers['content-digest'];
    const contentType = req.headers['content-type'];
    const idempotencyKey = req.headers['idempotency-key'];

    // Extract base64 signature from "sig=:BASE64:" format
    const sigMatch = signature.match(/^sig=:(.+):$/);
    if (!sigMatch) throw new Error('Invalid signature format');
    const signatureBytes = Buffer.from(sigMatch[1], 'base64');

    // Extract signature-input value (after "sig=")
    const sigInputValue = signatureInput.replace(/^sig=/, '');

    // Build the signature base per RFC 9421
    const targetUri = `https://${req.headers.host}${req.url}`;
    const signatureBase = [
      `"@target-uri": ${targetUri}`,
      `"content-digest": ${contentDigest}`,
      `"content-type": ${contentType}`,
      `"idempotency-key": ${idempotencyKey}`,
      `"@signature-params": ${sigInputValue}`,
    ].join('\n');

    // Verify with Ed25519
    return verify(null, Buffer.from(signatureBase), publicKey, signatureBytes);
  }
  ```

  ```python Python theme={null}
  import base64
  import json
  import os
  import re

  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

  PUBLIC_KEY_JWK = json.loads(
      base64.b64decode(os.environ["ACCESSOWL_WEBHOOK_PUBLIC_KEY"])
  )

  # JWK "x" is base64url without padding; add padding for decoding
  x = PUBLIC_KEY_JWK["x"]
  public_key_bytes = base64.urlsafe_b64decode(x + "=" * (-len(x) % 4))
  public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes)


  def verify_webhook(request) -> bool:
      signature_input = request.headers["signature-input"]
      signature = request.headers["signature"]
      content_digest = request.headers["content-digest"]
      content_type = request.headers["content-type"]
      idempotency_key = request.headers["idempotency-key"]

      # Extract base64 signature from "sig=:BASE64:" format
      sig_match = re.match(r"^sig=:(.+):$", signature)
      if not sig_match:
          raise ValueError("Invalid signature format")
      signature_bytes = base64.b64decode(sig_match.group(1))

      # Extract signature-input value (after "sig=")
      sig_input_value = signature_input.removeprefix("sig=")

      # Build the signature base per RFC 9421
      target_uri = f"https://{request.headers['host']}{request.path}"
      signature_base = "\n".join([
          f'"@target-uri": {target_uri}',
          f'"content-digest": {content_digest}',
          f'"content-type": {content_type}',
          f'"idempotency-key": {idempotency_key}',
          f'"@signature-params": {sig_input_value}',
      ])

      # Verify with Ed25519
      try:
          public_key.verify(signature_bytes, signature_base.encode())
          return True
      except Exception:
          return False
  ```
</CodeGroup>

### Security considerations

**HTTPS required**: The examples construct `@target-uri` with `https://`. AccessOwl only delivers webhooks to HTTPS endpoints. If you're behind a reverse proxy, ensure the `Host` header reflects your public hostname.

**Content-Digest verification**: The signature covers the `Content-Digest` header, but you may also want to independently verify that the digest matches the actual request body to detect any tampering between signature generation and delivery.

**Replay protection**: The `Signature-Input` header includes a `created` timestamp. Consider rejecting requests where this timestamp is more than a few minutes old to prevent replay attacks.

### Test vectors

Use these values to validate your implementation:

```
Public key (JWK):
{"crv":"Ed25519","x":"7EZp3jjRy8iygjUguHNB0IaPTPU8hVyWFy2hCdbwi1s","kty":"OKP","kid":"whsec_test"}

Request:
POST https://example.com/webhook
Host: example.com
Content-Type: application/json
Content-Digest: sha-512=:/OcoCOV1JIOPCUiyfsEsOwlsIF2EoPSD4avSNJ8/ksknyitIPnudRnMBbcZF6HSaLfZO2JpNloCoRgDXbQpzZw==:
Idempotency-Key: 018f1e2a-3b4c-7d8e-9f0a-1b2c3d4e5f6a
Signature-Input: sig=("@target-uri" "content-digest" "content-type" "idempotency-key");created=1718884473;keyid="whsec_test"
Signature: sig=:Ee+jOzzCmLHHQWRglRaespo5p9x2n+YtCHIgG/oPhiSEL7HPaOTK0WepYOjaV+4HF0kziCWhALVCMt9PzkCnAw==:

Body:
{"event_type":"test","data":{}}
```

Verification should return `true` for these inputs.
