Abuja Digital Studio · Est. 2018
Start a Project
Docs
Orravo Spam Shield
Invisible spam protection for WordPress forms — honeypot, timing analysis, rate limiting, geo blocking, disposable email detection, and IP/email/keyword blocklists. Works with CF7, WPForms, Gravity Forms, and OForms.
orravo-spam-shield
Developer documentation

Orravo Spam Shield v1.0.0

Invisible spam protection for WordPress forms: honeypot fields, timing analysis, rate limiting, geo blocking, disposable email detection, and IP, email, and keyword blocklists. Works with Contact Form 7, WPForms, Gravity Forms, OForms, and Fluent Forms; no CAPTCHA, no SaaS dependency.

WordPress plugin 6 defense layers 5 form integrations v1.0.0 · 2026-05-15
01 · Overview

What Spam Shield does

CAPTCHA hurts conversion. Akismet sends every submission to a third party. Spam Shield does neither. It runs six invisible checks on every form submission, scores the result, and accepts, holds, or rejects based on your threshold, with zero friction for real humans.

Honeypot
A hidden field added to every form. Bots fill it; humans do not. Detection is instant and unconditional.
Timing analysis
Form rendered-at and submitted-at timestamps are HMAC-signed. Submissions under 1.5s or over 90 min are flagged.
Rate limiting
Per-IP submission caps, per-form caps, sliding-window counters in oss_rate_log. Burst control with cool-down.
Geo blocking
Country-level allow / deny lists using a bundled MaxMind GeoLite2 database. No external API call required.
Disposable email
Auto-updated list of disposable / throwaway domains (mailinator, tempmail, etc.). Refreshed daily via cron.
Blocklists
Manual or auto-populated lists: IPs (with CIDR), email addresses (with wildcards), keyword regex matched against the body.
5 form integrations
Contact Form 7, WPForms, Gravity Forms, OForms, Fluent Forms. Drop-in; no per-form configuration required.
Activity log
Every decision is logged in oss_log with the per-layer score breakdown. Re-classify false positives from the inbox.
Spam Shield runs entirely inside WordPress. No data leaves your server, no external API calls, no per-request quotas. The bundled GeoLite2 database can be opted out of for the smallest install footprint.
02 · Installation

Getting installed

Requirements

  • WordPress 6.0 or newer
  • PHP 8.0 or newer with the mbstring extension
  • One of: Contact Form 7, WPForms, Gravity Forms, OForms, Fluent Forms (or no form plugin at all if you only need the REST API)
  • Optional: iconv for the disposable-domain Punycode normaliser

From WordPress admin

1
Go to Plugins → Add New → Upload Plugin.
2
Upload orravo-spam-shield.zip, click Install Now, then Activate.
3
Visit Spam Shield → Dashboard. The plugin is already enforcing defaults; no further configuration is required.
Activation creates three custom tables: oss_log (every submission decision), oss_rate_log (sliding-window rate counters), and oss_blocklist (IPs, emails, keywords). It also schedules a daily cron event (oss_refresh_disposable_domains) to keep the throwaway-email list up to date.
03 · Quick Start

Up and running in minutes

Defaults are sane. The plugin starts protecting forms the moment you activate it. The quick-start path is about confirming what is on and tuning the score threshold.

1
Visit Spam Shield → Layers. All six defense layers (honeypot, timing, rate, geo, disposable email, blocklists) are enabled by default. Leave them on.
2
Visit Spam Shield → Scoring. The default Spam threshold is 0.6 and the Block threshold is 0.85. Submissions above Block are rejected, between Spam and Block are flagged for review, below Spam are accepted silently.
3
Submit a test entry through any active form. Visit Spam Shield → Activity; you should see your submission classified as Clean with each layer's individual score.
4
Over the next week, sort the Activity log by Score. Anything above 0.6 should be obvious spam. If you see false positives, mark them Not spam; the row's score components feed into the per-layer tuning recommendations.
04 · Defense Layers

Defense layers

Each layer is independent. Each contributes a fractional score (0 to 1). The submission's total score is a weighted sum; weights are configurable.

The six layers

LayerDefault weightTrigger
Honeypot1.00Hidden oss_hp field is non-empty. Score 1.0, single-handedly trips Block.
Timing0.30HMAC-signed rendered-at timestamp shows the form was submitted in under 1.5s or after 90 minutes.
Rate0.20Same IP has submitted more than burst_limit times in the sliding window. Window is 5 minutes by default.
Geo0.40Submitter IP resolves to a country on the deny list (default off; you opt in to a country deny list).
Disposable email0.50Email's domain matches the disposable-domain list.
Blocklists1.00 eachIP, email, or keyword match. Any single blocklist match is treated as a hard block (score 1.0).

Honeypot

