Skip to content

Webhook Signature Verification Fails - Secret Not Base64 Decoded Before Signing #318

@XenonOrion

Description

@XenonOrion

Description

Webhook signature verification fails when following the documented Python verification code from here. The issue is a mismatch between the documented Standard Webhooks spec implementation and the actual Go backend implementation.

Steps to Reproduce

Configure a webhook endpoint with a Notifuse webhook secret
Implement verification using the provided Python code from the docs
Receive a webhook and attempt to verify the signature
Verification always fails with "Invalid signature"

Root Cause

The Python documentation shows the Standard Webhooks spec approach - base64 decoding the secret before using it as the HMAC key:

  # From Notifuse docs
  secret_bytes = base64.b64decode(secret.replace('whsec_', ''))
  expected_signature = base64.b64encode(
      hmac.new(secret_bytes, signed_payload.encode(), hashlib.sha256).digest()
  ).decode()

However, the actual Go backend implementation uses the secret as raw bytes without base64 decoding:

// From webhook_delivery_worker.go
	envelope := map[string]interface{}{
		"id":           testID,
		"type":         eventType,
		"workspace_id": workspaceID,
		"timestamp":    time.Now().UTC().Format(time.RFC3339),
		"data":         buildTestPayload(eventType),
	}

	payloadBytes, err := json.Marshal(envelope)
	if err != nil {
		return 0, "", fmt.Errorf("failed to marshal test payload: %w", err)
	}

	// Generate timestamp for signing
	timestamp := time.Now().Unix()

	// Sign the payload
	signature := signPayload(testID, timestamp, payloadBytes, []byte(sub.Secret))
// Called with:
signature := signPayload(delivery.ID, timestamp, payloadBytes, []byte(sub.Secret))
The []byte(sub.Secret) conversion treats the 44-character base64 string as raw UTF-8 bytes (44 bytes), rather than decoding it to the 32-byte value the Standard Webhooks spec expects.

See, code. The []byte(sub.Secret) conversion treats the 44-character base64 string as raw UTF-8 bytes (44 bytes), rather than decoding it to the 32-byte value the Standard Webhooks spec expects.

Evidence

When not decoding verification does work:

    secret_bytes = secret.replace('whsec_', '').encode('utf-8')
    expected_signature = base64.b64encode(
        hmac.new(secret_bytes, signed_payload.encode(),
                 hashlib.sha256).digest()
    ).decode()

The webhook secret is 44 characters long (base64-encoded 32 bytes), confirming it's stored in base64 format. The Go code uses this 44-character string directly as the HMAC key instead of decoding it first.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions