Bot protection and captcha
Choosing and configuring a captcha provider — Cloudflare Turnstile, reCAPTCHA v3, hCaptcha or the built-in proof-of-work — plus the signup and login rate limits that do the work a captcha cannot.
Registration, login and password reset each sit behind a captcha. Which one is a setting: Admin → System → Platform Settings → Security → Protection.
It is one layer of five, and it is the weakest of them. The other four — requiring email verification, the per-address signup limit, correct client-IP resolution, and not running any unprotected route that also creates accounts — do more, cost your real customers nothing, and are covered below. Configure a captcha because it helps; do not configure only a captcha and consider the job done.
Choosing a provider
| Provider | Needs keys | Cost | What it actually does |
|---|---|---|---|
| Cloudflare Turnstile | Yes, free | Free, unlimited | Browser-integrity checks. Invisible to nearly every real visitor. The recommended choice. |
| Google reCAPTCHA v3 | Yes | Free to ~10k assessments/month | Scores each request 0.0–1.0. Never blocks by itself — you choose the threshold. |
| hCaptcha | Yes | Free tier + paid | Similar to Turnstile. Privacy-friendlier than Google; its free tier is the most heavily targeted of the three. |
| Proof of Work | No | Free | Built in. Makes the browser burn CPU before submitting. Needs no account and no third party. |
| None | — | — | No captcha on any of the three forms. |
Proof of Work is the shipped default, because it is the only option that works on an install where nobody has created any accounts yet. It is a genuine control and it is genuinely weak — see what proof-of-work can and cannot do before relying on it.
Cloudflare Turnstile
-
Create a widget — at dash.cloudflare.com → Turnstile → Add widget. You do not need to move your DNS to Cloudflare; a Turnstile widget works on any host.
-
List your domain — add the hostname your platform is served from. Add the
www.and apex spellings if you serve both. -
Choose the mode — Managed is right for almost everyone. It shows a challenge only to visitors Cloudflare cannot vouch for.
-
Copy both keys — Cloudflare gives you a Site Key (public) and a Secret Key (private).
-
Save them — in Settings → Security → Protection, set Captcha Provider to Cloudflare Turnstile and paste the two keys.
Google reCAPTCHA v3
-
Register a site — at google.com/recaptcha/admin. Choose reCAPTCHA v3, not v2. The platform sends a v3 assessment; a v2 key will be rejected by Google's own verify endpoint.
-
Add your domain, then copy the site key and secret key.
-
Save them with Captcha Provider set to Google reCAPTCHA v3.
-
Set the threshold — the reCAPTCHA Score Threshold slider appears once this provider is selected. Requests scoring below it are refused.
Google answers success: true for a bot and a human alike, and puts the verdict
in a score from 0.0 to 1.0. The threshold is therefore the entire control:
set it to 0 and reCAPTCHA blocks nothing at all.
0.5 is Google's own starting point and the shipped default. Raise it if bots are getting through, lower it if real customers are being refused. Change it one step at a time — the scale is not linear, and 0.9 will refuse a great many real people on mobile networks and VPNs.
hCaptcha
- Add a site at dashboard.hcaptcha.com.
- Copy the sitekey and the secret.
- Save them with Captcha Provider set to hCaptcha.
hCaptcha does not return a score on the free tier, so the threshold slider does not apply. It also does not echo an action label, so the platform cannot check that a token minted on your login form is not being spent on your registration form — a check it does perform for Turnstile and reCAPTCHA.
What the platform verifies
Every token is checked server-side against the provider before the form is accepted. Four things are checked, not one:
| Check | Why it matters |
|---|---|
| The provider says the token is valid | The obvious one. |
| The hostname the token was minted for is yours | A token solved on an attacker's own page, against their own site key, is a valid token. Without this check it is also a valid token here. |
| The action matches the form | Stops a token farmed from a low-value form being spent on registration. Turnstile and reCAPTCHA only. |
| The score clears the threshold | reCAPTCHA v3 only, and see the warning above. |
The hostname check uses APP_PUBLIC_URL (falling back to
NEXT_PUBLIC_SITE_URL). If neither is set the check is skipped rather than
failed — a missing environment variable must not take signup down — so set them.
See environment variables.
What happens when the provider cannot be reached
Cloudflare has outages. DNS blips. Secret keys get pasted into the wrong field. None of those mean "this visitor is a bot", and none of them mean "this visitor is human" — they mean no answer. The platform treats that case differently on different forms, deliberately:
| Form | Behaviour when the captcha cannot be evaluated |
|---|---|
| Registration | Refused, with "Registration is temporarily unavailable." |
| Login | Allowed through, and an error is written to the log. |
| Password reset | Allowed through, and an error is written to the log. |
You sign in through the same /api/auth/login as your customers. If a mistyped
secret key blocked login, it would lock you out of the admin panel that
holds the setting you need to fix — with no way back in.
Nobody is locked out of anything by being unable to create a new account, and an hour of refused signups is cheaper than an hour of unmetered ones. So registration fails closed and the other two fail open.
Every fail-open is logged at error level with the reason, so a captcha that has
quietly stopped running is a line in your log rather than something you infer
months later from a table full of junk accounts. Search the backend log for
CAPTCHA.
A wrong secret key produces a working-looking site: registration returns
"temporarily unavailable" and login carries on as normal. Grep for CAPTCHA
once after saving. rejected our SECRET KEY is the line that names the problem.
What proof-of-work can and cannot do
The built-in option asks the visitor's browser to find a number whose hash starts with a run of zero bits. It is a price, not a test of humanity — it cannot tell a browser from a script, it only bills CPU.
And the bill lands the wrong way round. A real customer pays through the
browser's crypto.subtle.digest, one call per attempt; an attacker pays native
code, roughly an order of magnitude cheaper per core and far more than that per
machine. Both sides scale identically with difficulty, so the ratio does not
change when you raise the difficulty — you slow your customers down and barely
inconvenience anybody else.
| Difficulty | Roughly, for a real visitor | For one attacker CPU core |
|---|---|---|
| Low | ~0.25 s | ~0.02 s |
| Medium (default) | ~2 s | ~0.16 s |
| High | ~15 s | ~1.3 s |
Leave it on Medium. High times out on a large share of phones, and the signup is abandoned before the request is even sent.
Keep proof-of-work if you cannot use a third-party service — a hard privacy requirement, an air-gapped install, an operator who will not send visitor traffic to a US cloud. Otherwise use Turnstile: it is free, it needs no more work than pasting two keys, and it is a different kind of control rather than a stronger dose of the same one.
The rate limits behind it
These run whether or not a captcha is configured, and they do more than any of the providers above.
| Route | Limit | On failure |
|---|---|---|
POST /api/auth/register |
3 per address per hour | Refused |
POST /api/auth/register/google |
shares the same budget | Refused |
POST /api/auth/login |
30 per address per 15 minutes | Refused |
| Whole platform, any write | RATE_LIMIT per RATE_LIMIT_EXPIRE seconds |
Refused |
The signup limit fails closed: if Redis is unavailable, registration is refused rather than waved through. A customer who cannot register retries in a minute and loses nothing; an account created during the outage cannot be un-created.
Three accounts an hour, not three attempts
The budget counts rows created. A visitor who solves the challenge and is then refused for any other reason — the address is already taken, the password fails the policy, the address belonged to a deleted account — gets their slot back, because nothing was created and there is nothing to clean up.
Failing the challenge itself does not refund. That is the outcome the budget exists to price; refunding it would leave registration with no ceiling beyond the platform-wide per-IP cap.
The Google route is both a signup and a sign-in door — one handler, an unknown address registers and a known one logs in — so a Google login refunds too. A returning customer signing in for the fourth time in an hour is not creating accounts and is no longer told that they are.
Three deliberate failures in a row used to shut the door for a full hour with no
way to clear it, which reads as "the site is broken" rather than "you are being
throttled". Failed attempts no longer count, but three successful signups from
your address still will. pnpm --filter backend redis-cli DEL signup:ip:<your-ip>
clears it, or wait out the hour.
Behind nginx or Apache the address the backend sees on the socket is the proxy's. It recovers the visitor's from the forwarding header, which it honours automatically for a proxy on this machine — but only if the proxy actually sends one. If it does not, every visitor on earth shares one bucket and the platform refuses everybody as soon as any three of them sign up.
- Apache:
a2enmod headers, thenRequestHeader unset X-Forwarded-Forin the vhost. See Apache. - nginx:
proxy_set_header X-Forwarded-For $remote_addr;— not$proxy_add_x_forwarded_for, which appends to whatever the client sent. - Proxy on another host: list its network in
TRUST_PROXY_CIDRS.
The backend detects the failure rather than assuming it: when nearly every
request in a window resolves to the same address, it logs one RATE_LIMIT
warning naming that address and what to do about it. Grep for it before
investigating anything else.
The hop list is read from the right, so a caller who prepends their own
X-Forwarded-For cannot choose their bucket even on an appending proxy.
Also worth turning on
- Email verification (
Verify Emailin Settings → Security). Without it an account is usable the moment it is created, so a throwaway address nobody can receive mail at still produces a working account. This is the single most effective control on this page. - KYC, for anything that touches money.
you+anything@gmail.com all deliver to you@gmail.com, and the platform stores
them as separate accounts. Email verification therefore costs a determined
person one mailbox rather than one per account. It is still worth having — it
raises the price from nothing to something — but it is not a per-person limit.
Troubleshooting
The captcha could not be evaluated. Check the backend log for CAPTCHA; the
usual causes are a wrong or empty secret key, or the provider being unreachable
from your server. Setting Captcha Provider to Proof of Work restores
signups immediately while you sort the keys out.
Check the browser console for a blocked script. The three hosted providers load
from challenges.cloudflare.com, www.google.com and js.hcaptcha.com
respectively; a content-security-policy or an ad blocker in front of your site
will stop them. Then confirm Site Key is set — the widget cannot render
without one, and the platform will refuse the submission rather than accept it
unchecked.
Most likely the hostname check. The provider reports which site a token was
minted for, and it is compared against APP_PUBLIC_URL. If that variable still
holds an old domain — or a domain with a trailing path — no token will ever
match. See environment variables.
Released clients that only understand proof-of-work cannot satisfy a hosted
provider. The legacy powSolution field is still accepted, so such a client
keeps working while the provider is Proof of Work — and stops the moment you
switch. Ship an updated client first, or leave that install on proof-of-work.
GET /api/auth/pow/challenge?action=register reports the armed provider and its
public site key. "provider":"none" means no captcha is running on any form.
Related
- Settings reference — the Protection group in context
- Settings key reference — the raw key names
- nginx configuration — client-IP resolution
- Geo restrictions — country-level blocking