Spam Shield injects a single hidden field, oss_hp, into every supported form. The field is wrapped in a position:absolute;left:-9999px div and labelled Leave this field empty (configurable). Bots fill it because they fill every input. Real users never see it.

If JavaScript is enabled, the field is additionally cleared on submit so even an autofill manager that pre-fills it will be neutralised. The server-side check still wins: a non-empty value is always a 1.0 honeypot score.

Timing

PHP// Hidden field added to each form on render
<input type="hidden" name="oss_ts"
       value="1748716800.eyJ0Ijox..." />

// Server-side check on submit:
//   1. Decode payload, verify HMAC signature
//   2. Compute now() - rendered_at
//   3. If delta < min_seconds (default 1.5)  -> bot-like
//      If delta > max_seconds (default 5400) -> stale form
//   4. Otherwise: 0.0 contribution

Rate limiting

Implemented as a sliding-window counter in oss_rate_log. Two buckets are maintained per IP: one for the last 5 minutes (burst), one for the last hour (sustained). Burst limit defaults to 5; sustained limit defaults to 30. Once a limit is exceeded, the score contribution goes to 1.0 and the offending IP receives a 60-second cool-down (configurable).

PHP// Read current bucket counts
[ $burst, $sustained ] = OSS_Rate::counts_for_ip( '203.0.113.5' );

// Programmatic reset
OSS_Rate::reset( '203.0.113.5' );

Geo blocking

Spam Shield ships with the MaxMind GeoLite2 Country database (CC BY-SA 4.0). IP-to-country resolution happens in PHP via the bundled geoip2/geoip2 reader. No external API call. The database is refreshed monthly via the oss_refresh_geoip cron event (defaults off; opt in if you want auto-updates).

Two modes: deny list (default; block specific countries) or allow list (only listed countries can submit; everything else blocked). Allow list mode is for forms that should only accept submissions from a single country or region.

Disposable email

The disposable-domain list lives in oss_disposable_domains (~12,000 entries). It is refreshed daily from a community-maintained JSON feed; you can pin to a specific revision or disable auto-refresh entirely. The email's domain is normalised (Punycode-decoded, lower-cased, MX-less subdomains trimmed) before lookup.

PHP// Programmatic check
if ( OSS_Disposable::is_disposable( 'foo@10minutemail.com' ) ) {
    // ...
}

// Add or remove a domain
OSS_Disposable::add( 'tempbox.net' );
OSS_Disposable::remove( 'mailinator.com' );
05 · Scoring & Decisions

Scoring & decisions

Every submission is reduced to a single score in [0, 1]. Two thresholds split the score into three actions.

Decision tree

  • 1Run every enabled layer. Each returns a partial_score in [0, 1] and a reason string.
  • 2Compute total = sum(partial * weight) / sum(weight_of_triggered_layers). Untriggered layers do not dilute.
  • 3If total >= block_threshold (default 0.85): Block. The form returns a generic validation error; the entry is not stored.
  • 4If total >= spam_threshold (default 0.6): Spam. The submission is stored but flagged; notifications can be suppressed.
  • 5Otherwise: Clean. The submission flows through to the form plugin normally.
  • 6The decision and per-layer scores are written to oss_log.

Decision actions

OutcomeWhat happens
CleanSubmission proceeds. No visible difference for the user. Logged with decision='clean'.
SpamSubmission is stored if the form plugin supports a Spam folder (CF7 Flamingo, WPForms, Gravity Forms). Notification emails are suppressed by default. Logged with decision='spam'.
BlockSubmission is rejected before the form plugin sees it. The user sees a generic validation error. Logged with decision='block'.

Layer weights

Weights are configurable under Spam Shield → Scoring → Weights. Setting a layer's weight to 0 effectively disables its contribution while keeping the layer running (useful for evaluating without blocking).

PHP// Filter weights at runtime
add_filter( 'oss_layer_weights', function( array $w ): array {
    $w['rate']  = 0.50;   // be aggressive about repeated submissions
    $w['geo']   = 0.10;   // be soft about geo (we run global outreach)
    return $w;
} );

// Filter the final decision (last word)
add_filter( 'oss_decision', function( string $decision, array $context ): string {
    // Always trust an authenticated user with edit_posts
    if ( is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
        return 'clean';
    }
    return $decision;
}, 10, 2 );
06 · Form Integrations

Form integrations

Five integrations ship in the box. Each one is auto-detected; you do not toggle them on per form. Disable a specific integration under Spam Shield → Integrations if you do not want Shield to touch that form plugin.

Built-in

