Skip to content

Spam defence

Charcha checks a comment in layers, cheapest first. The cheap ones cost a property read or a comparison, the expensive ones cost a database query or a call over the network, and an expensive layer only ever sees what the cheap ones could not settle.

That ordering is fixed. You cannot rearrange it, and the defence works on a deployment where you have configured nothing at all.

The eight checks, and what each wants from you

Section titled “The eight checks, and what each wants from you”
# Layer What it needs
1 Honeypot field Nothing
2 Time to submit Nothing
3 Turnstile A secret key, and the matching sitekey on your page
4 Rate limit, per IP and per thread Nothing, though the per-address half wants IP_HASH_SECRET
5 Somebody you already refused IP_HASH_SECRET, and a spam decision you made earlier
6 Content heuristics Nothing
7 The classifier Your moderation decisions, roughly sixty of them
8 Third-party provider Two secrets, and a paragraph in your privacy notice

Then you. The moderation queue is the ninth gate and the only one that is a person, and nothing above it can be skipped to reach it.

Layers 1, 2, 4, 5 and 6 transmit nothing about your reader anywhere. Layer 3 makes one subrequest to Cloudflare, and only if you have set a Turnstile secret. Layer 7 sends the text of a comment to a Cloudflare model running on your own account, the same account that already runs the Worker and holds every comment in it.

Layer 8 is the one that leaves. It hands a commenter’s IP address, email and words to a company that is not you, which is why it stays off until you set two secrets and why everything it sends is written out field by field.

Four things: reject, review, vouch, or nothing at all.

Nothing at all is the ordinary case, and it does not mean the comment is fine. It is also what a layer says when it did not run, which is why it can never be treated as approval.

A reject stops the run there and answers 403, so a rejected comment costs only the layers cheap enough to have already gone. A review does not stop the run, because a held comment still writes a database row and the layers that bound writes have to get their say first. If something later rejects, the reject wins. If two layers ask for review, the reason stored with the comment is the first one’s.

There is still no allow, deliberately. A layer that could allow would be a layer that could veto every layer behind it, and the ordering would stop being a pipeline.

A vouch says “I checked this comment and found it clean”. Silence cannot say that, because silence is also what a layer that never ran produces. Only a layer that asked a real classifier for a real verdict can vouch, and only on a positive answer. Today that is the third-party provider and nothing else.

A vouch never outranks a doubt. It is collected separately from the holds and consulted only after every layer has run, so a review or a reject from any layer beats any number of vouches, whichever came first. A vouching layer cannot stop the layers after it from being asked.

What it does is raise the ceiling on what you are allowed to do with a clean run. It never lowers the floor. Moderation is where you decide whether to act on one.

One property read and one comparison. No network, no database.

The field is called subject, a name that describes nothing and that no browser’s autofill recognises. It is hidden inline in the markup rather than from a stylesheet, because bare styling mode is permission to drop Charcha’s CSS entirely and a guard you can switch off by changing your theme is not a guard.

A missing field passes, since that is what a stale cached embed sends and absence is not evidence. A filled one is rejected, and this is the one layer where rejecting is easy to defend: the field is unlabelled, out of the tab order, and no person will ever find it.

The floor is 2,000 milliseconds. Faster than that is a reject.

The embed measures how long the composer was open with performance.now() and sends a duration, never a timestamp. A wall-clock timestamp would have to be compared against the server’s clock, and a phone running three seconds fast would look like it submitted the comment before the page loaded.

A missing, non-numeric or negative duration is held for review rather than rejected. That is also what an older cached embed produces.

This is exactly as forgeable as a timestamp would have been. It is here for the bots that do not bother, and it claims nothing more than that.

Off unless you set TURNSTILE_SECRET_KEY. Without it the layer abstains and makes no call at all.

With a secret set, it posts the token to https://challenges.cloudflare.com/turnstile/v0/siteverify and waits at most 5,000 milliseconds. A token longer than 2,048 characters is thrown out before the call is made.

It is free on the Cloudflare account you already have. Cloudflare’s plans page lists “Unlimited challenges (traffic or verification requests)” on the free plan, with up to 20 widgets and 10 hostnames per widget (plans, checked 2026-07-29). There is no quota to spend and no billing decision to take.

This is the one layer that adds a third party to your pages, so it is worth being plain about what that means.

The sitekey on your page loads Cloudflare’s widget, which runs in an iframe on challenges.cloudflare.com. What it does inside that iframe is Cloudflare’s, covered by the Turnstile Privacy Addendum, not by anything Charcha promises. Switching Turnstile on is you adding a third party to your site.

Charcha itself still stores nothing in a reader’s browser, with this on or off. No cookie, no localStorage, no sessionStorage. That is a design constraint rather than a current state of affairs, and it does not change when you enable this layer.

