Security

Verify webhook authenticity and prevent tampering

Overview

To ensure that webhook requests originate from Fynapse and have not been tampered with, each request is signed using a shared secret.

Your system must validate this signature before processing any webhook payload.

Why Validation Is Required

Webhook endpoints are public HTTP endpoints. Without validation, an attacker could:

  • Send forged requests to your system
  • Replay previously captured webhook payloads
  • Trigger unintended processing

Signature verification ensures that:

  • the request was sent by Fynapse
  • the payload has not been modified in transit

Signing Model

Fynapse signs each webhook request using HMAC-SHA256.

The signature is calculated over the following input:

<timestamp>.<raw_request_body>

Where:

  • timestamp is a Unix timestamp (seconds)
  • raw_request_body is the exact HTTP request body as received

Signature Header Format

Each request includes a Webhook-Signature header:

Webhook-Signature: t=<timestamp>,v1=<signature>[,v1=<previous_signature>]

Components

t — Unix timestamp used in the signature v1 — HMAC-SHA256 signature (hex-encoded)

During secret rotation, multiple v1 values may be present. Your system should accept the request if any one of the signatures is valid.

How to Validate a Request

To verify a webhook request:

  • Extract the Webhook-Signature header
  • Parse the timestamp (t) and all v1 signatures
  • Reconstruct the signing input: <timestamp>.<raw_request_body>
  • Compute the HMAC-SHA256 using your secret
  • Compare the computed signature with each provided v1 value
  • Accept the request if any match

Validation Rules

When implementing validation, follow these rules:

RuleDescription
Use raw request bodyUse the body exactly as received; do not reformat or reserialize JSON
Perform constant-time comparisonPrevent timing attacks when comparing signatures
Validate timestamp freshnessReject requests outside an acceptable time window (e.g., 5 minutes)
Support multiple signaturesAllow validation against multiple signatures for secret rotation
Include timestamp in signed inputAlways include timestamp; never validate against the body alone

The following example demonstrates how to validate a webhook signature in Java:

1import java.nio.charset.StandardCharsets;
2import java.security.MessageDigest;
3import java.time.Duration;
4import java.time.Instant;
5import java.util.ArrayList;
6import java.util.HexFormat;
7import java.util.List;
8import javax.crypto.Mac;
9import javax.crypto.spec.SecretKeySpec;
10public final class WebhookSignatureVerifier {
11 private static final String HMAC_SHA256 = "HmacSHA256";
12 private static final HexFormat HEX = HexFormat.of();
13 private static final Duration MAX_CLOCK_SKEW = Duration.ofMinutes(5);
14 public static boolean verify(String signatureHeader, String rawBody, String secret) {
15 if (signatureHeader == null || signatureHeader.isBlank()) {
16 return false;
17 }
18 if (rawBody == null || secret == null || secret.isBlank()) {
19 return false;
20 }
21 ParsedSignature parsed = parseHeader(signatureHeader);
22 long now = Instant.now().getEpochSecond();
23 if (Math.abs(now - parsed.timestamp()) > MAX_CLOCK_SKEW.toSeconds()) {
24 return false;
25 }
26 String signingInput = parsed.timestamp() + "." + rawBody;
27 String expected = hmacSha256Hex(signingInput, secret);
28 for (String provided: parsed.signatures()) {
29 if (MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), provided.getBytes(StandardCharsets.UTF_8))) {
30 return true;
31 }
32 }
33 return false;
34 }
35 private static String hmacSha256Hex(String value, String secret) {
36 try {
37 Mac mac = Mac.getInstance(HMAC_SHA256);
38 mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
39 return HEX.formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
40 } catch (Exception e) {
41 throw new IllegalStateException("Cannot verify webhook signature", e);
42 }
43 }
44 private static ParsedSignature parseHeader(String header) {
45 Long timestamp = null;
46 List < String > signatures = new ArrayList < > ();
47 for (String part: header.split(",")) {
48 String[] keyValue = part.trim().split("=", 2);
49 if (keyValue.length != 2) {
50 continue;
51 }
52 if ("t".equals(keyValue[0])) {
53 timestamp = Long.parseLong(keyValue[1]);
54 } else if ("v1".equals(keyValue[0])) {
55 signatures.add(keyValue[1]);
56 }
57 }
58 if (timestamp == null || signatures.isEmpty()) {
59 throw new IllegalArgumentException("Invalid Webhook-Signature header");
60 }
61 return new ParsedSignature(timestamp, signatures);
62 }
63 private record ParsedSignature(long timestamp, List < String > signatures) {}
64}

Secret Management

Creating a Secret

A webhook secret is generated by Fynapse and used to sign requests.

  • The secret value is returned only when:
    • creating a secret
    • rotating a secret
    • retrieving secret details
  • You must store this value securely on your side

Using a Secret

  • Each webhook subscription references a secret
  • The same secret is used to sign all deliveries for that subscription

Rotating a Secret

Secrets can be rotated to improve security.

During rotation:

  • Fynapse may include multiple signatures in the header
  • One corresponds to the new secret
  • One corresponds to the previous secret

This allows you to:

  • deploy the new secret safely
  • continue accepting requests during the transition