Honeypot w formularzu: jak zatrzymać spam bez CAPTCHA
Jedno ukryte pole potrafi odsiać sporą część automatycznego spamu. Pokazuję, jak zrobić to poprawnie, gdzie są pułapki i kiedy honeypot nie wystarczy.

W przykładach formularzy kontaktowych od dawna przewija się pole o nazwie botcheck. Czasem jest checkboxem, czasem zwykłym polem tekstowym. Użytkownik go nie widzi, nie wypełnia i właściwie nie powinien wiedzieć, że ono istnieje.
A jednak to niepozorne pole ma zatrzymywać spam.
Gdy zobaczyłem ten mechanizm po raz pierwszy, wydał mi się podejrzanie prosty. Bot dostaje dodatkowy input, wpisuje do niego jakąś wartość i sam się zdradza. Kilka linijek HTML, jedno sprawdzenie na serwerze, koniec problemu.
Oczywiście problem się na tym nie kończy.
Proste boty rzeczywiście wpadają w taką pułapkę. Lepsze zauważą CSS, przeczytają nazwę pola albo od razu wyślą POST do endpointu, pomijając formularz. Przeglądarka może z kolei uzupełnić pole automatycznie, a źle przygotowany honeypot potrafi zmylić osobę korzystającą z czytnika ekranu.
Po kilku dniach czytania dokumentacji i porównywania różnych implementacji doszedłem do dość przyziemnego wniosku. Honeypot jest dobrym zabezpieczeniem, jeżeli pozwolimy mu pozostać małym zabezpieczeniem. Działa świetnie jako tani pierwszy filtr. Kłopoty zaczynają się wtedy, gdy traktujemy go jak dowód, że po drugiej stronie siedzi człowiek.
`Botcheck` nie jest żadnym standardem
Zacznijmy od nazwy, bo bywa myląca.
HTML nie zna specjalnego pola botcheck. Przeglądarka nie wykonuje dla niego żadnej dodatkowej logiki. Jest to zwyczajna nazwa uzgodniona między formularzem a usługą, która odbiera zgłoszenie.
Web3Forms używa ukrytego checkboxa o nazwie botcheck.[1]
<input type="checkbox" name="botcheck" class="hidden" style="display: none;">
Netlify pozwala wskazać własną nazwę w atrybucie netlify-honeypot. W dokumentacji zwykle pojawia się bot-field, ale równie dobrze może to być website_url albo inna wartość.[2]
<form name="contact" method="POST" data-netlify="true" netlify-honeypot="website_url">
<input name="website_url" type="text">
</form>
Jeśli formularz wysyłasz do własnego API, sama nazwa nie znaczy nic. Możesz dodać dziesięć pól botcheck, a spam nadal przejdzie, jeżeli backend ich nie sprawdzi.
Pułapkę umieszczamy w HTML, ale decyzja zawsze należy do serwera.
Formularzowy honeypot to nie serwer wystawiony na atak
W klasycznym bezpieczeństwie honeypot jest celowo wystawionym systemem, który udaje interesujący cel. Może przypominać serwer SSH, bazę danych albo panel administracyjny. Nikt nie powinien z niego normalnie korzystać, więc każda próba połączenia jest warta odnotowania.
Honeypot w formularzu stosuje tę samą zasadę w mikroskali. Wystawia pole, którego człowiek nie powinien dotknąć. Gdy pojawia się w nim wartość, formularz dostał sygnał wskazujący na automatyzację.
To jest sygnał, a nie wyrok.
OWASP klasyfikuje spamowanie formularzy jako OAT-017 Spamming, czyli automatyczne dodawanie złośliwych lub wątpliwych treści i wiadomości.[3] To tylko jeden rodzaj ruchu botów. W sieci działają też poprawne roboty indeksujące, monitoringi, integracje i narzędzia wspierające dostępność. Celem nie jest zablokowanie całej automatyzacji. Chodzi o podniesienie kosztu tej, która szkodzi.[4]
Dlaczego bot wypełnia pole, którego nie widać
Wyobrażenie o bocie często zaczyna się od bezgłowej przeglądarki, która renderuje stronę niemal tak samo jak Chrome. Wiele operacji spamowych nie potrzebuje niczego tak złożonego.
Najtańszy skrypt pobiera HTML, wyszukuje tagi input, textarea i select, przypisuje wartości, a potem składa żądanie POST. Nie oblicza stylów. Nie sprawdza, czy pole mieści się w oknie przeglądarki. Nie zastanawia się, dlaczego formularz prosi jednocześnie o e-mail, wiadomość i stronę internetową.
Taki skrypt wypełnia wszystko, bo pominięcie wymaganego pola oznacza nieudaną wysyłkę. Zwykły tekstowy input schowany poza ekranem jest dla niego dobrą przynętą.
Bot korzystający z Playwrighta, Puppeteera albo Selenium może już sprawdzić widoczność elementu. Skrypt przygotowany konkretnie pod jedną stronę zapamięta poprawne żądanie i pominie pułapkę. Człowiek wysyłający ręcznie oferty SEO przejdzie bez problemu, bo faktycznie jest człowiekiem.
Honeypot odsiewa przede wszystkim tanią, masową automatyzację. To nadal ma sens. Jeżeli jeden warunek zatrzyma tysiące śmieciowych wiadomości przed wysłaniem maila, zapisaniem rekordu albo uruchomieniem płatnego API, trudno znaleźć tańszy filtr.
Najprostsza wersja, którą warto wdrożyć
Poniższy formularz nie wymaga JavaScriptu.
<form action="/api/contact" method="POST">
<label for="email">E-mail</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="message">Wiadomość</label>
<textarea id="message" name="message" maxlength="5000" required></textarea>
<div class="contact-trap">
<label for="website_url">To pole jest przeznaczone dla automatów. Pozostaw je puste.</label>
<input id="website_url" name="website_url" type="text" tabindex="-1" autocomplete="off">
</div>
<button type="submit">Wyślij</button>
</form>
.contact-trap {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
Pole pułapki ma typ text. To celowy wybór. <input type="hidden"> służy do przesyłania identyfikatorów, tokenów i innych danych dodawanych przez aplikację. Nie da się na nim ustawić fokusu, a nawet prymitywny bot może założyć, że człowiek nigdy niczego tam nie wpisuje.[5]
Kontener został przesunięty poza ekran, ale pole ma etykietę wyjaśniającą, że powinno pozostać puste. tabindex="-1" usuwa je ze zwykłej nawigacji klawiaturą.
Nie dodaję aria-hidden="true" do rodzica pola tekstowego. MDN ostrzega, aby nie ukrywać w ten sposób elementów, które mogą otrzymać fokus, ani ich przodków.[6] W sieci można znaleźć sporo przykładów łączących aria-hidden, fokusowalny input i ujemny tabindex. To mieszanie dwóch sprzecznych komunikatów: element istnieje i można go aktywować, ale drzewo dostępności ma udawać, że go nie ma.
Materiały W3C dotyczące alternatyw dla CAPTCHA zwracają uwagę na inny problem. Jeżeli czytnik ekranu natrafi na honeypot bez instrukcji, użytkownik może go wypełnić i zostać uznany za bota.[7] Dlatego etykieta nie jest dekoracją.
Można schować cały kontener przez display: none. Przeglądarka i czytnik ekranu wtedy go pominą, ale bot łatwiej rozpozna pułapkę po stylu. W formularzu o niewielkim ryzyku wybrałbym dostępność, nie dodatkową porcję sprytu. Statyczny honeypot i tak nie zatrzyma automatu, który analizuje widoczność pól.
Autofill potrafi zepsuć dobry pomysł
Najbardziej kuszące nazwy dla bota są również kuszące dla przeglądarki. email, phone, name, address i company mogą zostać automatycznie uzupełnione z profilu użytkownika.
autocomplete="off" pomaga, ale nie daje gwarancji. Przeglądarki i menedżery haseł mogą zignorować tę podpowiedź.[8]
Dlatego pole email jest kiepskim honeypotem, mimo że bot prawie na pewno je wypełni. Pole podobne do adresu strony internetowej bywa rozsądniejszym kompromisem. Nadal trzeba je sprawdzić w Chrome, Firefoxie i Safari, najlepiej również z popularnym menedżerem haseł.
Żadna nazwa nie rozwiąże tego problemu raz na zawsze. Pole trzeba wdrożyć, obserwować i w razie fałszywych alarmów po prostu zmienić.
Przez pierwsze dni nie usuwałbym złapanych wiadomości od razu. Trafiałyby do krótkiej kwarantanny z informacją, która reguła zadziałała. Dopiero po sprawdzeniu, że przeglądarki nie wypełniają pułapki, przeszedłbym do cichego odrzucania.
Backend wykonuje właściwą pracę
Każdy warunek działający wyłącznie w przeglądarce można pominąć. Nadawca kontroluje żądanie, a nie nasz komponent React.
Poniższy przykład korzysta z aktualnego API funkcji Netlify, opartego na webowych obiektach Request i Response.[9] Zod służy do walidacji prawdziwych pól. Można zastąpić go innym walidatorem, ale nie warto wymyślać własnego wyrażenia regularnego do pełnej obsługi adresów e-mail.
import { z } from "zod";
const Message = z.object({
email: z.string().trim().email().max(254),
message: z.string().trim().min(10).max(5_000),
});
const quietSuccess = () => Response.json({ ok: true }, { status: 202 });
export default async (request: Request) => {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const declaredLength = Number(request.headers.get("content-length") ?? 0);
if (declaredLength > 25_000) return new Response("Payload too large", { status: 413 });
let form: FormData;
try {
form = await request.formData();
} catch {
return new Response("Invalid form data", { status: 400 });
}
const trap = String(form.get("website_url") ?? "").trim();
if (trap !== "") return quietSuccess();
const parsed = Message.safeParse({ email: form.get("email"), message: form.get("message") });
if (!parsed.success) return new Response("Invalid submission", { status: 422 });
await sendContactMessage(parsed.data);
return quietSuccess();
};
export const config = { path: "/api/contact" };
sendContactMessage oznacza kosztowny lub trwały efekt: wysłanie maila, zapis w bazie, utworzenie leada w CRM albo wywołanie webhooka. Ten kod uruchamia go dopiero po sprawdzeniu pułapki i danych.
Bot dostaje taką samą małą odpowiedź 202 jak poprawny formularz. Nie informujemy go, że wpadł przez website_url. Netlify stosuje podobne ciche odrzucanie we własnym mechanizmie.[2]
To podejście ma cenę. Jeżeli prawdziwy użytkownik zostanie błędnie złapany, zobaczy sukces, choć wiadomość nie dotrze. Właśnie dlatego nową regułę najpierw warto połączyć z kwarantanną i obserwacją. Pewność budujemy na własnych zgłoszeniach, nie na tym, że przykład z dokumentacji wygląda przekonująco.
Brak pola też jest informacją
Zwykły formularz HTML wysyła pusty input jako pustą wartość. Skrypt uderzający bezpośrednio do /api/contact może całkowicie pominąć website_url.
Można więc sprawdzić dwa warunki:
- Pole istnieje i jest puste. Tak wygląda oczekiwane żądanie.
- Pole nie istnieje. Nadawca mógł nie użyć aktualnego formularza.
Nie blokowałbym automatycznie drugiego przypadku. Stara wersja strony mogła zostać w pamięci przeglądarki. Frontend i funkcja mogły zostać wdrożone w różnym momencie. Aplikacja mobilna może korzystać z tego samego endpointu.
Brak honeypota jest słabym sygnałem. Wypełniony honeypot jest sygnałem znacznie mocniejszym.
React nie musi przechowywać pułapki w stanie
Najprościej pozwolić przeglądarce zbudować FormData z całego formularza. Puste pole trafi wtedy do żądania bez osobnego useState.
async function handleContact(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const response = await fetch("/api/contact", {
method: "POST",
body: new FormData(form),
});
if (!response.ok) throw new Error("Nie udało się wysłać wiadomości");
form.reset();
}
export function ContactForm() {
return (
<form onSubmit={handleContact}>
<label htmlFor="email">E-mail</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Wiadomość</label>
<textarea id="message" name="message" maxLength={5000} required />
<div className="contact-trap">
<label htmlFor="website_url">To pole jest przeznaczone dla automatów. Pozostaw je puste.</label>
<input id="website_url" name="website_url" type="text" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit">Wyślij</button>
</form>
);
}
Popularny błąd wygląda tak: widoczne pola są w stanie Reacta, a do API wysyłamy ręcznie zbudowany JSON. Honeypot nie ma stanu, bo użytkownik go nie obsługuje, więc znika z payloadu. W HTML zabezpieczenie istnieje, ale backend nigdy go nie dostaje.
Przy JSON-ie trzeba przesłać pułapkę jawnie. Sprawdzenie nadal odbywa się na serwerze.
Honeypot w Netlify Forms
Jeżeli zgłoszenia obsługuje Netlify Forms, nie ma sensu powielać jego logiki we własnej funkcji.
<form name="contact" method="POST" data-netlify="true" netlify-honeypot="website_url">
<input type="hidden" name="form-name" value="contact">
<div class="contact-trap">
<label for="website_url">To pole jest przeznaczone dla automatów. Pozostaw je puste.</label>
<input id="website_url" name="website_url" type="text" tabindex="-1" autocomplete="off">
</div>
<label for="email">E-mail</label>
<input id="email" name="email" type="email" required>
<label for="message">Wiadomość</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Wyślij</button>
</form>
Podczas wdrożenia Netlify analizuje statyczny HTML, usuwa atrybut netlify-honeypot, ale pozostawia wskazane pole. Zgłoszenie z wartością w pułapce zostaje odrzucone bez komunikatu dla nadawcy. Każdy formularz Netlify przechodzi też domyślnie przez Akismet.[2]
W aplikacjach React, Vue i innych rozwiązaniach renderowanych po stronie klienta potrzebny jest statyczny formularz zawierający te same nazwy pól. Dzięki niemu Netlify wykryje definicję podczas budowania strony. Widoczny formularz powinien wysłać także ukryte form-name, a żądanie AJAX musi być zakodowane jako dane formularza.[10]
Jest tu mała pułapka podczas testowania. Odpowiedź 200 nie potwierdza, że zgłoszenie pojawiło się w panelu. Netlify może uznać serię wiadomości z adresem test@test.com i tekstem asdf za spam. Trzeba sprawdzić zarówno Verified submissions, jak i Spam submissions.
Czas jako druga pułapka
Formularz kontaktowy wysłany 70 milisekund po załadowaniu strony prawie na pewno nie został przeczytany i wypełniony ręcznie.
Najłatwiejsza implementacja zapisuje Date.now() w ukrytym polu, a backend odejmuje tę wartość od bieżącego czasu. Taki sygnał zatrzyma skrypt, który niczego nie analizuje. Lepszy bot wpisze dowolną starszą datę.
Jeśli czas ma mieć większą wagę, powinien pochodzić z serwera i być podpisany. Klient może zwrócić token, ale nie zmieni daty bez unieważnienia podpisu.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
const secret = process.env.FORM_TOKEN_SECRET;
if (!secret) throw new Error("Missing FORM_TOKEN_SECRET");
const sign = (value: string) => createHmac("sha256", secret).update(value).digest("base64url");
export default async () => {
const payload = Buffer.from(JSON.stringify({
issuedAt: Date.now(),
nonce: randomBytes(12).toString("hex"),
})).toString("base64url");
return Response.json({ token: `${payload}.${sign(payload)}` }, {
headers: { "cache-control": "no-store" },
});
};
export const config = { path: "/api/form-token" };
Endpoint formularza weryfikuje podpis i wiek tokenu.
function verifyFormToken(token: string) {
try {
const [payload, signature] = token.split(".");
if (!payload || !signature) return false;
const supplied = Buffer.from(signature, "base64url");
const expected = Buffer.from(sign(payload), "base64url");
if (supplied.length !== expected.length) return false;
if (!timingSafeEqual(supplied, expected)) return false;
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
const age = Date.now() - Number(data.issuedAt);
return Number.isFinite(age) && age >= 1_500 && age <= 2 * 60 * 60 * 1000;
} catch {
return false;
}
}
Półtorej sekundy i dwie godziny są wartościami przykładowymi. Krótki zapis do newslettera można poprawnie wysłać bardzo szybko. Długi formularz reklamacyjny wymaga więcej czasu. Autofill dodatkowo skraca cały proces.
Pakiet spatie/laravel-honeypot łączy puste pole z zaszyfrowanym znacznikiem czasu. Domyślnie losuje też nazwę pułapki i ustawia minimalny czas na jedną sekundę.[11] To sprawdzony wzorzec, ale nie powód, aby bez testów kopiować dokładnie ten próg.
Podpisany token nadal można wykorzystać ponownie. Bot pobierze go, poczeka dwie sekundy i będzie wysyłał zgłoszenia aż do wygaśnięcia. Token jednorazowy wymaga zapisania nonce we wspólnej bazie lub magazynie klucz-wartość. Dla zwykłego formularza kontaktowego może to być przerost formy nad treścią. Przy rejestracji kont albo operacji finansowej sytuacja wygląda inaczej.
Zamiast jednej blokady lepiej policzyć ryzyko
Pojedyncza anomalia nie zawsze powinna usuwać wiadomość.
Można zebrać kilka prostych sygnałów:
- Pułapka zawiera wartość. To mocny sygnał, o ile wcześniej sprawdziliśmy autofill.
- Oczekiwanego pola w ogóle nie ma. To sygnał słaby.
- Brakuje poprawnego tokenu wydanego przez serwer.
- Formularz wrócił podejrzanie szybko.
- Ten sam adres IP, sesja lub e-mail wysyła kolejne wiadomości.
- Treść zawiera kilka niepowiązanych odnośników albo powtarzalny tekst.
Każda reguła dodaje punkty. Suma decyduje, czy przyjąć zgłoszenie, przenieść je do kwarantyny, poprosić o dodatkową weryfikację albo odrzucić.
type Signals = {
trapValue: string;
trapPresent: boolean;
tokenValid: boolean;
submittedTooFast: boolean;
recentSubmissions: number;
linksInMessage: number;
};
function calculateSpamScore(input: Signals) {
let score = 0;
if (input.trapValue.trim() !== "") score += 5;
if (!input.trapPresent) score += 1;
if (!input.tokenValid) score += 2;
if (input.submittedTooFast) score += 2;
if (input.recentSubmissions > 5) score += 3;
if (input.linksInMessage > 3) score += 1;
return score;
}
Progi zależą od wartości zgłoszenia. Na prywatnym blogu wynik 5 może oznaczać odrzucenie. W formularzu sprzedażowym, gdzie pojedynczy kontakt jest dużo wart, ta sama wiadomość może trafić do ręcznej weryfikacji.
Scoring daje też miejsce na CAPTCHA wyświetlaną tylko części użytkowników. Normalny formularz przechodzi bez przeszkód. Dopiero średnie ryzyko uruchamia Turnstile lub podobne rozwiązanie.
Jeżeli używasz Turnstile, widget w przeglądarce nie wystarczy. Cloudflare wymaga weryfikacji tokenu przez Siteverify na serwerze. Token wygasa po pięciu minutach i można wykorzystać go tylko raz.[12]
Rate limiting robi więcej niż ukryte pole
Honeypot ocenia jedno żądanie. Rate limit zauważa, że podobnych żądań przyszło pięćdziesiąt.
OWASP nazywa ograniczanie częstotliwości podstawowym zabezpieczeniem i zaleca wiązanie limitów z różnymi identyfikatorami, nie tylko z adresem IP.[4] Sam IP jest łatwy do zmiany przez proxy, a operatorzy komórkowi potrafią umieścić wielu normalnych użytkowników za jednym adresem.
Dla formularza kontaktowego można ograniczać ruch według:
- adresu IP lub jego skrótu przechowywanego przez krótki czas,
- ciasteczka sesyjnego,
- znormalizowanego adresu e-mail,
- endpointu i przedziału czasu,
- konta użytkownika, jeżeli formularz wymaga logowania.
Przykładowa polityka może pozwalać na pięć zgłoszeń z IP w ciągu dziesięciu minut i dwa zgłoszenia na jeden e-mail w ciągu godziny. Licznik musi znajdować się we wspólnym magazynie. Map zadeklarowana w funkcji serverless nie wystarczy, bo instancje powstają, znikają i skalują się niezależnie.
Przy ruchu z nagłymi seriami lepiej użyć token bucket albo sliding window. Zwykły licznik resetowany co dziesięć minut pozwala wysłać pełny limit tuż przed resetem i drugi zaraz po nim.
Nie polecam też długiego sleep w funkcji serverless tylko po to, aby spowolnić bota. Tarpitting ma sens w niektórych systemach i OWASP wymienia go jako możliwą reakcję, ale zajęta przez kilka sekund funkcja może zwiększyć nasz rachunek. Limit na brzegu sieci albo szybka, ogólna odpowiedź są zwykle bezpieczniejsze.
Ile honeypotów naprawdę zatrzymuje spam
Tu pojawia się problem z danymi.
Cloudflare podał, że boty odpowiadały za 31,2 procent ruchu aplikacyjnego przetwarzanego przez jego sieć w 2024 roku. Firma sklasyfikowała 93 procent tego ruchu botów jako niezweryfikowany i potencjalnie złośliwy.[13]
Imperva w raporcie opublikowanym w 2025 roku oszacowała udział automatyzacji w ruchu webowym z 2024 roku na 51 procent. Bad boty miały odpowiadać za 37 procent, a poprawne boty za 14 procent.[14] Kolejna edycja mówi o ponad 53 procentach automatycznego ruchu w 2025 roku.[15]
Nie należy układać tych liczb na jednym wykresie i ogłaszać trendu. Firmy obserwują innych klientów, inne rodzaje żądań i korzystają z własnych klasyfikatorów. To nie są dwa termometry włożone do tej samej szklanki.
Żaden z tych raportów nie mówi też, jaki procent spamu zatrzyma jedno pole website_url.
Nie znalazłem niezależnej, powtarzalnej wartości, którą można uczciwie przypisać do każdego formularza. Hasła typu "honeypot blokuje 99 procent botów" zwykle opisują jedną stronę, jednego dostawcę albo próbkę bez podanej metodologii.
Skuteczność trzeba zmierzyć lokalnie:
- ile wiadomości uruchomiło pułapkę,
- ile przeszło dalej i okazało się spamem,
- ile wpisów z kwarantanny wyglądało na poprawne,
- czy użytkownicy zgłaszali niedostarczone wiadomości,
- jak wynik zmienił się po tygodniu i po miesiącu.
Raporty pokazują, że automatyzacja stanowi dużą część ruchu. Własne logi pokażą, czy honeypot rozwiązuje nasz problem.
Jak bot obchodzi honeypot
Nie trzeba być szczególnie zaawansowanym.
Bot może:
- ominąć pola z
display: none,hidden, zerowym rozmiarem albo pozycją poza ekranem, - nie wypełniać inputów opisanych jako
bot,honeypotlubleave blank, - wysłać POST bezpośrednio do endpointu,
- użyć pełnej przeglądarki i wybierać tylko elementy widoczne,
- porównać odpowiedzi serwera i zauważyć, która wartość powoduje blokadę,
- pobrać podpisany token, odczekać wymagany czas i użyć go ponownie,
- zlecić wypełnienie formularza człowiekowi.
Losowanie nazwy pola trochę pomaga, o ile serwer wie, której nazwy oczekiwać. Nie zmienia jednak faktu, że bot analizujący gotowy formularz zobaczy dodatkowy input. Kilka pułapek zwiększa szansę złapania prostego automatu, ale dodaje też więcej miejsc na problemy z dostępnością i autofill.
Nie próbowałbym budować nieprzeniknionej zagadki z CSS i JavaScriptu. Każda taka zagadka trafia do przeglądarki napastnika razem z instrukcją jej rozwiązania. Lepiej dołożyć niezależne warstwy: limit częstotliwości, czas, analizę treści, potwierdzenie e-mail i w razie potrzeby weryfikację typu Turnstile.
Czego honeypot nie zabezpiecza
Honeypot nie chroni przed CSRF. Cross-site request forgery zmusza zalogowaną przeglądarkę do wysłania operacji z uprawnieniami ofiary. Potrzebne są mechanizmy frameworka, odpowiednio ustawione ciasteczka SameSite, tokeny CSRF i kontrola pochodzenia żądania. OWASP przypomina, że zwykły formularz HTML może wysłać między domenami tzw. simple request.[16]
Nie jest to również ochrona przed SQL injection, XSS ani command injection. Dane nadal trzeba walidować, w bazie używać parametryzowanych zapytań, a przy wyświetlaniu poprawnie kodować dane wyjściowe.
Pusta pułapka nie dowodzi, że formularz wypełnił człowiek. Oznacza tylko, że konkretna reguła nie zadziałała.
Honeypot nie wystarczy przy logowaniu, rejestracji, resetowaniu hasła, głosowaniu, płatnościach i zakupach. Tam motywacja napastnika jest większa. Zabezpieczenia powinny uwzględniać konto, sesję, liczbę operacji, ryzyko transakcji i reguły konkretnego biznesu.
Nie uratuje też kosztownej operacji wykonanej przed sprawdzeniem formularza. Najpierw walidacja i filtry, później wysyłka maila, wywołanie modelu AI, utworzenie konta albo zapis w CRM.
Błędy, które powtarzają się najczęściej
`type="hidden"` jako przynęta
Ukryte inputy są przeznaczone dla danych aplikacji. Bot nie ma powodu ich wypełniać. Pułapka powinna być normalnym polem schowanym przez kontener.
Sprawdzenie tylko w komponencie
Kod działający w przeglądarce nie chroni endpointu. Backend musi ponownie wykonać wszystkie istotne kontrole.
Nazwa `honeypot`
To trochę jak podpisanie pułapki tabliczką. Naturalna nazwa daje większą szansę złapania parsera, ale nie powinna przypominać pola często uzupełnianego przez przeglądarkę.
Szczegółowy błąd dla bota
Odpowiedź 403: website_url must be empty dokładnie wyjaśnia, co poprawić. Zwróć zwykłą odpowiedź albo umieść zgłoszenie w kwarantannie bez ujawniania reguły.
Automatyczna blokada przy braku pola
Może odciąć formularz zapisany wcześniej w przeglądarce lub starszego klienta API. Brak pułapki lepiej najpierw traktować jako jeden z kilku sygnałów.
Zapisywanie całego spamu w logach
Wiadomość może zawierać dane osobowe, złośliwe adresy i ogromny payload. Do obserwacji zwykle wystarczy kod przyczyny, identyfikator żądania, czas i krótkotrwały identyfikator źródła.
Wiara w dowolny `X-Forwarded-For`
Klient może sfałszować ten nagłówek. Ufaj tylko wartości dodawanej przez kontrolowaną infrastrukturę i korzystaj z pola opisanego przez Netlify, Cloudflare albo własny reverse proxy.
Zbyt agresywny limit czasu
Autofill potrafi uzupełnić krótki formularz niemal natychmiast. Strona może też pozostać otwarta przez kilka godzin. Czas powinien zwiększać wynik ryzyka, nie zawsze samodzielnie odrzucać wiadomość.
Test, który sprawdza coś więcej niż zielony komunikat
Przed publikacją sprawdziłbym następujące przypadki:
- Normalna wiadomość z pustą pułapką dociera dokładnie raz.
- Pułapka wypełniona przez DevTools daje zwykłą odpowiedź, ale nie uruchamia maila, webhooka ani zapisu do bazy.
- Bezpośredni POST bez pola trafia do przewidzianej ścieżki scoringu lub kwarantanny.
- Bardzo szybkie zgłoszenie nie blokuje poprawnego autofill.
- Formularz pozostawiony otwarty dłużej niż ważność tokenu potrafi pobrać nowy token albo pokazuje sensowny błąd.
- Podwójne kliknięcie nie tworzy dwóch zgłoszeń.
- Nawigacja klawiaturą, czytnik ekranu i menedżer haseł nie wypełniają pułapki bez wiedzy użytkownika.
- Zbyt duży payload zostaje odrzucony przed uruchomieniem kosztownych operacji.
- Rate limit korzysta ze wspólnego licznika i działa na kilku instancjach funkcji.
- Logi pokazują, która reguła zadziałała, ale nie przechowują całej wiadomości bez końca.
Dwa podstawowe żądania można wysłać przez curl.
curl -i https://example.com/api/contact -F "email=jan@example.com" -F "message=To jest poprawna wiadomość testowa" -F "website_url="
curl -i https://example.com/api/contact -F "email=bot@example.com" -F "message=Kup tanie linki" -F "website_url=https://spam.example"
Oba mogą zwrócić 202. Tylko pierwsze powinno uruchomić właściwą operację. Test integracyjny musi sprawdzić efekt uboczny, nie sam status HTTP.
Gdzie ten mechanizm wystarczy
Puste pole sprawdzane na serwerze jest rozsądnym początkiem dla prywatnej strony kontaktowej, komentarzy z moderacją, formularza opinii albo prostego zapisu do newslettera z potwierdzeniem adresu.
Rate limiting dodałbym od razu, bo chroni również przed botem, który zna pułapkę. Podpisany czas ma sens, gdy w logach pojawiają się bezpośrednie wywołania endpointu. CAPTCHA powinna wejść później, najlepiej tylko dla żądań o podwyższonym ryzyku.
Przy kontach użytkowników, płatnościach, rezerwacjach, limitowanym towarze i publicznym API zaczynałbym od modelu zagrożeń dla konkretnej operacji. Honeypot może zostać jako darmowy filtr, ale nie powinien być główną bramką.
Im więcej można zarobić na obejściu zabezpieczenia, tym krócej działa statyczna sztuczka.
Mała rzecz, która robi dokładnie jedną rzecz
Po całym tym czytaniu nadal mam słabość do honeypota.
Nie próbuje rozstrzygać, kim jest użytkownik. Wystawia przynętę i obserwuje, czy ktoś jej dotknie. Większość ludzi nie widzi dodatkowego skryptu, zagadki ani obrazków z przejściami dla pieszych. A prymitywny spam może zakończyć się przed dojściem do skrzynki i płatnych usług.
To bardzo dobry interes jak na kilka linii HTML i jeden warunek na serwerze.
Trzeba tylko pamiętać, co naprawdę wiemy. Wypełnione pole jest mocnym sygnałem dopiero po sprawdzeniu autofill. Puste pole nie jest dowodem człowieczeństwa. Globalny udział botów w ruchu nie mówi nic o skuteczności naszego formularza. Zielony komunikat nie potwierdza, że Netlify zapisał zgłoszenie.
Honeypot najlepiej działa wtedy, gdy nie próbujemy zrobić z niego całego systemu bezpieczeństwa. Niech odsieje tani spam. Resztą powinny zająć się limity, walidacja, obserwacja i mocniejsze kontrole uruchamiane tam, gdzie naprawdę są potrzebne.
Źródła i dalsza lektura
- Web3Forms: Spam Protection. Dokumentacja pola
botcheckużywanego przez usługę. - Netlify: Spam Filters. Honeypot, Akismet i ciche odrzucanie zgłoszeń.
- OWASP Automated Threats to Web Applications. Klasyfikacja automatycznych nadużyć, w tym OAT-017 Spamming.
- OWASP Bot Management and Anti-Automation Cheat Sheet. Warstwowa ochrona, rate limiting, honeypoty i sposoby reagowania.
- MDN: input type hidden. Zachowanie ukrytych pól formularza.
- MDN: aria-hidden. Drzewo dostępności i ostrzeżenie dotyczące fokusowalnych elementów.
- W3C: CAPTCHA Alternatives and Thoughts. Dostępność honeypotów i ryzyko dla użytkowników czytników ekranu.
- MDN: Turning off form autocompletion. Ograniczenia
autocomplete="off". - Netlify Functions API Reference. Format funkcji opartych na
RequestiResponse. - Netlify Forms Setup. Wykrywanie formularzy w statycznym HTML i wysyłka z aplikacji JavaScript.
- Spatie Laravel Honeypot. Implementacja łącząca losowane pole z podpisanym czasem.
- Cloudflare Turnstile: Server-Side Validation. Weryfikacja, ważność i jednorazowość tokenów.
- Cloudflare Application Security Report 2024 Update. Dane Cloudflare dotyczące udziału botów w ruchu aplikacyjnym.
- Imperva 2025 Bad Bot Report. Dane Imperva dotyczące ruchu automatycznego w 2024 roku.
- Imperva Bad Bot Report 2026. Kolejna edycja raportu z danymi za 2025 rok.
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet. Oddzielne zabezpieczenia wymagane przed CSRF.
Chcesz przełożyć ten temat na swoją stronę albo firmę? Mogę zająć się projektem, wdrożeniem i dalszą opieką.