knadh/listmonk · error

invalid signature

Error message

invalid signature

What it means

ProcessBounce computes HMAC-SHA256 over the raw body with the configured hmacKey and compares it to the hex-decoded signature using hmac.Equal. This error means the provided signature does not match the computed one, so the payload is not from Forwardemail (or was tampered with) and is rejected before JSON parsing.

Source

Thrown at internal/bounce/webhooks/forwardemail.go:67

func (p *Forwardemail) ProcessBounce(sigHex string, body []byte) ([]models.Bounce, error) {
	if len(p.hmacKey) == 0 {
		return nil, errors.New("webhook key is not configured")
	}

	// Decode the hex-encoded signature from the webhook
	sig, err := hex.DecodeString(sigHex)
	if err != nil {
		return nil, fmt.Errorf("invalid signature encoding: %v", err)
	}

	// Generate HMAC using the request body and secret key
	mac := hmac.New(sha256.New, p.hmacKey)
	mac.Write(body)
	expectedSignature := mac.Sum(nil)

	// Compare the generated signature with the provided signature
	if !hmac.Equal(expectedSignature, sig) {
		return nil, errors.New("invalid signature")
	}

	// Parse the JSON payload
	var n forwardemailNotif
	if err := json.Unmarshal(body, &n); err != nil {
		return nil, fmt.Errorf("error unmarshalling Forwardemail notification: %v", err)
	}

	// Categorize the bounce type
	typ := models.BounceTypeSoft
	hardBounceCategories := []string{"block", "recipient", "virus", "spam"}
	for _, category := range hardBounceCategories {
		if n.Bounce.Category == category {
			typ = models.BounceTypeHard
			break
		}
	}

View on GitHub (pinned to 670c01717d)

Solutions

  1. Verify the configured hmacKey exactly matches the webhook secret in your Forwardemail account settings.
  2. Sign/verify over the exact raw request bytes — read the body once and hash those bytes without re-marshalling JSON.
  3. Ensure the signature is plain lowercase hex of the 32-byte HMAC (no prefixes, correct encoding).
  4. Confirm no middleware modifies the request body before ProcessBounce; log both computed and received digests when debugging.
  5. Check for clock-independent replay issues: hmac.Equal is constant-time, so a mismatch is a content/key problem, not timing.

Example fix

// before
body, _ = json.Marshal(reparsedPayload) // mutates bytes before verify
bounces, err := p.ProcessBounce(sigHex, body)
// after
raw, _ := io.ReadAll(c.Request().Body) // verify exact raw bytes
bounces, err := p.ProcessBounce(sigHex, raw)
Defensive patterns

Strategy: validation

Validate before calling

if _, err := hex.DecodeString(sigHex); err != nil || len(sigHex) != 64 {
	return echo.NewHTTPError(http.StatusBadRequest, "malformed signature")
}

Type guard

func isHexSHA256(sigHex string) bool {
	b, err := hex.DecodeString(sigHex)
	return err == nil && len(b) == sha256.Size
}

Try / catch

bounces, err := p.ProcessBounce(sigHex, rawBody)
if err != nil {
	if strings.Contains(err.Error(), "invalid signature") {
		// return 401; log computed vs received HMAC digest (not the key) for diagnosis
		return echo.NewHTTPError(http.StatusUnauthorized)
	}
	return err
}

Prevention

When it happens

Trigger: The sigHex supplied does not equal the HMAC-SHA256 of body under the shared key: wrong signing key, body modified before verification (e.g. re-serialized/re-indented JSON), signature not hex-encoded, or signature computed over different bytes.

Common situations: Rotated webhook keys where Forwardemail still signs with the old key; a proxy/gateway rewriting the body (compression, charset conversion, re-encoding) between receipt and verification; passing the signature with 0x prefix or base64 instead of raw hex; tests replaying a body captured with trailing newline stripped.

Related errors


AI-assisted analysis of knadh/listmonk@670c01717d (2026-09-01). Data as JSON: /api/errors/0ad9e68c5834ea64. Report an issue: GitHub.