Securing an AI Chatbot: Hybrid Encryption, Rate Limiting, and Bot Detection
· English · Ghostwritten by Claude Sonnet 5
The chat widget on this site’s profile page lets an anonymous visitor ask an AI a few questions about my résumé. That sounds simple, but “anonymous visitor, real backend, open internet” is exactly the shape of problem that invites abuse: replay attacks, scraping, and plain old cost overruns if someone scripts a few thousand requests against my Anthropic API bill overnight. None of this is exotic — it’s the same handful of concerns you’d apply to any public-facing endpoint that costs money per call.
Hybrid encryption, not just HTTPS
TLS protects the request in transit, but the request body still needs to reach my server logic in a form the browser and the server can agree on without a shared secret exchanged in advance. Each visitor’s browser generates a fresh AES-256 key, encrypts the message payload with it, then wraps that AES key with an RSA-OAEP public key the server handed it moments earlier:
const aesKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']);
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, data);
const wrappedKey = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, rawAesKey);
Both the browser and the server speak the same native Web Crypto API — no third-party crypto library on either end, no PEM parsing, and both sides use SHA-256 for RSA-OAEP throughout. The server’s private key never leaves Redis, keyed by a hash of the visitor’s anonymous ID, IP, and user agent, with a 24-hour TTL.
Rate limiting that’s actually atomic
A sliding-window rate limiter sounds straightforward until two requests arrive within a millisecond of each other and both read the same “count so far” before either writes back. The fix is to never let the check-and-increment happen as two separate round trips — it runs as a single Lua script inside Redis, so nothing else can interleave:
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, member)
return {1, limit - count - 1}
else
return {0, 0}
end
Bot detection without a CAPTCHA
Nobody likes solving a CAPTCHA to ask a chatbot a question. Instead, the client accumulates a
weighted score from real interaction signals — mouse movement, focus events, keystrokes, touch
events — and a message typed suspiciously fast relative to its length gets penalized. Headless
browsers (navigator.webdriver, a HeadlessChrome user agent, missing plugins and languages)
are flagged outright. It’s not bulletproof, but it raises the cost of automated abuse well above
what a casual script would bother with, without asking a real visitor to do anything at all.
None of this is visible in the product — a visitor just types a question and gets an answer. That’s the point.