Signals & weights
Signals & weights
The final score is Σ (signal points × weight). Each signal decides its own
base points (how bad the observation is); the weight in config is how much
you trust that signal. Two dials, both explicit — nothing hidden.
score = Σ points(signal) × weights[signal.key]
outcome = most severe band in `thresholds` that score reaches
The signals
| Key | Fires when | Base points | Config |
|---|---|---|---|
honeypot |
Hidden field filled (dispositive) | filled_points (100) |
risk.honeypot_signal |
honeypot |
Submitted faster than min_seconds |
too_fast_points (60) |
risk.honeypot_signal |
user_agent |
No User-Agent | 40 | — |
user_agent |
Automated client (curl, python-requests, headless…) |
45 | — |
user_agent |
Missing Accept/Accept-Language |
15 | — |
email.disposable |
Domain on the disposable list | 40 | risk.disposable_domains_path |
email.no_mx |
Domain has no MX/A record (undeliverable) | 50 | — |
velocity |
> threshold requests per IP in windows |
30 + overage (max 60) | risk.velocity |
ip.reputation |
IP on ≥ medium_level blocklists |
medium_points (25) |
risk.ip_reputation |
ip.reputation |
IP on ≥ strong_level blocklists |
strong_points (50) |
risk.ip_reputation |
ip.tor_exit |
IP is a Tor exit node | 20 | — |
ip.stopforumspam |
IP appears on StopForumSpam (opt-in) | 40 | in the signal's constructor |
Weights
config('risk.weights') scales each signal by its key. A weight of 0 mutes a
signal without removing it; >1 amplifies it.
'weights' => [
'ip.reputation' => 1.5, // you trust ipsum a lot
'user_agent' => 0.5, // your audience has many odd but legit UAs
'email.disposable' => 1.0,
'velocity' => 1.0,
// …
],
IP reputation — what "level" means
The ip.reputation signal reads stamparm/ipsum,
whose level is the number of independent blocklists an IP appears on. One list
could be noise; five lists agreeing is strong corroboration. So the signal has two
bands rather than a single flag:
'ip_reputation' => [
'strong_level' => 5, // on >= 5 lists -> strong_points
'strong_points' => 50,
'medium_level' => 3, // on >= 3 lists -> medium_points
'medium_points' => 25,
],
Because CGNAT and shared IPs cause false positives, even strong_points (50) is
below the single-signal reject threshold (80) — IP reputation must accumulate
with another signal to block. Run risk:refresh-ipsum daily to keep the list warm.
Velocity — IP + action, stored as an HMAC
velocity counts requests per (action, IP) in a sliding window. The IP is stored
only as an HMAC (never raw), with the window as its TTL — no durable PII.
'velocity' => [
'window' => 300, // seconds
'threshold' => 5, // points start on the 6th request
],
Points scale with the overage (30 base, +5 per request over, capped at 60), so a burst climbs toward challenge/step-up without a single request hard-blocking.
Email — disposable list vs deliverability
Two independent email signals:
email.disposable— domain membership against a list. The bundled list is a small starter set; publish and refresh the full amieiro/disposable-email-domains (MIT) list and pointrisk.disposable_domains_pathat it. Or bind your ownDisposableDomains(e.g. DB-backed) — see Extending.email.no_mx— the domain can't receive mail at all. Independent of the disposable list: a typo domain or a made-up one scores here even if it isn't a known disposable provider.
Both firing together (disposable and no MX = 40 + 50 = 90) rejects on email alone — appropriate, since the address is both throwaway and undeliverable.
Thresholds — score to outcome
'thresholds' => [
Outcome::Flag->value => 15,
Outcome::Challenge->value => 30,
Outcome::StepUp->value => 60,
Outcome::Reject->value => 80,
],
Raise a band to be more permissive (fewer CAPTCHAs), lower it to be stricter. See Cookbook → tuning for a calibration workflow.