Skip to main content
AccessOwl signs every webhook request using RFC 9421 HTTP Message Signatures. The signing scheme used is Ed25519 (asymmetric). Each request includes three headers you can use for verification:
HeaderDescription
Content-DigestSHA-512 hash of the request body, formatted as sha-512=:BASE64:
Signature-InputDescribes 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"
SignatureThe actual signature over the covered components, formatted as sig=:BASE64:
The key ID in Signature-Input matches the whsec_... identifier shown in Settings → Webhooks, where you can also retrieve the public key to verify signatures.
It’s advised to verify the signature before processing a webhook payload. Reject any request where verification fails.

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:
  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.
We recommend using an HTTP Message Signatures library for your language rather than implementing RFC 9421 verification from scratch.

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 is provided as a JWK. Base64-encode it and set as an environment variable:
export ACCESSOWL_WEBHOOK_PUBLIC_KEY=$(echo '{"crv":"Ed25519","kid":"whsec_xxx","kty":"OKP","x":"..."}' | base64)

Verification function

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);
}

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.