Skip to content

Add a captcha to any form

Qlyne Verify stops bots on your forms without a puzzle and without the connector. The browser solves a short proof-of-work (about a second on a phone), and the form carries a token your server checks with Qlyne before accepting it. It works on any host: Vercel, Netlify, your own server.

Your site ID is in the site menu of the dashboard, and so are these two lines, already filled in (Bots, “Captcha for any form”):

<script src="https://wait.qlyne.com/src/verify.js" async></script>
<form method="post" action="/contact">
<!-- your fields -->
<div class="qlyne-verify" data-site="your_site_id" data-core-url="https://api.qlyne.com"></div>
<button>Send</button>
</form>

The box shows “Checking your browser…” and then “Browser checked”. The token goes into a hidden field named qlyne-token, inside the form.

Options on the div:

AttributeWhat it does
data-themelight or dark; by default it follows the visitor’s system
data-fieldanother name for the hidden field
data-callbackname of a global function that gets the token

The page also gets a qlyne:verified event with the token in event.detail.token.

Under Bots, “Challenge”, “Click to confirm” can make the box ask for a click on “I’m a person”: always, or only when the visitor looks like a bot (the widget sends the same counts as the bot score, never keys or text). The token comes after the click.

Add your site’s address to Site settings, “Allowed origins”, so only your pages can ask for challenges. Empty allows any site.

The widget reports what it sees in the browser (automation flags, pointer movement, a click when the box is shown), and Qlyne also looks at what the browser cannot fake:

  • The TLS connection. A visitor claiming a recent Chrome, Firefox or Safari that connects without TLS 1.3 or HTTP/2, as scripts do, gets no token. A company proxy that opens HTTPS to inspect it can change the fingerprint; those are rare on small sites, and the visitor sees “Could not check the browser”.
  • The IP’s reputation. An address that other Qlyne sites reported attacking in the last 24 hours always gets the box to click and a harder proof-of-work, even when the site uses the invisible mode. Turn Reputation off under Firewall to skip this.
  • Pace. An IP asking for many challenges in a few minutes gets a harder proof-of-work.

Before accepting the form, send the token to Qlyne with the captcha secret, shown under Bots › Captcha for any form in the dashboard. It is a secret of its own: it only checks tokens for this site, and the account API key does not work here, so a secret that leaks from a plugin or a third-party form exposes the captcha, not the account. Keep it on the server; New secret replaces it within a minute.

Terminal window
curl -X POST "https://api.qlyne.com/verify/check?tenant_id=your_site_id" \
-H "Authorization: Bearer $QLYNE_CAPTCHA_SECRET" -H "Content-Type: application/json" \
-d '{"token": "value of qlyne-token", "remoteip": "visitor IP"}'

Answers:

AnswerMeaning
{"success": true, "solved_at": 1791300000, "origin": "https://shop.example"}Accept the form
{"success": false, "error": "invalid_or_expired"}Missing, wrong, already used or older than 5 minutes
{"success": false, "error": "ip_mismatch"}Solved from another IP than the one in remoteip
HTTP 401Wrong secret

remoteip is optional. Send it when your server knows the visitor’s real IP; behind a proxy, use the address the proxy forwards.

app/api/contact/route.js
export async function POST(request) {
const form = await request.formData();
const res = await fetch(`${process.env.QLYNE_CORE_URL}/verify/check?tenant_id=${process.env.QLYNE_SITE}`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.QLYNE_CAPTCHA_SECRET}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ token: form.get('qlyne-token') }),
});
const check = await res.json();
if (!check.success) return Response.json({ error: 'Please try again.' }, { status: 400 });
// ...handle the form
return Response.json({ ok: true });
}
$check = Http::withToken(env('QLYNE_CAPTCHA_SECRET'))
->post(env('QLYNE_CORE_URL').'/verify/check?tenant_id='.env('QLYNE_SITE'), [
'token' => $request->input('qlyne-token'),
'remoteip' => $request->ip(),
])->json();
abort_unless($check['success'] ?? false, 422, 'Please try again.');
import os, requests
check = requests.post(
f"{os.environ['QLYNE_CORE_URL']}/verify/check",
params={"tenant_id": os.environ["QLYNE_SITE"]},
headers={"Authorization": f"Bearer {os.environ['QLYNE_CAPTCHA_SECRET']}"},
json={"token": form["qlyne-token"]},
timeout=5,
).json()
if not check.get("success"):
raise ValueError("captcha")

A token works once. If your page sends the form with fetch and stays open, ask for a new token after each send:

QlyneVerify.reset(document.querySelector('.qlyne-verify'));

The widget also renews the token by itself before it expires, so a form left open for a while still works.

If your site sends a Content-Security-Policy header, allow the script and the calls to Qlyne:

script-src 'self' https://wait.qlyne.com; connect-src 'self' https://api.qlyne.com

The widget needs nothing else: its styles don’t count as inline styles, and if the policy doesn’t allow a worker from blob:, it solves the challenge on the page itself. Adding worker-src blob: lets it solve in the background, which keeps a slow phone responsive.

Under Bots in the dashboard: captchas solved, automated browsers refused (the widget notices browsers driven by Selenium, Puppeteer or Playwright) and tokens your server rejected. No personal data is kept: the token holds the time, the origin and the visitor’s IP (the /64 for IPv6) for 5 minutes, only to compare with remoteip.