Skip to content

Commit e66836c

Browse files
docs: enhance webhook security documentation with signature verification details
1 parent 09288a5 commit e66836c

1 file changed

Lines changed: 152 additions & 3 deletions

File tree

docs/webhooks/security.md

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,156 @@ OAuth authentication enables secure, token-based access to your webhook endpoint
2222
When configured with OAuth, epilot will obtain an access token from your authorization server and include it in the `Authorization` header with the `Bearer` scheme. This approach provides enhanced security through token expiration and refresh mechanisms.
2323

2424

25-
### How Webhooks Are Further Secured (Asymmetric Signature)
26-
To ensure that webhook requests are secure, epilot uses an asymmetric signature mechanism. This involves generating a unique signature for each request that is sent to the external system. The signature is created using a private key that is known only to the epilot platform, and it is verified by the external system using a corresponding [public key](https://webhooks.sls.epilot.io/v1/webhooks/.well-known/public-key). This ensures that only authorized requests are processed, and it prevents unauthorized access to the external system.
25+
# Webhook Signature Verification
2726

28-
To secure the endpoint our webhook is calling, you need to verify the signature of the request. We recommend to use the `verifyEpilotSignature` function by our App SDK as it handles the verification process for you. This function checks the signature against the public key and ensures that the request is valid. We use a standard way to sign & verify the requests according to the [webhook spec](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md).
27+
Every webhook request from epilot includes three signature headers:
28+
29+
| Header | Description |
30+
|--------|-------------|
31+
| `webhook-id` | Unique message identifier (e.g. `msg_2a4f8b...`) |
32+
| `webhook-timestamp` | Unix timestamp (seconds) when the request was signed |
33+
| `webhook-signature` | Space-separated signatures: `v1a,<asymmetric> v1s,<symmetric>` |
34+
35+
## Two Signatures, Two Purposes
36+
37+
epilot sends **two** signatures with each webhook request:
38+
39+
- **`v1a`** (asymmetric, Ed25519) — Proves the request came from epilot. Verified using epilot's public key, which you can fetch from the `/v1/webhooks/.well-known/public-key` endpoint.
40+
- **`v1s`** (symmetric, HMAC-SHA256) — Proves the request is intended for your specific webhook. Verified using the `whsec_...` signing secret you received when the webhook was created.
41+
42+
Both signatures are computed over the same content:
43+
44+
```
45+
signed_content = ${webhook-id}.${webhook-timestamp}.${request_body}
46+
```
47+
48+
## Signed Payload Fields
49+
50+
Every webhook payload includes two system-injected fields that are **always set by epilot after any payload transformations** (including JSONata). These fields cannot be modified or spoofed:
51+
52+
- `_org_id` — The epilot organization ID that owns the webhook
53+
- `_webhook_event_id` — The unique event ID for this webhook invocation
54+
55+
## Verification
56+
57+
### Option 1: Symmetric Verification (recommended for most use cases)
58+
59+
Use the [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) npm package to verify the `v1s` signature with your webhook's signing secret.
60+
61+
```typescript
62+
import { Webhook } from "standardwebhooks";
63+
64+
const signingSecret = "whsec_..."; // from webhook creation response
65+
66+
function verifyWebhook(req: Request): boolean {
67+
const payload = req.body; // raw request body as string
68+
const headers = req.headers;
69+
70+
// Extract the v1s signature and convert prefix for standardwebhooks compatibility
71+
const signatureHeader = headers["webhook-signature"];
72+
const v1sSig = signatureHeader
73+
.split(" ")
74+
.find((s) => s.startsWith("v1s,"));
75+
76+
if (!v1sSig) {
77+
throw new Error("No symmetric signature found");
78+
}
79+
80+
// standardwebhooks expects "v1," prefix, so replace "v1s," -> "v1,"
81+
const standardSig = v1sSig.replace("v1s,", "v1,");
82+
83+
const wh = new Webhook(signingSecret);
84+
85+
// verify() throws if the signature is invalid or timestamp is too old
86+
wh.verify(payload, {
87+
"webhook-id": headers["webhook-id"],
88+
"webhook-timestamp": headers["webhook-timestamp"],
89+
"webhook-signature": standardSig,
90+
});
91+
92+
return true;
93+
}
94+
```
95+
96+
### Option 2: Asymmetric Verification
97+
98+
Use Node.js `crypto` to verify the `v1a` Ed25519 signature with epilot's public key.
99+
100+
```typescript
101+
import crypto from "node:crypto";
102+
103+
// Fetch once and cache — this key rarely changes
104+
// GET /v1/webhooks/.well-known/public-key
105+
const epilotPublicKey = `-----BEGIN PUBLIC KEY-----
106+
MCowBQYDK2VwAyEA...
107+
-----END PUBLIC KEY-----`;
108+
109+
function verifyAsymmetric(req: Request): boolean {
110+
const payload = req.body; // raw request body as string
111+
const headers = req.headers;
112+
113+
const signatureHeader = headers["webhook-signature"];
114+
const v1aSig = signatureHeader
115+
.split(" ")
116+
.find((s) => s.startsWith("v1a,"));
117+
118+
if (!v1aSig) {
119+
throw new Error("No asymmetric signature found");
120+
}
121+
122+
const signature = Buffer.from(v1aSig.replace("v1a,", ""), "base64");
123+
124+
const signedContent = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${payload}`;
125+
126+
return crypto.verify(
127+
null,
128+
new TextEncoder().encode(signedContent),
129+
epilotPublicKey,
130+
signature
131+
);
132+
}
133+
```
134+
135+
### Option 3: Verify Both
136+
137+
For maximum security, verify both signatures:
138+
139+
```typescript
140+
function verifyWebhookFull(req: Request): boolean {
141+
// 1. Check timestamp freshness (reject requests older than 5 minutes)
142+
const timestamp = Number(req.headers["webhook-timestamp"]);
143+
const now = Math.floor(Date.now() / 1000);
144+
if (Math.abs(now - timestamp) > 300) {
145+
throw new Error("Webhook timestamp too old — possible replay attack");
146+
}
147+
148+
// 2. Verify asymmetric signature (proves it came from epilot)
149+
const isFromEpilot = verifyAsymmetric(req);
150+
if (!isFromEpilot) {
151+
throw new Error("Invalid asymmetric signature");
152+
}
153+
154+
// 3. Verify symmetric signature (proves it's for your webhook)
155+
verifyWebhook(req); // throws on failure
156+
157+
return true;
158+
}
159+
```
160+
161+
## Fetching the Public Key
162+
163+
```bash
164+
curl https://webhooks.sls.epilot.cloud/v1/webhooks/.well-known/public-key
165+
```
166+
167+
```json
168+
{
169+
"publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----\n"
170+
}
171+
```
172+
173+
Cache this key — it only changes if epilot rotates signing keys.
174+
175+
## Signing Secret
176+
177+
The `whsec_...` signing secret is returned **only once** in the response when you create a webhook. Store it securely. If lost, you'll need to recreate the webhook to get a new one.

0 commit comments

Comments
 (0)