{"record":{"id":"8266238f977106be","repo":"knadh/listmonk","slug":"invalid-signature-826623","errorCode":null,"errorMessage":"invalid signature","messagePattern":"invalid signature","errorType":"http","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/bounce/webhooks/lettermint.go","lineNumber":72,"sourceCode":"\t}\n\n\t// Verify timestamp tolerance (300 seconds).\n\tif math.Abs(float64(time.Now().Unix()-ts)) > 300 {\n\t\treturn nil, fmt.Errorf(\"signature timestamp expired\")\n\t}\n\n\t// Decode the hex signature from the header.\n\tsigB, err := hex.DecodeString(strings.TrimSpace(sigHex))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid signature encoding: %v\", err)\n\t}\n\n\t// Compute HMAC-SHA256 of \"{timestamp}.{body}\" and compare.\n\tmac := hmac.New(sha256.New, l.hmacKey)\n\tmac.Write([]byte(fmt.Sprintf(\"%d.%s\", ts, body)))\n\n\tif !hmac.Equal(mac.Sum(nil), sigB) {\n\t\treturn nil, fmt.Errorf(\"invalid signature\")\n\t}\n\n\tvar n lettermintNotif\n\tif err := json.Unmarshal(body, &n); err != nil {\n\t\treturn nil, fmt.Errorf(\"error unmarshalling Lettermint notification: %v\", err)\n\t}\n\n\t// Map event to bounce type.\n\tvar typ string\n\tswitch n.Event {\n\tcase \"message.hard_bounced\":\n\t\ttyp = models.BounceTypeHard\n\tcase \"message.soft_bounced\":\n\t\ttyp = models.BounceTypeSoft\n\tcase \"message.spam_complaint\":\n\t\ttyp = models.BounceTypeComplaint\n\tdefault:\n\t\t// Ignore irrelevant events (e.g. webhook.test).","sourceCodeStart":54,"sourceCodeEnd":90,"githubUrl":"https://github.com/knadh/listmonk/blob/670c01717d48647093335cc23a6be6f4b79c3b6b/internal/bounce/webhooks/lettermint.go#L54-L90","documentation":"Lettermint signs the string \"{timestamp}.{body}\" with HMAC-SHA256 using the shared webhook key. After decoding the hex signature, ProcessBounce compares it against the computed MAC with hmac.Equal. This error means the digests don't match: the body or timestamp was modified in transit, or the wrong signing key is configured.","triggerScenarios":"Any of: the request body was re-encoded/altered by middleware after signing; the t= timestamp in the header doesn't match the one used when signing; the configured hmacKey differs from Lettermint's actual signing secret; the v1 value was computed over the body alone (no \"{ts}.\" prefix); replayed/templated test requests.","commonSituations":"A proxy or WAF modifying the body (re-compression, charset transcoding); reading the body from req.Body then passing a modified copy; copying the signing key from the wrong provider/environment (staging key vs production webhooks); signing with JSON re-serialization that differs byte-for-byte from the sent body; framework middleware that normalizes line endings.","solutions":["Verify the configured key exactly matches the webhook signing secret shown in your Lettermint dashboard (same environment).","Ensure the HMAC is computed over \"{timestamp}.{body}\" using the exact bytes of the request body — pass the raw body untouched to ProcessBounce.","Check no middleware modifies the body between receipt and processing; read the body once and pass those exact bytes.","For local testing, recompute the signature with the documented scheme and current timestamp rather than reusing captured headers."],"exampleFix":"// before: signing body only\ndigest := hmac.New(sha256.New, key); digest.Write(body)\n\n// after: sign \"{timestamp}.{body}\" as Lettermint does\nts := time.Now().Unix()\ndigest := hmac.New(sha256.New, key)\ndigest.Write([]byte(fmt.Sprintf(\"%d.%s\", ts, body)))\nsig := fmt.Sprintf(\"t=%d,v1=%s\", ts, hex.EncodeToString(digest.Sum(nil)))","handlingStrategy":"try-catch","validationCode":"// Pre-check the signing key and header presence before calling the handler:\nfunc canVerifyLettermint(key []byte, sigHeader string) bool {\n    return len(key) > 0 &&\n        strings.Contains(sigHeader, \"t=\") &&\n        strings.Contains(sigHeader, \"v1=\")\n}","typeGuard":null,"tryCatchPattern":"bounces, err := lm.ProcessBounce(sig, body)\nif err != nil {\n    if err.Error() == \"invalid signature\" {\n        log.Printf(\"lettermint HMAC mismatch: check signing key and that body/timestamp bytes are unmodified\")\n        http.Error(w, \"unauthorized\", http.StatusUnauthorized) // never 500 — this is an auth failure\n        return\n    }\n    http.Error(w, \"bad request\", http.StatusBadRequest)\n}","preventionTips":["Pass the raw request body bytes untouched to ProcessBounce — never a re-serialized copy.","Verify the configured key matches the exact environment (staging vs production) of the webhook sender.","Treat HMAC mismatches as security events: log with limited detail, always return 401.","Add round-trip tests that sign with the documented \"{ts}.{body}\" scheme and verify they pass."],"tags":["webhook","hmac","signature","lettermint","security"],"backgroundTag":"webhook-signature-verification-failed","analyzedSha":"670c01717d48647093335cc23a6be6f4b79c3b6b","analyzedAt":"2026-09-01T03:39:35.452Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}