Skip to content

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.

  1. Create a project — you receive a site key.
  2. Install the tag on your pages and list your forms in it.
  3. 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.

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.com and https://www.example.com. Events are accepted only from these addresses.

You will receive a site key and a ready-made installation snippet.

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:

  1. adds a hidden ws_sid field to each one — the session identifier your backend needs;
  2. asks us for a verdict on submission;
  3. 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.

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":
... # reject
elif decision == "challenge":
... # ask the visitor to pass the check (see "Visitor check") and score again
else:
... # accept the action

Response:

{
"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: deny means reject, challenge means check instead of reject.
  • An empty or unknown session_id is not an error — but it is not “clean” either. The response carries the no_session reason and a challenge verdict. 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 event namessignup, 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 sid covers opening the form, submitting it and retrying after a validation error.

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 hmac
import 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_identity above does this). Otherwise Ivan@example.com and ivan@example.com will be different people to us;
  • use the same secret for scoring and for reporting outcomes — otherwise we cannot match one to the other.

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 fraud add 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: legit for 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.

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.

VerdictMeaningCommon response
allowNo signs of abuse foundLet it through
challengeSigns are present but insufficient for refusal — or there are no observations at all (no_session)Show a challenge, email confirmation, delayed moderation
denyStrong signs confirmed by our own dataReject 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:

FieldMeaning
visitor_idthe visitor: survives page reloads and navigation across the site
device_id, device_id_hwthe device by the full fingerprint and by its hardware core — the latter survives a change of browser, private windows and address spoofing
ipthe visitor’s address
behaviorhow the interaction with the page looked: human, weak, auto, idle
tls_class, hdr_classwhether the connection and the headers look like a real browser
pages, visits, events, submits, age_secondsthe 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.

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 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.

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.

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).

  • 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 is challenge: 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.

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.

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.