Form and sign-up protection
Form protection answers one question: can this particular action — a sign-up, a login, a form submission — be trusted. You place a small tag on your pages, your backend asks us for an assessment at the right moment and receives a verdict with reason codes.
The site itself may stay wherever it is; moving the domain to WebShield is not required. If it is already behind our protection, form protection is still installed separately and solves its own task: see If the site is already behind WebShield.
Setup in three steps
Section titled “Setup in three steps”- Create a project — you receive a site key.
- Install the tag on your pages and list your forms in it.
- Call scoring from your backend and apply the verdict.
Optionally after that: report the outcome of an action so that the assessment adapts to your site.
Step 1. Create a project
Section titled “Step 1. Create a project”In the control panel open Services → Form protection and create a project:
- Name — anything you like, for your own convenience.
- Allowed site addresses — scheme and domain without a path, for example
https://example.comandhttps://www.example.com. Events are accepted only from these addresses.
You will receive a site key and a ready-made installation snippet.
Step 2. Install the tag
Section titled “Step 2. Install the tag”Place the snippet in <head> on every page, including pages with forms. If you have forms, list them
comma-separated in the data-forms attribute as ordinary CSS selectors:
<script src="https://af.webshield.pro/t/v1.js?k=YOUR_KEY" data-forms="#signup, #contact_form" async></script>The tag blocks nothing and does not delay page loading: the async attribute is required, and the heavy
part of the work (the environment fingerprint) runs later, together with the first event.
A place in <head> is preferable to the end of <body> for two reasons. First, the visitor’s interaction
with the page is measured from the moment the tag starts, and a late start distorts that picture against an
ordinary visitor. Second, the tag is ready sooner — there is less chance that a form is submitted before it
is bound. The markup may not be ready at that point, and that is fine: the tag waits for it on its own.
The site key is public — it is visible in the page source, and that is fine: access is restricted by the list of allowed addresses. The script URL is permanent; updates arrive on their own.
What the tag does with the listed forms:
- adds a hidden
ws_sidfield to each one — the session identifier your backend needs; - asks us for a verdict on submission;
- shows the visitor a challenge on a doubtful verdict and continues the submission once it is solved — if the challenge is enabled.
The hidden field name is changed with data-sid-field="name". A selector that matches nothing on a given
page simply does not fire. Forms that appear later — modal windows, SPA routing — are picked up by the tag
itself as it watches for markup changes; nothing is required on your side.
If a form is submitted by your own code and you want to control the moment of submission, see Manual form binding.
Step 3. Call scoring from your backend
Section titled “Step 3. Call scoring from your backend”You will need an API token with the antifraud:write scope. Your backend takes the
ws_sid field from the form and passes it as session_id:
import logging
import requests
def ws_decision(session_id: str, event: str, identity: str = "") -> str: """WebShield verdict: "allow", "challenge" or "deny".""" try: resp = requests.post( "https://webshield.pro/api/v1/antifraud/score", headers={"Authorization": f"Bearer {WS_TOKEN}"}, json={ "site_key": WS_SITE_KEY, "session_id": session_id, "event": event, # Optional, see "User identifier" below. "identity_sha256": identity, }, timeout=3, ) resp.raise_for_status() return resp.json()["decision"] except (requests.RequestException, ValueError, KeyError): # Our unavailability must not break your form. Logging is mandatory, # however: a persistent error here means the check is silently off — # an expired token or a changed project key, for example. logging.exception("WebShield score failed") return "allow"
decision = ws_decision(request.POST.get("ws_sid", ""), "signup", ws_identity(email))
if decision == "deny": ... # rejectelif decision == "challenge": ... # ask the visitor to pass the check (see "Visitor check") and score againelse: ... # accept the actionResponse:
{ "decision": "challenge", "score": 55, "reasons": ["behavior_auto", "velocity_fp"], "needs_challenge": true, "session": { "visitor_id": "…", "device_id_hw": "…", "behavior": "auto" }}A few practical rules:
- Accept the action only on
allow. The other two verdicts require a decision from you, and both matter:denymeans reject,challengemeans check instead of reject. - An empty or unknown
session_idis not an error — but it is not “clean” either. The response carries theno_sessionreason and achallengeverdict. That is how both a visitor without JavaScript and a request sent outside a browser look — and the latter is the cheapest bypass there is, so it cannot be waved through. Do not skip the call: a missing session is a signal in itself. - You choose the
eventnames —signup,login,contact. Use different names: event velocity is counted per name, and ten sign-ups an hour must not be mixed with ten messages. - A session belongs to the visitor, not to a form. It survives page reloads, so one
sidcovers opening the form, submitting it and retrying after a validation error.
User identifier: identity_sha256
Section titled “User identifier: identity_sha256”An optional field that noticeably improves the quality of the assessment. Send it if you have an identifier
of the user: an email address, a phone number, the sub from your SSO (Google, Apple, Keycloak) or an
internal account identifier — any of them will do, as long as it is the same one for the same person.
We do not accept raw addresses, phone numbers or names as a matter of principle — only a hash — so your users’ data never reaches our system.
Compute it like this:
import hmacimport os
# Your own secret — a random string created ONCE:# python -c "import secrets; print(secrets.token_hex(32))"# Keep it with the rest of your application secrets and do not change it: with a# new secret the same person becomes a different one to us. It is never sent to us.WS_SALT = os.environ["WS_SALT"].encode() # the HMAC key is bytes, not a string
def ws_identity(value: str) -> str: return hmac.new(WS_SALT, value.strip().lower().encode(), "sha256").hexdigest()Two conditions for the matching to work:
- bring the value to a single form — phone numbers in E.164 format (
+15551234567), email in lower case without surrounding spaces (ws_identityabove does this). OtherwiseIvan@example.comandivan@example.comwill be different people to us; - use the same secret for scoring and for reporting outcomes — otherwise we cannot match one to the other.
Step 4 (optional). Report the outcome
Section titled “Step 4 (optional). Report the outcome”Only you know how an action ended: a chargeback, a banned account, spam in a form — or, conversely, a completed purchase. Send the outcome once it is known, even a week later:
requests.post( "https://webshield.pro/api/v1/antifraud/feedback", headers={"Authorization": f"Bearer {WS_TOKEN}"}, json={ "site_key": WS_SITE_KEY, "session_id": saved_ws_sid, # the same one used for scoring "event": "signup", "outcome": "fraud", # or "legit" }, timeout=3,)Why this is worth doing:
- a device and an address marked as
fraudadd extra weight to the assessment for some time — within your project only; other clients of the platform are not affected; - a mistake can be corrected:
legitfor the same action removes the mark; - accumulated outcomes are the only way to tune the thresholds to your site rather than to an average one.
A repeated report about the same action updates the previous one instead of adding another. There is no free text in the request by design: the outcome itself is enough for tuning.
If the session_id was not stored
Section titled “If the session_id was not stored”Keeping the session identifier is not convenient for everyone — it is an extra column in your database. In
that case send identity_sha256 instead: you always have a user identifier.
json={ "site_key": WS_SITE_KEY, "identity_sha256": ws_identity(email), # the same secret as in scoring "event": "signup", "outcome": "fraud",}We will find the recent sessions of that user and label them — so the report is still attached to specific actions; the identifier only helps to find them.
One condition: this works only for users you passed during scoring, and with the same secret. We have no
data about your users of our own, and must not have any, so there is nothing else to match on. If the
identifier is unknown to us, the response comes back with "matched": 0 — this is not an error. matched
always shows how many sessions were labelled, and sessions shows which ones.
An explicit session_id is more precise: it points at one specific action, whereas an identifier labels
several recent sessions, one of which may be unrelated. If both fields are sent, session_id is used.
What the verdict means
Section titled “What the verdict means”| Verdict | Meaning | Common response |
|---|---|---|
allow | No signs of abuse found | Let it through |
challenge | Signs are present but insufficient for refusal — or there are no observations at all (no_session) | Show a challenge, email confirmation, delayed moderation |
deny | Strong signs confirmed by our own data | Reject or send to manual review |
A refusal (deny) is issued only when our own observations confirm it. Data obtained from the page
alone is not enough for a refusal: code on the page can be forged, and blocking a real person because of it
is not acceptable.
The session block is what we saw in this session:
| Field | Meaning |
|---|---|
visitor_id | the visitor: survives page reloads and navigation across the site |
device_id, device_id_hw | the device by the full fingerprint and by its hardware core — the latter survives a change of browser, private windows and address spoofing |
ip | the visitor’s address |
behavior | how the interaction with the page looked: human, weak, auto, idle |
tls_class, hdr_class | whether the connection and the headers look like a real browser |
pages, visits, events, submits, age_seconds | the structure of the visit: pages opened, visits during the week, events and submissions, session age |
We return the structure of the visit as raw numbers deliberately: the rule “a sign-up from the first screen without a single navigation” depends on your business. For an online store such a visit is ordinary; for a service with a long funnel it is not.
The visitor and device identifiers are unique to your project: the same device on another site gets different values. Within your project they are stable and serve your own matching — linking repeat registrations, blocking a device. What they cannot do is track a person across different sites — neither for another client of ours, nor for us.
Visitor challenge
Section titled “Visitor challenge”When the assessment is ambiguous, the response contains "needs_challenge": true — an invitation to show
the visitor a short picture task that our tag renders on top of your page. It is a way through for a person
we could not confidently classify as human; a solved task is not a proof of “not a bot”.
If your forms are listed in data-forms, this is already built in — nothing to do. For your own code:
if (resp.needs_challenge) { try { await wsAf.challenge(); // shows the task and waits for the solution await submitAgain(); // the same request, the same ws_sid } catch (err) { // The visitor closed the challenge or failed it. }}The repeated scoring call uses the same session_id — we keep the record of the solved task ourselves.
A solved task lowers the score but does not cancel the evidence, and it applies to the next few scoring
calls rather than to the whole session. If it did not change the verdict, reasons will contain
captcha_passed_but_denied or captcha_passed_but_auto — showing that the task was solved but the
suspicion remained.
The switch in the control panel
Section titled “The switch in the control panel”The single project switch controls exactly one thing: whether the tag may interrupt the visitor with a challenge. It is off by default — the visitor sees nothing.
This does not affect the verdict in the API. decision, score and reasons are genuine in both
positions. Only needs_challenge differs: with the challenge off it is always false, because there is
nothing to show.
That is also how to start: keep the challenge off, log the verdicts alongside the outcome of each request
for a few days, look at the report — and only then enable the challenge and start acting on decision.
Report
Section titled “Report”The project card contains a report:
- Automation — the share of events with signs of an automated browser.
- Devices — the number of distinguishable devices; compare it with the number of visitors.
- Reason codes — what triggers most often.
- Suspected farm — one device arriving from several addresses.
- Recent sessions — visits one by one: time, address, pages, form submissions, behaviour, score.
A disputed case is easier to examine session by session: the summary answers “how many”, while the decision is made per visit.
Manual form binding
Section titled “Manual form binding”Needed only if a form is submitted by your code and you want to control the moment of submission yourself.
In every other case listing the forms in data-forms is simpler: the tag requests the session on the
“Submit” click itself rather than on a background timer, so the field is filled in even for someone who
completed the form in a couple of seconds.
The session identifier is known only to the page, and there is nowhere to read it from on the backend:
the tag works cross-domain and deliberately sets no cookies. The value of wsAf.state().sid does not appear
immediately — the session is issued by our collector in response to the first event, and before sending it
the tag observes the visitor for a while. So do not wait for the background timer; request the session on
the first interaction with the form:
// The tag loads asynchronously, so check that it is already present.document.querySelector("#signup").addEventListener("focusin", function () { if (window.wsAf) wsAf.event("form_open");}, { once: true });
// Submitting via JavaScript: read the value right before the request, not in advance.payload.ws_sid = (window.wsAf && wsAf.state().sid) || "";For an ordinary form with a page reload, fill in a hidden field as soon as the session arrives:
<form id="signup" method="post" action="/signup"> <input type="hidden" name="ws_sid" value=""></form>
<script> window.addEventListener("load", function () { if (!window.wsAf) return; wsAf.onVerdict(function (state) { var field = document.querySelector('#signup input[name="ws_sid"]'); if (field) field.value = state.sid || ""; }); });</script>The page code can also use wsAf.token() (the current session token), wsAf.event("name") (your own event)
and wsAf.onVerdict(fn) (the verdict once it arrives).
What to keep in mind
Section titled “What to keep in mind”- Binding forms in the tag is convenience, not protection. The decision is made in the browser, and a bot simply will not execute our handler — it will send the request directly. The scoring call from your backend is mandatory: it is the only place where the decision cannot be bypassed.
- A failure on our side does not break your form. If the collector is unavailable or the visitor closes the challenge, the form is submitted as usual, and the decision is still made by your backend.
- A bot without JavaScript will not run the tag — this appears as
no_session, and the default verdict ischallenge: in that case we have no observations at all, and a request sent outside a browser must not be waved through. For a mobile application calling the same API without a page this is expected — give it a separate path that does not rely on the tag. - The assessment is probabilistic. No system detects automation with absolute accuracy, and automation of a real browser is the hardest case to recognise.
- Data from your perimeter does not affect other clients of the platform — we accept it only for your own assessments and reports.
What is collected
Section titled “What is collected”A technical fingerprint of the environment (screen and graphics parameters, the set of fonts), anonymised behaviour aggregates, the address, headers and connection parameters. Not collected: form field contents, page text, typed characters, contact details.
For a form we count only anonymised counters and timings of the interaction with it. Which characters were typed we neither know nor store.
If the site is already behind WebShield
Section titled “If the site is already behind WebShield”Domain protection and form protection solve different tasks. Bot protection works at the request level: let through, show a browser check, block. It knows nothing about the meaning of the action — a sign-up form submission is indistinguishable from a page view to it.
Form protection answers exactly the question “should this request be accepted”, and does so at the moment your code makes the decision. That is why a form protection project is needed even when the site is already proxied through us. The setup is the same; there are two differences:
- in allowed addresses, specify the domain protected by us;
- the assessment additionally includes the address reputation accumulated by our protection on your own domain: a visitor previously caught brute-forcing or belonging to a detected farm arrives with that signal already attached.
The tag stays cross-domain (https://af.webshield.pro/t/v1.js?k=…) — that way the request reaches our
address directly and we see the network signals of the connection.
If the goal is only to see automation in the statistics, a project is not needed: the site script is enough. There is no need to install both scripts — the form protection tag collects everything the site script does.
Project consumption (tag events and scoring calls) is counted from day one and is visible in the project card.