TL;DR
reCAPTCHA v3 nie pokazuje checkboxa „Nie jestem robotem” — zwraca score 0.0–1.0 i krótkotrwały token, który musisz zweryfikować po stronie serwera (siteverify), zanim zapiszesz lead w bazie lub wyślesz mail. Na produkcyjnej stronie firmowej Next.js 15 skrypt ładuje się lazyOnload w layout.tsx (żeby nie psuć LCP), a formularz kontaktowy czeka na grecaptcha.ready z timeoutem 12 s, bo inaczej token bywa pusty i API zwraca 400. Próg 0.5 to rozsądny start; obok tego warto mieć rate limit po IP (w tym projekcie 5 zgłoszeń / minutę) i rozważyć honeypot oraz ten sam wzorzec dla chatbota. Poniżej: pełny przepływ, RODO, fałszywe odrzucenia i testy.
Dla kogo to jest
- Firm usługowych z formularzem kontaktowym lub chatbotem generującym leady
- Developerów na Next.js / React, którzy chcą ochrony bez irytującego captcha v2
- Osób odpowiedzialnych za RODO / DSGVO — disclosure przy formularzu i transfer danych do Google
- Zespołów widzących spam w skrzynce mimo „jakiegoś captcha” w motywie WordPress — i szukających wzorca server-side
Fraza (SEO)
recaptcha v3 formularz kontaktowy, ochrona formularza przed spamem, recaptcha nextjs api route, recaptcha score próg, weryfikacja recaptcha serwer, lazyOnload recaptcha layout, rodo recaptcha google
Dlaczego v3, a nie checkbox v2
Google reCAPTCHA ewoluowało od widocznych łamigłówek do niewidocznej oceny ryzyka. W wersji v3 użytkownik zwykle nie klika niczego — skrypt analizuje sygnały (historia interakcji z domeną, wzorzec ruchu, fingerprint) i zwraca score. To lepsze UX na stronie B2B, gdzie kafr „wybierz wszystkie sygnalizatory” obniża konwersję.
| Aspekt | reCAPTCHA v2 (checkbox) | reCAPTCHA v3 |
|---|---|---|
| UX | Widoczna łamigłówka | Niewidoczne; badge w rogu |
| Wynik | pass / fail | score 0.0–1.0 |
| Gdzie decyzja | głównie u Google w UI | Ty na serwerze (próg) |
| Wydajność | Cięższy iframe | Lżejszy, ale nadal zewnętrzny skrypt |
v3 nie zastępuje walidacji pól, rate limitu ani sanity-checków — to jedna warstwa w obronie głębokiej (defence in depth).
Architektura na stronie Next.js 15 (wzorzec produkcyjny)
W projekcie software house (strona wielojęzyczna /pl, /en, /de) ochrona opiera się na trzech miejscach: globalny skrypt w layoucie, wykonanie execute() przy submit formularza, weryfikacja w /api/submissions.
Skrypt w layout.tsx — lazyOnload
Tag reCAPTCHA nie ładuje się afterInteractive jak GA4 — celowo lazyOnload, żeby nie konkurować o pierwszy render z tagami analitycznymi i nie obciążać LCP. Klucz witryny (site key) jest publiczny i trafia do URL:
<Script
src="https://www.google.com/recaptcha/api.js?render=6Lcwsx0sAAAAAO4sbP31qTdgjuoGLgMmp9HyxhYB"
strategy="lazyOnload"
/>Komentarz w kodzie mówi wprost: skrypt jest lazy — formularz musi poczekać na ready, inaczej token nie powstanie.
Badge i informacja prawna — dwa poziomy
Google wymaga widoczności badge reCAPTCHA. W layoucie CSS obcina badge do małej ikony w lewym dolnym rogu (overflow hidden), a pełne zdanie prawne stoi przy formularzu kontaktowym w page.tsx — z linkami do Polityki prywatności i Warunków Google (tłumaczenia w messages/pl.json, en.json, de.json):
{translations.contact.form.recaptchaBeforePrivacy}
<a href="https://policies.google.com/privacy">...</a>
{translations.contact.form.recaptchaBetween}
<a href="https://policies.google.com/terms">...</a>
{translations.contact.form.recaptchaAfter}To ważne pod RODO: użytkownik wie, że dane mogą trafić do Google LLC w ramach antyspamowej analizy — nie wystarczy sam ukryty badge.
Po stronie klienta — token przy wysyłce formularza
W handleSubmit formularza kontaktowego nie wywołuj execute() na onClick bez oczekiwania — przy lazyOnload window.grecaptcha bywa undefined przez pierwsze sekundy.
Wzorzec: czekaj na ready, potem execute
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('recaptcha_timeout')), 12000);
const done = () => { clearTimeout(t); resolve(); };
if (window.grecaptcha?.ready) {
window.grecaptcha.ready(done);
} else {
const iv = setInterval(() => {
if (window.grecaptcha?.ready) {
clearInterval(iv);
window.grecaptcha.ready(done);
}
}, 100);
setTimeout(() => {
clearInterval(iv);
if (!window.grecaptcha?.ready) reject(new Error('recaptcha_no_script'));
}, 12000);
}
});
const recaptchaToken = await window.grecaptcha.execute(
'6Lcwsx0sAAAAAO4sbP31qTdgjuoGLgMmp9HyxhYB',
{ action: 'submit_contact_form' }
);Action (submit_contact_form) pojawia się w panelu reCAPTCHA i w logach — osobne akcje dla chatbota (submit_chatbot_form) ułatwiają analizę score per kanał.
Token wysyłasz w body POST razem z polami leada:
const response = await fetch('/api/submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, locale, recaptchaToken }),
});Jeśli execute się nie uda, użytkownik dostaje czytelny komunikat („Poczekaj chwilę po załadowaniu strony…”) — nie wysyłasz pustego tokena na siłę.
Typy TypeScript dla grecaptcha trzymamy w src/types/gtag.d.ts obok gtag, żeby uniknąć błędów kompilacji.
Po stronie serwera — weryfikacja w API route
Secret key nigdy nie trafia do przeglądarki. W Vercel / .env ustawiasz RECAPTCHA_SECRET_KEY i w POST /api/submissions wołasz:
const recaptchaResponse = await fetch(
'https://www.google.com/recaptcha/api/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `secret=${recaptchaSecret}&response=${recaptchaToken}`,
}
);
const recaptchaData = await recaptchaResponse.json();Kolejność decyzji w produkcji:
- Brak tokena → HTTP 400 (boty często omijają JS).
success: false(np.timeout-or-duplicate, zły secret) → 400 + logerror-codes.score < 0.5→ 400 (bot / podejrzany ruch).- Dopiero potem walidacja pól i zapis do Prisma + maile transakcyjne.
if (recaptchaData.score !== undefined && recaptchaData.score < 0.5) {
return NextResponse.json(
{ error: 'Weryfikacja nie powiodła się. Odśwież stronę i spróbuj ponownie.' },
{ status: 400 }
);
}Token jest jednorazowy i krótko żyje — nie cache’uj go między requestami. Przy błędzie sieci do Google blokujesz zgłoszenie (fail closed), bo otwarcie endpointu bez weryfikacji = zaproszenie dla spammerów.
Próg score — jak dobrać 0.5, 0.7 czy „miękki”
Google sugeruje traktować score jako sygnał, nie absolut. W praktyce:
| Próg | Efekt | Kiedy |
|---|---|---|
| 0.3 | Mało fałszywych odrzuceń, więcej spamu | Bardzo niski ruch, testy A/B |
| 0.5 | Balans (domyślny w wielu tutorialach) | Strony firmowe, lead B2B |
| 0.7 | Agresywniejszy filtr | Formularze z bonusem (e-book, kupon) |
W opisywanym projekcie start to 0.5. Po tygodniu warto zajrzeć w Admin Console reCAPTCHA → Analytics i zobaczyć rozkład score dla action submit_contact_form. Jeśli 5% legitnych leadów ma score 0.45–0.49, rozważ 0.45 albo dodatkową warstwę (rate limit) zamiast twardego 0.7.
Miękki próg: score 0.3–0.5 → zapis do kolejki „do ręcznej weryfikacji” zamiast twardego odrzucenia — przydatne przy kampaniach Ads z ruchem z nowych urządzeń (VPN, Safari ITP).
Rate limit — pierwsza linia obrony (już w projekcie)
Zanim w ogóle dojdzie do Google, endpoint /api/submissions woła checkEmailRateLimit(ip) z src/lib/email-rate-limit.ts:
- 5 żądań na IP na 60 sekund
- przy przekroczeniu: HTTP 429 + nagłówek
Retry-After
To chroni przed prostymi skryptami i floodem, który wyczerpałby limit maili transakcyjnych. Ten sam helper jest na innych endpointach mailowych (chatbot, umowy, testimoniale).
Rate limit nie zastępuje reCAPTCHA — bot z wielu IP nadal przejdzie. Razem dają sensowny koszt ataku.
Honeypot — kiedy dodać trzecią warstwę
W tym repozytorium nie ma jeszcze pola honeypot — i to OK przy v3 + rate limit na umiarkowanym ruchu. Honeypot warto dodać, gdy:
- widzisz powtarzalny spam z poprawnym score (farmy botów),
- masz prosty formularz HTML łatwy do scrapowania,
- chcesz zero kosztu (ukryte pole
website,company_url— CSSposition:absolute; left:-9999px,tabIndex={-1},autoComplete="off").
Serwer odrzuca request, gdy honeypot niepusty — bez komunikatu „jesteś botem” (żeby nie uczyć atakującego). Kolejność: rate limit → honeypot → reCAPTCHA → walidacja biznesowa.
Chatbot — ten sam wzorzec, osobny endpoint
Formularz kontaktowy korzysta z /api/submissions i pełnej weryfikacji reCAPTCHA. Endpoint chatbota /api/chatbot/submit w chwili pisania ma rate limit, ale nie weryfikuje tokena — typowa luka, gdy spammer odkryje JSON API.
Rekomendowany plan (zgodny z resztą architektury):
- Wyodrębnij
verifyRecaptchaToken(token, minScore)dosrc/lib/recaptcha.ts. - W komponencie chatbota — ten sam blok
ready+executez actionsubmit_chatbot_form. - W
POST /api/chatbot/submit— identyczna ścieżka weryfikacji co w submissions. - Disclosure reCAPTCHA przy finalnym kroku chatbota (skrócona wersja tekstu z formularza).
Dzięki temu jeden klucz witryny, jeden secret, dwie akcje w panelu Google — łatwiejsze debugowanie niż dwa osobne projekty reCAPTCHA.
RODO, cookies i polityka prywatności
reCAPTCHA v3 przetwarza dane użytkownika u Google (USA — mechanizmy transferu: SCC, DPF w zależności od aktualnego stanu prawnego). Minimalny pakiet compliance:
- Informacja przy formularzu (linki Google Privacy + Terms) — już w UI.
- W polityce prywatności strony: cel (antyspam), kategorie danych (IP, cookies Google, sygnały zachowania), podstawa prawna (uzasadniony interes art. 6 ust. 1 lit. f lub zgoda — skonsultuj z prawnikiem przy marketingowych cookies).
- Jeśli baner cookies blokuje skrypty Google do zgody — reCAPTCHA może nie działać; wtedy albo kategoria „niezbędne” dla antyspam, albo ładowanie po zgodzie z jasnym komunikatem przy formularzu.
- Retention: logi score w Twoim API trzymaj krótko; nie zapisuj tokena reCAPTCHA w bazie leadów na stałe.
Nie udawaj, że v3 jest „bezcookies” — badge i dokumentacja Google mówią inaczej.
Fałszywe odrzucenia (false positives) — co robić
Użytkownicy z VPN, Tor, świeżymi profilami przeglądarki, adblockiem blokującym google.com/recaptcha, albo korporacyjnym proxy czasem dostają niski score mimo legitnej intencji.
Objawy w logach:
recaptcha_no_script/recaptcha_timeoutpo stronie klienta,score too lowz wartością 0.1–0.4 przy sensownym opisie projektu,- skargi „formularz nie działa” tylko na Firefox z ETP.
Remediacja:
- Komunikat zachęcający do odświeżenia / maila bezpośredniego (
kontakt@...) — już lepsze niż generic 400. - Obniżenie progu tymczasowo + monitoring.
- Fallback: drugi kanał (telefon, Calendly) widoczny obok formularza.
- Nie przechodź na sam honeypot „bo captcha zła” — stracisz ochronę przy targetowanym spamie.
Testuj z produkcyjnej domeny — localhost w panelu reCAPTCHA musi być na liście domen testowych, inaczej success: false.
Testowanie — checklist 15 minut
Panel Google reCAPTCHA
- Typ kluczy: v3, domeny: produkcja + opcjonalnie localhost.
- Skopiuj site key (public) i secret →
RECAPTCHA_SECRET_KEYna Vercel. - Po deployu: zakładka Overview — czy rosną requesty dla action
submit_contact_form.
DevTools
- Network → submit formularza → payload JSON z
recaptchaToken(długi string). - Response 200 tylko gdy score OK.
- Celowo wyślij bez tokena (curl) → 400.
Symulacja niskiego score
Google nie daje przełącznika „bądź botem” w v3; do testów użyj test keys z dokumentacji Google albo tymczasowo obniż próg na stagingu.
Checklist przed kampanią Ads
- Secret ustawiony na produkcji (nie tylko lokalnie)
-
lazyOnload+ timeout 12 s naready - Disclosure przy formularzu we wszystkich locale
- Rate limit aktywny
- Chatbot (jeśli publiczny) — ten sam verify co formularz
- Logi serwera bez wycieku secret key
Wydajność vs. bezpieczeństwo
lazyOnload dla reCAPTCHA to świadomy kompromis jak przy tagach Google Ads: LCP ważniejszy niż natychmiastowa gotowość captcha. Formularze B2B wypełnia się zwykle po 30+ sekundach — wtedy skrypt jest już załadowany. Wyjątek: landing z formularzem „above the fold” i bardzo krótkim czasem decyzji — rozważ afterInteractive tylko dla reCAPTCHA albo preload na stronie kontaktowej.
Nie ładuj reCAPTCHA na każdej podstronie bloga, jeśli masz tylko jeden formularz na home — w tym projekcie skrypt jest globalny (prostsze), ale przy optymalizacji Performance można warunkowo <Script> tylko tam, gdzie jest <form>.
Typowe błędy wdrożenia (i jak ich uniknąć)
- Secret w
.env.local, brak na Vercel — lokalnie działa, produkcja zwraca 500 „Konfiguracja reCAPTCHA nie jest kompletna”. Po każdym nowym środowisku sprawdźRECAPTCHA_SECRET_KEYw dashboardzie hostingu. - Weryfikacja tylko gdy token istnieje — w dobrym wzorcu brak tokena = blokada, nie „przepuść, bo może adblock”. W
/api/submissionspustyrecaptchaTokenkończy się 400. - Ten sam token dwa razy — użytkownik klika „Wyślij” podwójnie; drugi request dostaje
timeout-or-duplicate. UI powinno disable’ować przycisk po pierwszym kliknięciu (isSubmittingw React). - Brak action w
execute— trudniejsza analiza w panelu Google; zawsze podawaj sensowną nazwę akcji per formularz. - Logowanie pełnego tokena w produkcji — token to dane jednorazowe, ale i tak nie wrzucaj go do publicznych logów ani Sentry breadcrumbs.
Wielojęzyczność (/pl, /en, /de)
Skrypt reCAPTCHA w wspólnym layout.tsx obejmuje wszystkie locale — nie duplikuj kluczy per język. Tekst disclosure bierze się z plików tłumaczeń (messages/*.json), więc każda wersja językowa ma poprawne linki prawne bez hardcodu w komponencie. Komunikaty błędów API (400/429) warto w przyszłości też zlokalizować po locale z body — dziś część jest po polsku niezależnie od /en, co warto poprawić przy refaktorze i18n backendu.
Integracja z e-mailem i CRM
Po udanej weryfikacji reCAPTCHA endpoint /api/submissions zapisuje lead w Prisma i wysyła maile transakcyjne (admin + potwierdzenie dla klienta) przez sendTransactionalEmail. Spam, który przejdzie reCAPTCHA, i tak trafi do skrzynki — stąd rate limit i opcjonalny honeypot przed kosztownym wysyłaniem maili. Score nie musi lądować w CRM; wystarczy log serwerowy z IP i timestampem do późniejszej kalibracji progu. Przy dużym napływie śmieci: podnieś próg score albo filtruj podejrzane domeny na poziomie API — po reCAPTCHA, nie zamiast niego.
Porównanie z innymi metodami antyspamowymi
| Metoda | Mocna strona | Słabość |
|---|---|---|
| reCAPTCHA v3 | Analiza zachowania, niewidoczne | Zależność od Google, wymóg RODO |
| Honeypot | Proste, zero kosztu | Łatwe do obejścia przez celowane boty |
| Rate limit | Zatrzymuje flood | Nie chroni przy wielu IP |
| Double opt-in e-mail | Jakość adresów | Nie blokuje bota przed submit |
| Akismet / filtry ML | Komentarze, blogi | Mniej standard w formularzach B2B |
Produkcyjny układ w tym projekcie: rate limit + reCAPTCHA v3 + (opcjonalnie) honeypot — trzy warstwy na różne wektory ataku.
FAQ
Czy site key może być w repozytorium?
Tak — site key jest publiczny (widać go w HTML). Secret key nigdy — tylko zmienne środowiskowe serwera.
Czy wystarczy sama weryfikacja po stronie klienta?
Nie. Token można skopiować z DevTools lub wysłać stary. Decyzja musi paść na serwerze po siteverify.
Dlaczego token jest pusty mimo skryptu w layout?
Najczęściej execute przed grecaptcha.ready albo skrypt zablokowany przez adblock. Rozwiązanie: wzorzec z pollingiem i timeoutem jak w page.tsx.
Czy 0.5 to oficjalna rekomendacja Google?
Google podaje interpretację score, nie jeden próg. 0.5 to praktyczny default; kalibruj na swoim ruchu.
reCAPTCHA v3 vs Cloudflare Turnstile?
Turnstile bywa lżejszy i prostszy UX; reCAPTCHA v3 integruje się z ekosystemem Google i wieloma panelami hostingu. Wybór zależy od polityki prywatności i kosztu utrzymania — ważniejsze niż marka jest server-side verify + rate limit.
Czy muszę pokazywać badge?
Tak — regulamin Google. Można stylizować pozycję (jak w projekcie: mała ikona + pełny tekst przy formularzu).
Podsumowanie
Skuteczna ochrona formularza to nie wklejenie skryptu z tutoriala, tylko spójny pipeline: lazyOnload w layout, cierpliwe execute po ready, obowiązkowy siteverify z progiem score, rate limit na API, disclosure RODO przy polu submit, i ten sam schemat dla chatbota. Próg 0.5 to punkt startowy — kalibruj na logach. Przy false positives obniż próg lub dodaj honeypot, zamiast wyłączać ochronę całkowicie.
Chcesz zabezpieczyć formularze u siebie?
- Skontaktuj się — wdrożymy reCAPTCHA v3, rate limit i audyt antyspamowy pod Twoją stronę
- Strony WWW — Next.js, formularze leadowe i RODO w jednym pakiecie
- Zobacz realizacje — produkcyjne strony z niewidoczną ochroną przed botami