pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).
pay-rails/pay: non-constant-time HMAC comparison in Paddle Billing webhook signature verifier
7.4
/ 10
High
Network
High
None
None
Unchanged
High
High
None
Summary
Pay::Webhooks::PaddleBillingController#valid_signature? (app/controllers/pay/webhooks/paddle_billing_controller.rb) verifies the Paddle Billing webhook signature by computing OpenSSL::HMAC.hexdigest(...) and comparing it to the attacker-supplied header value using Ruby's String#==. Ruby's == is non-constant-time — it returns as soon as the first byte mismatches — and exposes a per-byte timing side channel on the webhook signature verification path. The canonical mitigation is to use a constant-time primitive (OpenSSL.fixed_length_secure_compare / ActiveSupport::SecurityUtils.secure_compare).
Impact
CWE-208 — Observable Timing Discrepancy on the webhook signature verifier.
An attacker who can deliver requests to the /pay/webhooks/paddle_billing mount point can probe the verifier with guessed Paddle-Signature header values. Because String#== short-circuits on the first mismatching byte, the response-time distribution shifts as the prefix of the guess matches the real hex digest.
A signature recovered through the oracle lets the attacker deliver forged Paddle Billing webhook events (e.g. subscription.created / transaction.completed) against the host application. Pay's webhook processor enqueues a Pay::Webhooks::ProcessJob for any accepted webhook, which downstream applications use to update billing state — including provisioning paid features, recording refunds, and triggering customer notifications.
The endpoint is internet-reachable by definition (Paddle must POST events to it).
Affected versions
pay (rubygem) ≤ v11.6.1 (latest release as of 2026-05-27).
hmac is the 64-character hex-encoded SHA-256 HMAC of "<ts>:<raw_post>" under the application's configured Paddle Billing signing secret. The comparison with h1 (the attacker-supplied h1= token from the Paddle-Signature header) uses Ruby's native String#==, which is implemented in MRI as rb_str_equal and returns immediately on the first byte mismatch.
How an attacker reaches this code
Any Pay-using Rails application mounting Pay::Engine exposes POST /pay/webhooks/paddle_billing to the public internet (Paddle requires the endpoint to be reachable). The controller is configured by default in config/routes.rb when paddle_billing is enabled.
The controller's before_action :verify_signature invokes valid_signature? on every inbound request.
An attacker repeatedly POSTs forged webhook payloads with Paddle-Signature: ts=<now>;h1=<guess> headers and measures the response time. The verifier returns early on the first mismatching byte of the hex digest; with a sufficient probe count per byte position, response-time distribution reveals when the prefix of <guess> matches the real hmac.
A signature recovered through the oracle lets the attacker forge arbitrary Paddle Billing webhook deliveries.
Proof of concept (microbenchmark)
Local Ruby microbenchmark isolating the verifier comparison path:
This isolates the same String#== path used by valid_signature?. The static defect is verifiable by bundle show pay and reading line 38 of the controller.
End-to-end reproduction against gem install pay --version 11.6.1
Minimal Rails 8 app mounting Pay::Engine with paddle_billing enabled:
gem install rails -v 8.0.2
rails new payapp --skip-test --skip-bundle
cd payapp
echo "gem 'pay', '11.6.1'" >> Gemfile
echo "gem 'paddle', '~> 2.0'" >> Gemfile
bundle install
bin/rails g pay:install
# config/initializers/pay.rb adds Pay.setup, paddle_billing config
# config/routes.rb already has 'mount Pay::Engine => "/pay"' from generator
bin/rails server &
# attacker probes the webhook endpoint
WEBHOOK="http://127.0.0.1:3000/pay/webhooks/paddle_billing"
BODY='{"event_type":"transaction.completed","data":{}}'
TS=$(date +%s)
# Try guesses with different prefix-match counts; response-time delta is the oracle
for guess in 0000000000000000000000000000000000000000000000000000000000000000 \
a000000000000000000000000000000000000000000000000000000000000000 ; do
for _ in 1 2 3; do
curl -s -w '%{time_total}\n' -o /dev/null \
-X POST -H "Paddle-Signature: ts=$TS;h1=$guess" \
-H 'Content-Type: application/json' -d "$BODY" "$WEBHOOK"
done
done
The static defect is verifiable by:
$ bundle show pay
.../gems/pay-11.6.1
$ sed -n '38p' .../gems/pay-11.6.1/app/controllers/pay/webhooks/paddle_billing_controller.rb
hmac == h1
After the fix is applied, the verifier uses ActiveSupport::SecurityUtils.secure_compare, which compares all bytes regardless of mismatch position, and the timing oracle closes.
Suggested fix
Replace == with ActiveSupport::SecurityUtils.secure_compare (Pay is a Rails engine, so ActiveSupport is always available).
The bytesize-equality guard ensures secure_compare does not return early on a length mismatch (it falls back to == if lengths differ on older Rails versions). For the Paddle Billing signing format the hex tag is a fixed 64 chars.
Credit
Reported by tonghuaroot (https://github.com/tonghuaroot).