Skip to content

Shield360 Bot Check: a free, private CAPTCHA alternative

Shield360 Bot Check protects a form the way a CAPTCHA does, without a puzzle. The widget makes the visitor’s browser do a small piece of work, about a second, and your server checks the result. It is free on every plan, and it runs entirely on your own Shield360 deployment, so a self-hosted install needs nothing from a third party.

Your server gets a verdict with each check:

VerdictMeaning
verified_agentThe caller presented a valid agent passport for this host, and your environment’s inbound policy accepts it. agent holds the agent’s name.
likely_botSomething counts against the visitor. See What decides a verdict.
signed_inA Shield360 session came with the check. This needs a page on your Shield360 deployment’s own site; other sites do not receive this verdict.
humanThe widget did the work and nothing counts against the visitor.
unknownThe work was done without the widget, or the check failed.

A verdict describes a visitor; it never identifies one. Bot Check does not work out who an anonymous person is. human means “nothing here looks automated”, not proof. A determined script can solve the work, so treat a verdict as one input to your own decision.

  • A Shield360 account with permission to manage API keys
  • A site served over HTTPS, or localhost for testing. Browsers offer the Web Crypto API the widget needs only on secure pages.
  1. Go to Settings → Bot Check (/settings/bot-check).
  2. Select Add a site.
  3. Name the site and list its host names, one per line. *.example.com covers subdomains of example.com, but not example.com itself. A challenge for any other host is refused.
  4. Copy the secret. It is shown once. If you lose it, rotate it.

Choose a mode for each site:

  • Enforce (default): a likely_bot verdict fails, with success: false and the error bot-detected.
  • Observe: a likely bot still passes with success: true, and your server decides what to do with the verdict. Use it to see what would be stopped before you enforce.
<form action="/signup" method="post">
<!-- your fields -->
<div class="s360-bot-check" data-sitekey="<your site key>"></div>
<button type="submit">Sign up</button>
</form>
<script src="https://<your Shield360 URL>/bot-check/widget.js" async defer></script>

The widget starts by itself, works with a keyboard and screen readers, follows the visitor’s light or dark setting, and adds a hidden field named s360-bot-check-response to the form. If the visitor submits before it finishes, it holds the form and sends it when it is done.

AttributeEffect
data-sitekeyYour site key. Required.
data-themelight or dark. Follows the visitor’s setting when left out.
data-hostnameThe host to claim on a sandboxed page whose address has no host.
data-callbackThe name of a global function to call with the response.
data-text-verifying, data-text-verified, data-text-error, data-text-retryYour own wording, for other languages.

If your site sends a Content-Security-Policy, allow your Shield360 URL in script-src and connect-src.

For forms added after page load, run the script again or call s360BotCheck.render(element); elements already rendered are left alone. The field is cleared when its token is about to expire. Call s360BotCheck.reset(element) to get a fresh token after sending a form by script, because each token works once. A token lasts five minutes, and the widget replaces it before then.

Send the field to siteverify from your server, with your secret. JSON, form-encoded and multipart bodies are accepted.

const res = await fetch("https://<your Shield360 URL>/api/bot-check/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.SHIELD360_BOT_CHECK_SECRET,
response: req.body["s360-bot-check-response"],
remoteip: req.ip, // optional
}),
});
const result = await res.json();
if (!result.success) return reject();
{
"success": true,
"error-codes": [],
"challenge_ts": "2026-11-07T15:04:05.000Z",
"hostname": "www.example.com",
"verdict": "human",
"agent": null
}

Treat anything other than success: true as a failure, including a network error or a status other than 200. siteverify needs no cookie or CSRF token, so any server that can reach your Shield360 URL can call it. Check hostname too if one key serves several sites, keeping in mind what it guarantees.

Error codeMeaning
missing-input-secret, invalid-input-secretThe secret is absent or wrong.
missing-input-response, invalid-input-responseThe token is absent, malformed, forged, or for another site.
timeout-or-duplicateThe token has expired or was already used.
hostname-not-allowedThe token’s host is no longer on the site’s list.
bot-detectedEnforce mode only: the verdict is likely_bot.
bad-requestThe body could not be read (status 400).
internal-errorShield360 could not check the token (status 500), or you sent too many calls (status 429).