There is one exception and it is yours to make. Turnstile’s pre-clearance setting issues a cf_clearance cookie. Every widget has it off by default, and turning it on is something you would do yourself in the Cloudflare dashboard. Charcha’s no-cookies promise cannot cover a cookie you asked Cloudflare to set, so leave it off or disclose it.

The sitekey is public, goes on your page as data-turnstile-sitekey, and is what puts the widget there. The secret key is private, goes on the Worker, and is what lets the Worker check the widget’s answer. Set both or neither.

The two directions are not symmetrical, and knowing which is which is the point.

A sitekey with no secret key is the harmless direction. The widget renders, the reader solves it, and nothing checks the answer. The layer abstains and no comment is affected.

A secret key with no sitekey is the failure worth knowing about, because it looks like nothing at all. Every comment then arrives with no token to verify. Those comments are held for review rather than refused, marked turnstile: no-token-unverified-deployment in your queue, until one real token has verified on your deployment, after which anything arriving without a token is refused. The symptom is a moderation queue filling up with comments that look perfectly ordinary. Your dashboard’s Setup tab names the sitekey for exactly this reason: Charcha cannot see your pages, so it cannot check that half for you.

The first layer that reads the database. Two counts, never a scan, both bounded by an index, so it costs two queries whatever the page holds.

Limit Default Window Verdict
Per address 5 comments 600 seconds reject
Per page, from everybody 30 comments 600 seconds review

The two verdicts differ on purpose. Rejecting per address is easy to defend, since the whole point is to stop one address spending your deployment’s write budget. The per-page limit is a circuit breaker against a flood arriving from many addresses at once, and the person who trips it has done nothing wrong. They arrived after thirty other people, on the day a post found an audience. Holding for review changes what happens to them without moving the trigger.

The per-address half needs two things: IP_HASH_SECRET, and a CF-Connecting-IP header from the edge. Missing either, it abstains rather than guessing, because an unkeyed hash of an address is reversible, and it says so once in your Worker’s log so that a guard being off is something you can find out. The per-page half needs neither and runs everywhere.

Both halves read a count and then the pipeline writes, with nothing holding the two together, so what you have is a bound on a sustained rate rather than an exact quota. D1 offers no way to close that gap.

These thresholds are constants in the source today. Changing one means editing the code and redeploying, and the dashboard cannot show you what they are.

The only layer whose evidence is a decision you actually made. Every other local check measures the absence of something wrong, and a script written against your form walks past all of them. This one replays a judgement a human took, in your dashboard, about where this comment came from.

It costs one indexed database read and one hash, which is why it sits here rather than earlier. It needs IP_HASH_SECRET. Without it your deployment never writes the column this reads, so the layer abstains and says so once in your log.

Two tiers, because the identity has two strengths:

Reason in the queue What matched
known-spammer The email address and the hashed address, both against the same comment you marked spam
address-refused-before The hashed address alone

The loose tier exists because being wrong costs differently in each direction. Wrongly trusting somebody publishes a stranger’s spam with nobody in the way. Wrongly distrusting them holds a real comment for review, which is what a fresh deployment does to every comment anyway.

That said, the loose tier’s price is real and is paid by people who did nothing. An address gets shared behind a router, handed around an office or a campus, pooled by a mobile carrier, and reassigned by an ISP next month. A spammer’s neighbour is held, and nobody tells them. The retention sweep clears the stored hashes on a window, so the effect expires on its own rather than waiting for somebody to notice it.

This is the strongest evidence anywhere in the pipeline, so it is also the best case for refusing outright. It still does not, and the reason is worth following.

Anybody can write a comment carrying anybody else’s email address. On any shared address, somebody can post obvious spam under a victim’s name, using an address that is usually public anyway. You mark it spam, correctly. If that produced a refusal, every comment the victim wrote from then on would be turned away with a bare 403, never stored, never queued, with nothing for either of you to see, until the retention sweep cleared the hash. The attacker would have aimed your own moderation at somebody.

Note that the trust direction cannot be gamed the same way. To plant approval, somebody needs you to approve their spam, which you will not. To plant condemnation, they only need you to do your job.

Refusing would have bought one database row, and layer 4 already bounds those. What is worth keeping is the reason, which tells you which of the two tiers you are looking at.

The last layer that judges a comment by rules you could read, and the one whose judgement is least certain, which is why most of what it does is hold rather than refuse.

Signal Threshold Verdict
Link count 3 or more review
Link count with little prose 10 or more links and 400 characters or less left after the links are removed reject
BBCode [url=…] or [/url] markup present review
Lookalike link domain present review
Exact duplicate body same page, within 3,600 seconds, body 60 characters or longer reject
Same body on one other page within the same window review, as duplicate-across-pages
Same body on two other pages within the same window reject

