Spam Protection
Every protection the HTML library has, the React package has too. The difference is where the evidence comes from: React’s own events, not DOM listeners. Nothing to configure; this page explains what is happening so you do not accidentally switch it off.
The human gate
Section titled “The human gate”The form does nothing until a person does something. Five signals open the gate:
| Signal | Source |
|---|---|
focus | Any registered input receives focus, or anything inside the form does (capture-phase onFocusCapture). |
click | A click inside the form, or submit() being called. |
keydown | A key pressed inside the form. |
touch | A touch starts inside the form. |
visible | The form element scrolls into view (an IntersectionObserver on formProps.ref, 10% visible). |
The first one to fire opens the server session (idle to initializing) and stamps the moment the visitor started. Until then no request leaves the page, which is why a crawler that never interacts costs you nothing and a script that posts to the API without a session gets nowhere.
The honeypot
Section titled “The honeypot”At init the server names a decoy field for this form, a plausible-looking name like phone that differs per form. form.honeypotProps carries it once the session is open and <SnipForm> (or <Honeypot>) renders it:
<input type="text" name="phone" value="" tabindex="-1" autocomplete="off" aria-hidden="true" readonly style="position:absolute; left:-10000px; width:1px; height:1px; opacity:0; overflow:hidden">Off screen, unfocusable, read only and empty. A human never sees it; a bot that fills every input it finds fills this one, and the server refuses the submission with a 403. The field is sent on every submit as '' and stripped by the server before validation, so it never appears in your submissions.
If you build your own <form> instead of using <SnipForm>, render <Honeypot form={form} /> inside it. Without it the server still accepts the form but you lose the trap.
Behavioural signals
Section titled “Behavioural signals”Alongside the values, each submit carries a small _sig object:
| Key | Meaning | Collected from |
|---|---|---|
dur | Milliseconds between the gate opening and submit | The gate timestamp |
gt | What opened the gate: focus, click, keydown, touch, visible | The gate |
k | Keystrokes | onKeyDown from register() |
f | Focus events | onFocus from register() |
t | Distinct fields touched | onFocus, onChange, setValue |
m | Mouse moved | One passive mousemove listener on window |
s | Page scrolled | One passive scroll listener on window |
w | navigator.webdriver | Set by Puppeteer, Playwright, Selenium |
p | navigator.plugins.length | Headless browsers typically report 0 |
l | navigator.languages.length | Automated browsers often report 0 |
The server combines these with what it can measure itself, chiefly the time between the session opening and the submit arriving, which the client cannot fake, plus network origin (datacenter ranges, Tor exits). The result is a score and one of four actions.
| Action | What happens |
|---|---|
accept | Saved normally. |
flag | Saved, marked suspicious for review in the dashboard. |
fake_success | Not saved. The client receives the normal success response. |
reject | Not saved, logged as spam. The client receives the normal success response. |
What this means for your markup
Section titled “What this means for your markup”Signals are only as good as the events that feed them. Two rules:
- Spread
register()whole. ItsonFocusandonKeyDownare thefandkcounters. An input wired with onlyonChangereports no focus and no keystrokes, which reads as programmatic filling. - Keep at least one typed input. A form made entirely of selects and checkboxes produces no keystrokes. That is fine on its own, but combine it with a fast submit and it scores worse than a form someone typed into. Custom components driven purely by
setValuecount as touched fields but produce neither focus nor keystroke events.
Validation happens before scoring, so a real visitor always sees their validation errors regardless of score.
Signed, pinned, single use
Section titled “Signed, pinned, single use”These are server rules, shared with every client and covered in How Forms Work. The package honours them for you:
- Init and submit carry a signature derived from the form key (or session token) and the UTC date. The package computes it with the same algorithm as the server; a wrong signature is a
403. - The session is pinned to the visitor’s connection and lives 600 seconds. A
419(expired, already used, or connection changed) makes the hook open a fresh session and retry the submit once, invisibly. - The field set is frozen at init. A submit or blur-validate that includes an undeclared field is a
419“Data discrepancy”, which is whyfieldsmust list everything.
Compared with the HTML library
Section titled “Compared with the HTML library”| HTML library | @snipform/react | |
|---|---|---|
| Gate triggers | visible, focus, click, keydown, touch via DOM listeners | The same five, via React events and one observer |
| Honeypot | Injected into the DOM | Rendered from honeypotProps |
| Signals | DOM listeners on the host element | register() handlers plus two passive window listeners |
| Requires | Markup the library can scan | register() spread on inputs |
Same server, same scorer, same outcome. The only way to weaken it from React is to bypass register().