Spam and Abuse Prevention
A public chat bubble is an open door. Anyone can open the widget, and the visitor endpoints have to work without a login, so they run with an open permission callback. Super Speedy Chat leans on several layers instead of a single gate: a hidden honeypot to catch bots, per-IP rate limiting to blunt floods, an optional login requirement to shut anonymous visitors out entirely, and a hard daily ceiling on paid LLM calls to cap the cost of an attack. This guide explains each control, where it lives in the code, and which ones you should turn on for a public site versus a members-only one.
Every chat form renders a hidden text input named website_url. It sits off screen (position:absolute;left:-9999px), is zero height and width, has tabindex="-1" so keyboard users skip it, and has autocomplete switched off. A real person never sees it and never fills it in. Automated form-fillers, which populate every field they find, will.
On the server, the send handler reads that field before doing anything else:
$honeypot = $request->get_param( 'website_url' );
if ( ! empty( $honeypot ) ) {
return rest_ensure_response( array( 'conversation_id' => 0, 'message_id' => 0 ) );
}
The honeypot fails silently. A caught bot receives a normal-looking success response with zeroed IDs, so it never learns it was blocked and has no error to adapt to. Nothing is stored and nothing reaches your inbox.
If the honeypot has any value, the request is dropped silently. The bot gets a normal-looking success response with zeroed IDs, no message is stored, and nothing reaches your inbox. Because the drop is silent, the bot has no signal it was caught and no error to adapt to. The honeypot is always on, with nothing to configure, and runs on both the normal REST path and the Ultra Ajax fast path, since both dispatch through the same send handler.
Per-IP rate limiting
Every visitor endpoint is rate limited per IP address, using a WordPress transient keyed on the action name plus an MD5 of REMOTE_ADDR. The check increments a counter inside a fixed time window and returns a 429 Too Many Requests error once the count passes the limit. The window is 60 seconds throughout. The confirmed limits are:
| Endpoint | Limit | Window |
|---|---|---|
session | 10 | 60 seconds |
send | 15 | 60 seconds |
auto-reply | 3 | 60 seconds |
The tightest limit is on auto-reply (3 per minute), because that is the endpoint that can trigger a paid LLM call. When a limit is exceeded the REST API returns a genuine HTTP 429 with the message “Too many requests. Please wait a moment.” The Ultra Ajax mu-plugin enforces the same numbers independently, using the same transient keys and the same 60-second window, and also responds with HTTP 429 when tripped, so switching on fast polling does not open a gap. Note that keys are per action, so a burst of cheap session calls cannot use up a visitor’s send allowance and vice versa.
Rate limiting is always on and has no setting. It is a floor, not a wall: an attacker rotating through many IPs is not stopped by per-IP limits alone, which is why the LLM daily cap (below) exists as a separate backstop.
Login prompt versus Require Login enforcement
These two are easy to confuse, and they do very different jobs.
The login prompt is a soft nudge. After a visitor sends a set number of messages (the “Prompt Login After” setting, ssc_login_prompt_after, default 5) the widget reveals a small “Log in or create an account to save your chat history” banner. It is purely cosmetic. The visitor can ignore it and keep chatting as an anonymous guest. It exists to encourage account creation, not to block anyone. See Behaviour and Session Settings for how to tune it.
Require Login is a hard gate. It is the “Require login to chat” checkbox (ssc_require_login, default off). Before version 1.09 the checkbox existed but did nothing. Since 1.09 it is fully enforced on both sides:
- On the server, a
check_require_login()guard runs at the top of every visitor endpoint (session, send, poll, email, auto-reply). When the setting is on and the request is not from a logged-in user, it returns a401with the message “Please log in to use chat.” No session is created, no message is stored, no LLM call is possible. - In the widget, an anonymous visitor never sees a message box. The input area is replaced with a “Log in or create an account to start chatting” invitation, and the widget never opens a session or starts polling. The login and register links use the site’s configured URLs.
The Ultra Ajax mu-plugin cannot authenticate a user itself (it runs before WordPress loads the auth stack), so when Require Login is on it deliberately falls through to the normal REST API, where the check_require_login() guard is authoritative. Turning the setting on therefore does not accidentally bypass the gate on the fast path.
The LLM daily cap
The auto-reply endpoint can dispatch a paid LLM classification call, and it is unauthenticated. Per-IP rate limiting slows a single attacker but does not bound the bill someone can run up by rotating IPs. The daily cap is the hard global backstop for that.
SSC_LLM counts every paid classification call against a per-day transient (keyed on the UTC date). Before dispatching, it checks whether today’s count has reached the cap; if so, it logs “daily classification cap reached” and returns no match instead of calling the provider. The default ceiling is 200 calls per UTC day (DAILY_CALL_CAP). You can raise, lower, or disable it with the ssc_llm_daily_cap filter; returning 0 or a negative number disables the cap entirely.
add_filter( 'ssc_llm_daily_cap', function() {
return 500; // Raise the ceiling. Return 0 to disable.
} );
Do not disable the daily cap on a public site. Per-IP rate limiting does not stop an attacker rotating through many IP addresses, so the cap is your only hard ceiling on the paid LLM bill. Set it to a number you are comfortable paying on your worst day rather than returning 0.
On top of the cap, the auto-reply handler will only classify for a real, in-progress conversation, and at most once per conversation: it refuses if the conversation has no visitor message yet, and refuses again if it has already auto-replied. Those two conditions bound how many paid calls any one conversation can cause regardless of the global cap. See LLM Auto-Reply Setup Guide for the wider auto-reply configuration.
Other guards
A few smaller controls tighten things further:
- Message length limit. Visitor messages are capped at
ssc_max_message_length(default 500 characters). Anything longer is rejected server-side, and the widget’s textarea carries a matchingmaxlength. Empty messages are rejected too. - Input sanitisation. All visitor-supplied text runs through
sanitize_text_field()before storage, so message bodies cannot carry markup or scripts into the inbox. - Nonce on the widget. The front-end sends a WordPress nonce with every request via the
X-WP-Nonceheader, which ties requests to a genuine page load rather than a bare curl.
Recommendations
Public site (anyone can chat). Leave the honeypot and rate limiting as they are, they are always on. Keep Require Login off, since the point is to talk to strangers. If you use LLM auto-reply, keep the daily cap in place and set it to something you are comfortable paying for on your worst day; do not disable it. Use the soft login prompt to convert regulars into accounts without blocking anyone.
Members-only or internal site. Turn Require Login on. That alone removes the entire anonymous attack surface: no anonymous sessions, sends, or LLM calls, so the honeypot and the daily cap become belt-and-braces rather than front-line defences. Everything else can stay at defaults.
If legitimate visitors ever report a 429 or a “Please log in” message they did not expect, check these settings first, then see Troubleshooting and FAQ. For the exact status codes each endpoint returns, see REST API Reference.

Comments