Контактна форма на цьому сайті стріляє Telegram-сповіщенням при кожній відправці. Коли відвантажував, думав, що знадобиться reCAPTCHA — кожен гайд із захисту від спаму в інтернеті так каже. Спробував тиждень, ненавидів тертя, видрав.
Два місяці потому, без reCAPTCHA, інбокс все ще чистий. Ось патерн.
Весь фільтр, у коді
// app/api/telegram/route.ts
const data = await request.json()
const { website, mountedAt, name, email, message } = data
// 1. Honeypot — реальні користувачі ніколи не бачать це поле
if (website && website.trim().length > 0) {
return NextResponse.json({ success: true }) // мовчки приймаємо
}
// 2. Time-floor — боти відправляють миттєво, людям треба набирати
if (typeof mountedAt === 'number' && Date.now() - mountedAt < 1500) {
return NextResponse.json({ success: true })
}
// 3. Ліміти розміру — захищаємо downstream API від абʼюзу пейлоаду
if (name.length > 200 || email.length > 200 || message.length > 5000) {
return NextResponse.json({ error: 'Payload too large' }, { status: 413 })
}
Ось і все. Три гарди, всі серверні. Без клієнтського скрипта.
Чому кожен важливий
Honeypot — приховане поле форми. <input name="website" tabindex="-1" autocomplete="off" style={{display:'none'}} /> на стороні React. Реальні браузери його не рендерять, реальні користувачі не заповнюють. Більшість спам-ботів обходять DOM, бачать input і старанно заповнюють кожен. У момент, коли website має значення — ти знаєш, що це бот. Критична деталь: не повертай помилку. Повертай { success: true }. Боти, що стежать за 4xx/5xx, будуть ретраїти; боти, що отримали 200, позначать форму як «готово» і підуть.
Time-floor ловить решту. Форма надсилає таймстамп маунту в прихованому полі при рендері. Коли приходить відправка, все, що менше 1.5 секунди між маунтом і відправкою — бот. Люди фізично не можуть набрати імʼя, email і повідомлення так швидко. Той самий трюк: повертаємо 200 мовчки, щоб бот не навчився відступати.
Ліміти розміру — не стільки анти-спам, скільки захист від абʼюзу. Без них нудьгуючий атакуючий міг би POST'нути 50MB тіло і розігнати квоту Telegram API. Кепай кожне поле до розумного максимуму і повертай 413 для oversize.
Чому без reCAPTCHA
reCAPTCHA вирішує реальну проблему неправильним способом для цього юзкейсу. Вона:
- Завантажує ~100KB стороннього JavaScript на кожну сторінку — більша частина якого — fingerprinting відвідувачів, щоб Google міг оцінити їх пізніше.
- Додає розкриття приватності на сторінку — юридично потрібна виноска про трекінг Google, що зовсім неправильний тон для «напишіть мені».
- Тертя: навіть невидима reCAPTCHA фейлиться для деяких користувачів на VPN, у приватному режимі або на Linux/Firefox. Ці користувачі — непропорційно розробники, твої потенційні клієнти — бачать галочку або гірше, пазл. Частина з них здається.
Для малообʼємної B2B контактної форми математика просто не працює. Вартість reCAPTCHA (втрачені легітимні відправки) вища за користь (фільтрація спаму, який honeypot і так ловить).
Коли це перестане працювати
Я б переглянув у момент, коли щось із цього стане правдою:
- Цілеспрямовані атаки, а не generic спам — людина, що пише кастомний код для обходу конкретно твого фільтра. Чотирирядковий фільтр — для скрапера, що обходить 100K форм на день, а не для того, хто намагається спамити тебе конкретно.
- Високообʼємні форми — підписки на розсилку, форми реєстрації, що завгодно із серйозними грошима за ним. Там вартість false-negative достатньо висока, щоб виправдати розумнішу систему (Cloudflare Turnstile, Friendly Captcha, або повноцінний rate limiting на Redis).
- Authenticated abuse — коли спам іде від залогованих акаунтів, фільтр форми — не той шар. Переміщуй на створення акаунтів.
Для всього між — портфоліо, маленька SaaS контактна сторінка, CTA «розкажіть більше» — починай із чотирирядкової версії. Додавай складність тільки якщо реально зламається.
Повна клієнтська частина на React
Для повноти, React-сторона, що живить time-floor і honeypot:
const [mountedAt] = useState(() => Date.now())
return (
<form onSubmit={handleSubmit}>
{/* honeypot — візуально + семантично прихований */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute left-[-9999px]"
/>
<input type="hidden" name="mountedAt" value={mountedAt} />
{/* реальні поля */}
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Надіслати</button>
</form>
)
Три речі, які треба зробити правильно:
- Не використовуй
display: noneна honeypot. Деякі боти перевіряють computed style і пропускають. Використовуй absolute positioning за межами екрану — виглядає порожнім для людей, виглядає реальним полем для краулерів. - Mount timestamp — per-render, не per-session. Якщо закешуєш у localStorage — зламаєш time-floor для легітимних користувачів, що повертаються.
aria-hiddenне дає скрінрідерам оголосити honeypot. Вони б і так пропустилиdisplay:noneполе, але absolute-positioned inputs безaria-hiddenможуть збити асистивні технології.
Урок, якщо він є: захист від спаму — це перш за все проблема UX. «Правильна» відповідь в абстракції — найважчий, найрозумніший фільтр — часто неправильна відповідь, коли врахуєш вартість, яку він накладає на легітимних користувачів. Для контактної форми на особистому сайті чотирьох рядків серверної перевірки достатньо. Капчі бережи для загроз, які реально їх заслуговують.