Rejecting on link count alone would throw away a nine-thousand-character technical answer with ten citations, which is the best comment your site gets that week. The rule is about the ratio, so both halves have to hold.

BBCode is held rather than refused because the Markdown renderer produces fenced code blocks, and a reader quoting the spam they received writes that markup honestly.

The duplicate rule looks across your whole deployment rather than at one page. What changes across pages is the threshold, not the verdict, and the escalation is the point. The same paragraph on a second page is stronger evidence of intent and weaker evidence of pointlessness: a second copy on one page is visibly useless to everybody including whoever wrote it, while a copy on a new page is the only copy that page has. It might also be somebody answering the same question on two posts about the same thing, which no rule here can tell apart and you can tell in a second. So it is held, tagged duplicate-across-pages, and left to you.

A third page is where that reading runs out. Nobody writes one paragraph and posts it unchanged to three articles inside an hour.

The duplicate window has an end rather than running forever, for a specific reason. Comments are soft-deleted, so a body you removed as a moderator would otherwise block that text for good, invisibly, including for the person who wrote it.

Only a scheme or a www. prefix counts as a link. Bare dotted words are prose, since the renderer never autolinks, and a counter that thought otherwise would hold half of every programming blog for review.

A link whose host has a single label mixing Latin with Cyrillic or Greek is held for review, and the reason names the signal.

It is always a hold and can never be a refusal. An internationalised domain is a real domain belonging to real people, and no property of the string separates one owned by somebody who does not write in Latin from one chosen to impersonate a Latin brand. Only intent separates those two, and intent is what a moderator has and a regular expression does not. Refusing would fall entirely on readers whose script is not Latin, in the name of protecting them.

Three scripts are tracked, following Unicode UTS #39’s Restriction Levels, which name Cyrillic and Greek because their letters have Latin twins. A Devanagari, Arabic or Han label contains no tracked character and is never flagged, whatever it sits beside.

The judgement is per label, not per host. Nearly every internationalised domain sits under an ASCII top-level domain, so judging the whole host would flag the entire IDN namespace.

What it misses is worth saying plainly. Whole-script confusables, where nothing is mixed, go through. So does punycode, where the label is already ASCII.

The first layer with no rule inside it. It learns what spam looks like on your site from the decisions you make in your own moderation queue, and it starts out knowing nothing whatsoever.

Every time you approve a comment or mark one spam, Charcha sends that comment’s text to a Cloudflare embedding model, @cf/baai/bge-m3, which turns the text into a long list of numbers standing for what it is about. That list and your label go into the database, and one set of weights is nudged towards the call you just made. New comments are scored against those weights. Past 0.9 the comment is held for review with similar-to-spam as its reason, and below that the layer says nothing.

Only the first 4,000 characters of a comment are read. What gets stored alongside the label is the list of numbers rather than a second copy of the comment.

It does nothing for a while, and that is correct

Section titled “It does nothing for a while, and that is correct”

Thirty approvals and thirty spam decisions, counted separately, before the layer has an opinion about anything. Until then it abstains on every comment, makes no call to Cloudflare, and costs one database read.

Both counts have to clear thirty on their own. A site with three hundred spam decisions and four approvals knows what spam looks like and has no idea what a good comment looks like, so it stays quiet. A model fitted on four examples would be reproducing whichever four comments happened to arrive first, which on a new blog is arbitrary rather than representative.

The Setup tab in your dashboard says where you are. It reads Learning, which is neither On nor Off, and names how many more decisions of each kind it is waiting for rather than making you subtract two numbers. If you moderate a handful of comments a week, expect months before it speaks. That is the design working.

The Setup tab carries one date: when the model last learned something. It is the only symptom a stalled trainer has. Nothing else changes when training starts failing. Decisions still succeed, the queue still moves, and the counts simply stop.

So if that date stops moving while you are still approving and marking comments, the decisions are reaching the queue and not the model. Your Worker’s log has the reason. Every decision that failed to train writes a line carrying event: "classifier_training" with the outcome and the comment’s id, which is the string to search for.

What it learns is your judgement, so it inherits your inconsistencies with it. Two similar comments you called differently on two different mornings are two contradictory examples, and the model averages them rather than resolving them.

It is not a general spam model. It has read no corpus, has no notion of what spam looks like on anybody else’s site, and cannot be shared or seeded from one. A site about pharmaceuticals will teach it something a site about bicycles would not, which is the point of training it locally, and it means the layer is only ever as good as the queue behind it.

Approve and spam, and only those two, and only on the comment you actually clicked.

Deleting a comment teaches it nothing. Comments get deleted for being off-topic, or unkind, or because their author asked, and filing all of that under spam would teach the model that unwanted and spam are the same word. Putting a comment back to pending is the absence of a decision rather than a decision.