wpcf7_validate · priority 9
Contact Form 7
Honeypot and timing fields are injected via wpcf7_form_elements. Validation runs on wpcf7_validate before CF7's own.
wpforms_process_before
WPForms
Fields injected via the form HTML filter. Scored on wpforms_process_before; spam entries land in WPForms' built-in Spam folder.
gform_validation
Gravity Forms
Fields injected with gform_get_form_filter. Scored on gform_validation; spam status set via gform_entry_post_save.
oforms_pre_submit
OForms
Native integration. Spam Shield is auto-detected; OForms exposes a "Protected by Spam Shield" badge in the form admin.
fluentform_before_insert
Fluent Forms
Fields injected via the form-render filter. Scored on fluentform_before_insert_submission.

Custom forms

PHP// Pipe a custom form through Spam Shield
$decision = OSS_Score::evaluate([
    'form_id'  => 'my-custom-form',
    'fields'   => $_POST,        // raw input
    'email'    => $_POST['email'] ?? '',
    'ip'       => OSS_Helpers::client_ip(),
    'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
    'request_uri'=> $_SERVER['REQUEST_URI'] ?? '',
]);

if ( $decision['decision'] === 'block' ) {
    wp_die( 'Submission blocked.', '', [ 'response' => 403 ] );
}

if ( $decision['decision'] === 'spam' ) {
    // Quietly silence notifications; still store the entry
    $is_spam = true;
}

// Log it (Spam Shield logs automatically when called via integrations;
// log manually for custom forms)
OSS_Log::insert( $decision );
07 · Blocklists

Blocklists (IP, email, keyword)

Three blocklist types. Any single match is a hard block (score 1.0). Lists are stored in oss_blocklist, one row per entry, indexed by type.

Supported entry types

TypeFormatExample
ipSingle IPv4 / IPv6 or CIDR range203.0.113.5, 198.51.100.0/24, 2001:db8::/32
emailExact address or wildcard at the startspam@example.com, *@bad-domain.com
keywordSubstring (case-insensitive) or regex (when wrapped in /.../)buy viagra, /^[A-Z]{10,}$/

Auto-population

After three Block decisions for the same IP within an hour, Spam Shield can auto-add that IP to the IP blocklist with a 24-hour TTL. Disable auto-population under Blocklists → Behaviour if you want full manual control. Auto-added rows are labelled with source='auto' and a reason string so they are distinguishable from manual entries.

PHP// Programmatic
OSS_Blocklist::add( 'ip',       '198.51.100.0/24', [ 'source' => 'manual', 'note' => 'sustained scraping' ] );
OSS_Blocklist::add( 'email',    '*@throwaway.example' );
OSS_Blocklist::add( 'keyword',  '/(viagra|cialis|tramadol)/i' );

// Add with TTL
OSS_Blocklist::add( 'ip', '203.0.113.5', [ 'expires_at' => time() + DAY_IN_SECONDS ] );

// Check
$blocked = OSS_Blocklist::matches([
    'ip'    => '203.0.113.5',
    'email' => 'foo@example.com',
    'body'  => 'message body to scan for keyword matches',
]); // returns [ 'matched' => bool, 'type' => ..., 'rule' => ... ]
08 · REST API & Filters

REST API & filters

All endpoints live under /wp-json/orravo-spam/v1/. Every endpoint requires manage_options; there are no public endpoints.

Endpoints

MethodPathPurpose
GET/logPaginated activity log; filter by decision, form_id, date range.
POST/log/{id}/reclassifyMark a row as clean or spam; updates the row and trains the heuristic.
DELETE/log/{id}Delete a log row.
GET/blocklist?type=ipRead blocklist; filter by type.
POST/blocklistAdd a blocklist entry.
DELETE/blocklist/{id}Remove an entry.
POST/testRun a payload through the scoring engine without logging; returns the full breakdown.
POST/disposable/refreshForce a refresh of the disposable-domain list.

Test a payload

cURLcurl -X POST https://example.com/wp-json/orravo-spam/v1/test \
  -H 'Content-Type: application/json' \
  -H 'X-WP-Nonce: <rest-nonce>' \
  -d '{
    "email":      "foo@10minutemail.com",
    "ip":         "203.0.113.5",
    "body":       "Buy viagra cheap online click here",
    "user_agent": "Mozilla/5.0",
    "rendered_at":1748716800
  }'

# Response
{
  "decision":  "block",
  "score":     0.92,
  "layers": {
    "honeypot":   { "score": 0.0,  "reason": "ok" },
    "timing":     { "score": 0.3,  "reason": "submitted in 0.4s" },
    "rate":       { "score": 0.0,  "reason": "ok" },
    "geo":        { "score": 0.0,  "reason": "ok" },
    "disposable": { "score": 1.0,  "reason": "10minutemail.com" },
    "blocklist":  { "score": 1.0,  "reason": "keyword: viagra" }
  }
}

Filter reference