The swap is three changes: the script, the element and the two URLs.

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<div class="cf-turnstile" data-sitekey="<Cloudflare site key>"></div>
<script src="https://<your Shield360 URL>/bot-check/widget.js" async defer></script>
<div class="s360-bot-check" data-sitekey="<Shield360 site key>"></div>
const token = form.get("cf-turnstile-response");
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
const token = form.get("s360-bot-check-response");
const res = await fetch("https://<your Shield360 URL>/api/bot-check/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret, response: token, remoteip }),
});
const { success } = await res.json();

Your existing success check keeps working. verdict and agent are additions you can use when you are ready. Turnstile’s action, cdata and idempotency_key are not supported.

A page in a sandboxed frame has no origin of its own, so its browser sends Origin: null. The widget works there: it uses no cookies, storage, frames, external fonts or popups, and its footer is plain text.

Such a page cannot prove where it runs, so Shield360 refuses it unless you turn on Allow sandboxed pages for the site. The widget then claims its own host (location.hostname, or data-hostname), and that host must be on the site’s list. The check is recorded with the signal unproven_host.

Leave this off unless you need it. Base316’s hosted sign-up page does need it, with Base316’s hosted host name on the list.

The page isThe host comes fromWhat that gives you
An ordinary pageThe browser’s OriginAnother site’s page cannot use your key in its visitors’ browsers.
A sandboxed page, with Allow sandboxed pages onThe page’s own claimVery little. Any page can make the same claim, so another site can use your key in its visitors’ browsers.
A script or serverWhatever it sendsNothing. A script can name any host on your list.

So hostname in a siteverify answer is what a browser reported or a page claimed, not proof of where the check ran. Against a script, what counts is the work, the rate signals and each token working once.

An agent with a Shield360 agent passport proves what it is rather than pretending to be a person. Issue the passport for your site’s host name, then send it when asking for a challenge:

Terminal window
curl -H "Origin: https://www.example.com" \
-H "Shield360-Agent: <passport>" \
"https://<your Shield360 URL>/api/bot-check/challenge?sitekey=<site key>"

The agent still does the work. If your server receives the passport itself, pass it to siteverify as agent_passport instead. Either way, the operators and minimum assurance in your inbound agents policy apply, and each passport is accepted once per site.

Each check records the codes of the signals behind it. You can see them under Recent checks.

SignalSource
automation_user_agentNo User-Agent, or one naming an HTTP library or headless browser.
denied_addressThe address is on your environment’s Edge deny list.
high_rateOver 30 challenges in a minute from one address to one site.
repeated_failuresFive or more failed checks from one visitor in 10 minutes.
invalid_passportA passport that failed verification.
agent_not_allowedA valid passport your inbound policy does not accept.
client_automationThe widget saw signs of an automated browser. A client can fake this, so it can count against a visitor but its absence proves nothing.
address_changedThe address at siteverify differs from the one at the challenge. Recorded only; phones do this.
unproven_hostThe page could not prove its host. Recorded only.

likely_bot needs at least one of the signals not marked as recorded only. Shield360 does not hold a list of data-center addresses, so a request from a cloud provider is not marked for that reason alone.

Bot Check sets no cookies, and it keeps the minimum:

  • A visitor’s address is used during the check and stored only as a keyed hash, different for each site.
  • Each check stores its outcome, verdict, signal codes, host, and any verified agent’s name.
  • Checks are deleted after 30 days.

Even so, a site’s visitors are being checked, and a keyed hash of an address can count as personal data. Sites serving people in the EU may need to mention this check in their privacy notice. Ask your own adviser what yours should say.

Environment variablePurpose
SHIELD360_BOT_CHECK_SECRETSigns challenges. Defaults to a key derived from SHIELD360_VAULT_ENCRYPTION_KEY or NEXTAUTH_SECRET.
SHIELD360_BOT_CHECK_POW_COSTPBKDF2 rounds per attempt (default 10000). Lower it if your visitors’ devices are slow.
SHIELD360_TRUSTED_PROXY_HOPSProxies in front of Shield360 (default 1). Without a believable address, the per-address signals do not apply.