Marking a comment spam also moves the replies underneath it, and those replies never become examples. Nobody read them. They are disproportionately good comments, since somebody engaged enough to answer wrote them, so a model fed on them would learn the opposite of the lesson intended. Only the comment you were looking at is used.

Pressing the same key twice on the same comment trains nothing the second time. Changing your mind does train, because that is you correcting the record.

It can hold a comment and cannot refuse one

Section titled “It can hold a comment and cannot refuse one”

This is a limit on purpose rather than a gap. A false positive on one of the rule layers is a bug you can find and fix. A false positive here would be a real person’s comment destroyed by a number nobody can inspect, so the strongest thing the layer can do is put the comment in front of you.

There is a second reason, and it is the one that is easy to miss. A refused comment is never stored, so it can never be labelled. A classifier allowed to refuse would be deleting its own training data, and the examples it deleted would be exactly the ones it felt most sure about, which is the part of your history it could never be corrected on.

The score is a raw number out of an uncalibrated model, not a probability, and reading it as one would be reading it wrong. The threshold sits high because a high threshold makes the layer speak rarely, and nobody has measured how often it is right. There is no held-out data on your site to measure it against.

What a wrong hold costs you depends on one setting. If comments are held for review by default, which is how a fresh deployment behaves, the answer is nothing at all: the comment was going to your queue regardless, and the classifier only added a word to it. If you have switched to trusting returning commenters, a hold is what stops one of them being published straight away, so a false positive delays a real person’s comment until you next open the queue.

You cannot move the threshold. It is a constant in the source, along with every other number on this page.

The reason string is a verdict, not an argument

Section titled “The reason string is a verdict, not an argument”

classifier: similar-to-spam means the comment resembled the spam this site has been shown. It does not say which comments, or which part of the text, or how strongly, and none of that is recoverable from what is stored. You still have to read the comment. The reason is a hint about where to look first in a long queue, and it is worth exactly that much.

The embedding runs on Workers AI, on your own Cloudflare account, which is the account already running your Worker and holding your comments. No third party sees anything. It is not the same as never leaving the Worker though, and that is why it is written down rather than glossed: the text of a comment does go to a Cloudflare model, once when you moderate it and once more if a similar-looking comment arrives later.

Cloudflare’s free plan allows 10,000 Workers AI neurons a day, and this model costs around 1,075 neurons per million tokens of input, so ordinary comment traffic is nowhere near it. Spend the allowance anyway and every embedding fails for the rest of the day, which means the layer abstains and nothing else changes.

A deployment with no Workers AI binding abstains permanently. The one-click deploy provisions the binding for you, so this is the unusual case. The Setup tab says so outright and gives the click path that adds one, and your Worker’s log gets one line, once, saying the layer is inactive.

If Cloudflare changes the embedding model’s output, or the model id changes under you, the fitted weights stop meaning anything and are thrown away rather than reinterpreted. The site is untrained again and has to see thirty fresh decisions in each class. That is loud in your Worker’s log, naming how many of each it discarded, because a reset nobody is told about is indistinguishable from a classifier that never worked.

Off unless you set both AKISMET_API_KEY and CHARCHA_SITE_URL. Akismet is the only provider built.

It runs last, sees only what the seven layers ahead of it could not decide, and is skipped for a comment something else has already held. That skip is about money. Akismet’s Pro plan allows 500 checks a month, and an anonymous visitor should not be able to spend them on answers nobody reads.

Like layer 7 it can only hold a comment, never refuse one. Akismet distinguishes ordinary spam from the blatant kind, and Charcha keeps that distinction in the reason rather than in the verdict: akismet and akismet-discard both land in your queue, and the second tells you Akismet was not in two minds. Every failure on its side is an abstention rather than a held comment.

This is the one layer that can vouch. Akismet answering “ham” is a real verdict from a real classifier rather than the absence of an objection, so Charcha records it as positive evidence.

By default that changes nothing at all: the comment goes to your queue like everything else. It matters only if you switch your moderation policy to trust-vouched, which publishes a vouched comment straight away. Even then a vouch loses to any doubt from any layer, so nothing the seven layers ahead flagged can be published this way.

The property that makes that policy safe to offer is that it fails closed. A provider that is off, that errored, or that answered “unknown” produces silence rather than a vouch, and this policy does not act on silence. So on the deployment most likely to reach for it, the one with nothing configured, it publishes nothing whatsoever. Moderation covers choosing it.

This is the one layer that tells a company that is not you about your readers, which is a disclosure you take on the moment you enable it. Third-party spam checking lists every field that is sent, has a paragraph you can paste into a privacy notice, and covers switching it back off.

A comment nothing rejected reaches your queue, and the last layer is a person reading it. The eight above exist to make that queue short rather than to empty it. Moderation is where the work happens, and Not built yet has what these layers still cannot do.