FilterPurpose
oss_layer_weightsAdjust per-layer weights at runtime (return modified array).
oss_spam_thresholdOverride Spam threshold for this request (return float in [0, 1]).
oss_block_thresholdOverride Block threshold for this request.
oss_decisionLast-word override on the final decision string.
oss_should_scoreReturn false to skip scoring entirely for a given form / context.
oss_client_ipOverride the resolved client IP (useful behind a reverse proxy not handled by core).
oss_disposable_domainsModify the disposable-domain list at runtime.
oss_geo_countryOverride resolved country for an IP (useful in tests).
09 · FAQ

Frequently asked questions

Does Spam Shield replace Akismet?
For form submissions, yes. Akismet sends every payload to a third-party API; Spam Shield runs entirely in PHP on your server. For WordPress comments specifically, Akismet's reputation database can complement Spam Shield's local heuristics. Spam Shield does not currently filter the native WordPress comments stream; comment hardening is on the roadmap.
Does it require a CAPTCHA?
No. Spam Shield is invisible to humans. There is no challenge, no checkbox, no image puzzle. Honeypot and timing analysis catch bots without friction. If you still want CAPTCHA on a specific high-risk form, leave your existing CAPTCHA plugin in place; Spam Shield runs in parallel and does not conflict.
What about visitor IP behind Cloudflare or a load balancer?
Spam Shield reads the IP via OSS_Helpers::client_ip(), which checks CF-Connecting-IP, X-Real-IP, and X-Forwarded-For in that order, validates against your configured proxy allowlist, and falls back to REMOTE_ADDR. Set the allowlist under Settings → Trusted Proxies so spoofed headers from untrusted hops are ignored.
How is the disposable-domain list kept current?
A daily cron event (oss_refresh_disposable_domains) fetches a community-maintained JSON feed and merges new entries. You can pin to a specific revision via Settings → Disposable → Pin version, or disable auto-refresh entirely. Manual adds always win against the auto list.
Does the GeoIP database call out to a third-party service?
No. The bundled GeoLite2 Country database (CC BY-SA 4.0) ships inside the plugin zip. Lookups happen in-process via the geoip2/geoip2 reader. To keep the database fresh, opt into the monthly oss_refresh_geoip cron event under Settings → Geo; it downloads the latest MaxMind release. You can also disable Geo entirely to slim the install.
What happens to false positives?
Open Spam Shield → Activity, find the row, click Not spam. Spam Shield re-classifies the row, increments a per-layer correction counter, and surfaces a "Recommended tuning" panel after a handful of corrections accumulate (for example: "Your timing layer is over-triggering; consider raising min_seconds to 2.5").
Will Spam Shield log GDPR-sensitive data?
By default, the activity log stores the IP, user agent, email (lower-cased), and the first 500 chars of the body. IP is stored raw to support CIDR matching; switch to hash IPs under Settings → Privacy for SHA-256 storage. Body capture can be disabled entirely; you keep the decision and per-layer scores without storing the payload.
Can I run Spam Shield on a multi-vendor or membership site?
Yes. The oss_should_score filter lets you skip scoring for authenticated users with a specific role or capability. The Activity log indexes by form_id, so you can pivot on which forms produce the most spam and tune layers per form via the oss_layer_weights filter.
Will deactivating remove my blocklists?
No. Deactivation only stops scoring. The custom tables (activity log, rate log, blocklist) and the disposable-domain list all remain. Only deleting the plugin runs uninstall.php, which drops all three tables.
How much does Spam Shield add to a form submission?
A typical scored submission takes 2 to 6 milliseconds extra: one indexed SELECT against the rate log, one GeoIP read (memory-mapped file), and the in-PHP scoring. No external HTTP calls, no DNS lookups, no CAPTCHA verification round-trips.
10 · Changelog

What's changed

v1.0.0 2026-05-15
  • NewSix defense layers: honeypot, timing, rate, geo, disposable email, blocklists
  • NewWeighted scoring with Spam and Block thresholds
  • NewHMAC-signed timing field; configurable min and max seconds
  • NewSliding-window rate limiter with per-IP burst and sustained buckets
  • NewBundled MaxMind GeoLite2 Country DB; optional monthly auto-refresh
  • NewDisposable-email list with daily auto-refresh and Punycode normalisation
  • NewThree blocklist types: IP (with CIDR), email (with wildcards), keyword (with regex)
  • NewAuto-population of IP blocklist with configurable TTL
  • NewFive form integrations: CF7, WPForms, Gravity Forms, OForms, Fluent Forms
  • NewActivity log with per-layer score breakdown and reclassification UI
  • NewReverse-proxy-aware client-IP resolver with trusted-proxy allowlist
  • NewPrivacy toggle: IP hashing, body capture opt-out
  • NewREST API at /wp-json/orravo-spam/v1/ with a public-payload tester
  • NewFilter hooks for weights, thresholds, decisions, and disposable domains
  • NewThree custom DB tables with full uninstall.php cleanup
✦ Need help?

Got a question about Spam Shield?

Reach out directly. Kenneth replies within 24 hours.