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:
| 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, 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:
- For each component listed in
Signature-Input, in order, emit one line formatted as:
"<component-name>": <component-value>
- Append a final line using the exact string value of the
Signature-Input header:
"@signature-params": <Signature-Input value>
- 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.