Summary
PayPal webhook signature verification always succeeds. Any POST to
/webhooks/billing/paypal is treated as authentic, regardless of signature.
Verified on phoenix_kit_billing 0.7.1.
Root cause
PhoenixKitBilling.Providers.PayPal.verify_webhook_signature/3 forwards the
signature to verify_webhook_via_api/3, which has two clauses:
# paypal.ex:343
defp verify_webhook_via_api(token, payload, headers) when is_map(headers) do
# builds the verify-webhook-signature request from
# paypal-auth-algo / paypal-cert-url / paypal-transmission-id /
# paypal-transmission-sig / paypal-transmission-time
...
{:ok, %{"verification_status" => "SUCCESS"}} -> :ok
{:ok, _} -> {:error, :invalid_signature}
end
# paypal.ex:363
defp verify_webhook_via_api(_token, _payload, _signature) do
# If signature is just a string, we can't verify properly
# In production, headers should be passed
Logger.warning("PayPal webhook verification requires full headers map")
:ok # <-- paypal.ex:367
end
The first clause requires a map of headers. The controller passes a
single header value:
# webhook_controller.ex:59
def paypal(conn, _params) do
handle_webhook(conn, :paypal, "paypal-transmission-sig")
end
handle_webhook/3 reads that one header with get_req_header/2 and passes the
resulting string down. A string never matches is_map/1, so every PayPal
webhook falls into the second clause, logs a warning and returns :ok.
PayPal's verification API needs five headers, and the controller's own docstring
says so — "PayPal verification requires multiple headers for signature
verification" (webhook_controller.ex:56). The code states the requirement and
does not meet it.
Impact
The {:error, :invalid_signature} branch that returns 401
(webhook_controller.ex:143-147) is unreachable for PayPal. Signature
verification is not a check that can fail — it is a function that returns :ok.
Anyone who knows the endpoint URL can submit webhook events without possessing
the webhook secret. handle_webhook_event/1 acts on event_type /
resource, including CHECKOUT.ORDER.APPROVED.
The route is mounted unconditionally by PhoenixKitBilling.Web.Routes
(routes.ex:18) — there is no gate on "provider configured". A host that
installed the billing module but never configured PayPal still exposes the
endpoint.
How this was observed
External POST to a live host, no signature headers, deliberately malformed body:
POST /webhooks/billing/paypal -> 400
400, not 404 — the route is mounted and the request is processed.
Not 401 — the unsigned request is not rejected at the verification stage; it
proceeds and fails later, while decoding the body.
Note the failure mode is silent by design: the fallback clause logs at
warning level and returns success, so a host whose PayPal integration "works"
in testing is indistinguishable from one where verification never runs.
Suggested fix
- Pass the full header map from the controller to the provider for PayPal,
rather than a single header value.
- Make the fallback clause fail closed: if verification cannot be
performed, return {:error, :invalid_signature} instead of :ok.
- Cover both directions with tests — the negative case (missing headers /
bad signature must yield 401) is the one that matters here; a test that only
asserts the happy path would still pass against the current code.
- Consider gating the route on a configured provider, so hosts that do not use
PayPal do not expose a webhook endpoint at all.
Happy to open a PR if that helps.
Summary
PayPal webhook signature verification always succeeds. Any POST to
/webhooks/billing/paypalis treated as authentic, regardless of signature.Verified on
phoenix_kit_billing0.7.1.Root cause
PhoenixKitBilling.Providers.PayPal.verify_webhook_signature/3forwards thesignature to
verify_webhook_via_api/3, which has two clauses:The first clause requires a map of headers. The controller passes a
single header value:
handle_webhook/3reads that one header withget_req_header/2and passes theresulting string down. A string never matches
is_map/1, so every PayPalwebhook falls into the second clause, logs a warning and returns
:ok.PayPal's verification API needs five headers, and the controller's own docstring
says so — "PayPal verification requires multiple headers for signature
verification" (webhook_controller.ex:56). The code states the requirement and
does not meet it.
Impact
The
{:error, :invalid_signature}branch that returns 401(webhook_controller.ex:143-147) is unreachable for PayPal. Signature
verification is not a check that can fail — it is a function that returns
:ok.Anyone who knows the endpoint URL can submit webhook events without possessing
the webhook secret.
handle_webhook_event/1acts onevent_type/resource, includingCHECKOUT.ORDER.APPROVED.The route is mounted unconditionally by
PhoenixKitBilling.Web.Routes(routes.ex:18) — there is no gate on "provider configured". A host that
installed the billing module but never configured PayPal still exposes the
endpoint.
How this was observed
External POST to a live host, no signature headers, deliberately malformed body:
400, not 404 — the route is mounted and the request is processed.
Not 401 — the unsigned request is not rejected at the verification stage; it
proceeds and fails later, while decoding the body.
Note the failure mode is silent by design: the fallback clause logs at
warninglevel and returns success, so a host whose PayPal integration "works"in testing is indistinguishable from one where verification never runs.
Suggested fix
rather than a single header value.
performed, return
{:error, :invalid_signature}instead of:ok.bad signature must yield 401) is the one that matters here; a test that only
asserts the happy path would still pass against the current code.
PayPal do not expose a webhook endpoint at all.
Happy to open a PR if that helps.