An SEO plugin is a high-value target: it holds Google OAuth tokens, AI API keys, and write access to every page’s markup. Project Spark was engineered on the assumption that it would be attacked, by malicious requests, by compromised accounts, and by its own future bugs. Here’s the defensive architecture, at the published-standards level.
Key takeaway: Plugin security is a checklist discipline, not a feature: encrypt every stored secret with authenticated encryption, gate every endpoint with both a request token and a capability check, validate every outbound URL, and escape every output, with tests enforcing each rule so they survive future development.
The threat model: three attackers, one of them is you
Before any control, a threat model. Project Spark assumes three adversaries:
- The external attacker sending forged requests: cross-site request forgery against logged-in administrators, direct calls to endpoints hoping for missing authorization, hostile URLs fed to any feature that fetches them.
- The compromised or over-privileged account: a contributor-level login (phished or malicious) trying to reach data and operations above its station, other authors’ drafts, site-wide settings, stored credentials.
- The future defect, the adversary most teams forget. A refactor that drops a check, a new feature that reuses a write path without its guard, a settings page that overwrites a real key with its own masked placeholder. Over hundreds of releases, entropy attacks the codebase from inside.
Every control below maps to at least one of these, and the final section, security as regression tests, exists specifically for the third.
Secrets: authenticated encryption with context binding
Every stored credential, AI keys, OAuth tokens, service keys, is encrypted with AES-256-GCM, an authenticated mode that detects tampering, with additional authenticated data (AAD) binding each ciphertext to its storage slot. That last detail matters: without context binding, a ciphertext copied from one option to another would decrypt happily; with it, a value only decrypts in the slot it was encrypted for. A database leak or a partial export cannot be replayed across slots, and a tampered ciphertext fails authentication rather than decrypting to garbage that gets used.
Why encrypt at all, when the database is already access-controlled? Because WordPress databases leak through many doors that are not the front one: backup files on misconfigured storage, staging copies handed to contractors, SQL exports attached to support tickets, other plugins with injection flaws. Plaintext API keys in the options table are readable through every one of those doors. Encrypted-and-bound keys are not.
The human-facing half is just as important. Dashboards show masked previews (bullets plus last four characters), enough for an administrator to recognize which key is configured, never enough to exfiltrate one by looking over a shoulder or screensharing. And save-handlers detect the masked form so re-saving a settings page can never overwrite a real key with its own mask, a real bug class we found, fixed, and pinned with tests. It is a perfect specimen of adversary #3: no attacker involved, just a settings form quietly destroying a credential because nobody asked what happens when the display value is submitted back.
Two supporting details: encrypted blobs are excluded from WordPress’s autoloaded options (they are needed rarely, and autoloading them costs every request, Part 16), and legacy encryption formats from earlier releases migrate forward automatically on upgrade, so old installs converge to the current scheme without manual intervention.
Every endpoint gets three gates
The plugin exposes 170+ endpoints across AJAX, REST, and CLI. Each AJAX endpoint enforces, without exception: a nonce (request-forgery token), a capability check, and rate limiting scoped per action per user. REST routes carry equivalent permission callbacks. Self-syncing tests parse the registration code and verify no endpoint ships ungated.
The capability check deserves its own paragraph, because “checks a capability” hides the defect that actually bites: checking the wrong scope of capability. Several endpoints originally verified that a user could edit posts in general, then accepted any post ID in the request, meaning any author could run analysis against other authors’ unpublished drafts. The correct check is per-object: “may this user edit this specific post.” A security audit closed that insecure-direct-object-reference class across every post-scoped endpoint, and the per-post pattern is now the standard, with tests exercising the full role matrix (administrator, editor, author-on-own, author-on-others, subscriber, anonymous) so the distinction cannot quietly regress.
Rate limiting has a usability lesson embedded in it: the limiter is scoped per action, not per user globally. An early global budget meant a busy administrator’s normal dashboard use could exhaust the allowance and lock them out of an unrelated feature, a user-reported failure that taught us that security controls which punish legitimate use get disabled, and disabled controls protect nobody.
OAuth done properly
Google integrations use OAuth 2.0 with PKCE (S256), proof-key exchange that defeats intercepted-code attacks, plus a state token stored server-side, validated on callback for CSRF protection, and an open-redirect guard on post-auth return URLs (an attacker-supplied external URL is rejected, so the OAuth flow cannot be used to bounce victims to hostile sites). Search Console access requests a read-only scope at the protocol level, the token itself cannot modify anything, no matter what any future code does with it. Business Profile write-incapability is enforced in code, with tests asserting the write methods do not exist (Part 10). Every auth event lands in an audit log, so connects, disconnects, and failures leave a trail an administrator can actually read.
The principle underneath: request the least power that works, at the lowest layer available. A read-only scope beats a read-only policy; a deleted method beats a disabled one.
The quieter defenses
- SSRF guards: every feature that fetches a user-supplied URL (schema testers, social-card debuggers, performance checks) verifies the URL is on the site’s own host before any request, otherwise the server becomes a proxy for probing internal networks or burning API quotas on third parties. The rejection sets are tested with ten hostile URL shapes per endpoint: external hosts, traversal attempts, case tricks, injected paths, whitespace prefixes.
- Output escaping everywhere, including JSON-LD hex-escaping (Part 11), URL scheme allowlisting on the JavaScript side (
javascript:links become#, with control-character prefixes stripped before scheme detection to defeat obfuscation), and SVG sanitization for uploaded favicons, a 15-vector filter covering scripts, event handlers, and hostile URI schemes, because an SVG is a document that can execute, not an image. - Zero runtime dependencies (Part 3): no third-party code, no inherited CVEs, no supply-chain surface. The dependency you do not ship is the vulnerability you never patch.
- CSP-clean admin: no inline scripts, no inline styles, no inline event handlers, so a strict Content-Security-Policy can be deployed. Structural tests fail CI if anyone reintroduces one, which converts a painstaking one-time cleanup into a permanent property.
Hardening the human layer
A control that confuses administrators gets worked around, and a workaround is a vulnerability with good intentions. Several of the plugin’s security decisions are really usability decisions wearing armour:
- Masked previews instead of hidden keys. Showing bullets plus the last four characters lets an administrator confirm which credential is configured without exposing it. Fully hidden keys push people into re-pasting them from chat logs and text files, the exact behaviour key hygiene is supposed to prevent.
- Pre-flight checks instead of dead buttons. Every AI-powered surface verifies its key is configured before rendering its controls, so users meet a clear “configure this first” state rather than a button that fails cryptically, and rather than error messages that might echo sensitive configuration back to the screen.
- An audit log written for reading. OAuth connects and disconnects, sync events, blocked operations: each entry answers who, what, and when in plain language. Security logs that only a developer can parse are write-only storage; this one is a dashboard surface the client can actually consult when something looks odd.
- Failure messages that state the next action, “reconnect the integration”, “your quota resets shortly”, instead of leaking internals or, worse, teaching users that errors are background noise to click past.
None of this appears in a penetration-test report, but it determines whether the controls survive contact with real staff. Security that people can live with is security that stays enabled.
Security as regression tests
The pattern behind all of it: every security property is pinned by a test. Capability matrices per role, masked-key passthrough, SSRF rejection sets, XSS payload containment (tests inject actual script-breakout payloads and assert exactly one script element survives), encryption round-trips that must fail when the storage context is wrong. Security that lives only in code review erodes; security that lives in CI survives every future release, including the several hundred releases this project shipped after the controls were written, any one of which could otherwise have loosened a gate unnoticed.
If you commission plugins rather than build them, this section converts directly into vendor questions: How are stored credentials encrypted, and what binds them to their storage location? Which capability, exactly, does each endpoint check, and is it per-object? What happens when I save the settings page without touching the key field? Show me the test that fails if an endpoint ships without authorization. A vendor with answers has a security practice; a vendor without them has security intentions.
Next: Part 14, 4,700+ Tests: How Testing Culture Found Real Bugs
FAQ
How should a WordPress plugin store API keys?
Encrypted with authenticated encryption (AES-256-GCM), bound to their storage context via AAD, never rendered back to the browser in full, masked previews only, and excluded from autoloaded options.
What is PKCE and why does OAuth need it?
PKCE (Proof Key for Code Exchange) binds an OAuth authorization code to the client that requested it, so an intercepted code is useless. It’s the modern baseline for OAuth flows.
What security checks does every AJAX endpoint need?
Three minimum: a nonce to prevent request forgery, a capability check (per-object where relevant) for authorization, and rate limiting. Enforce all three with automated tests.
What is SSRF and why do SEO tools risk it?
Server-Side Request Forgery, tricking a server into fetching attacker-chosen URLs. SEO tools that fetch and analyze URLs must restrict targets to the site’s own host.
How do I know if a WordPress plugin is secure?
Ask what it encrypts, which capability each endpoint checks, and whether security properties are covered by automated tests. Masked key displays, per-object permission checks, and read-only API scopes are strong signals.
Can a WordPress plugin leak my API keys?
Yes, through plaintext storage read by backups, exports, or vulnerable plugins, or by rendering full keys into admin pages. Encryption with context binding and masked previews closes both paths.





