Security whitepaper

Everything we claim,
enforced in code.

Portcullis holds the keys to other people’s WordPress fleets, so the burden of proof is ours. This is the whole security picture in one document — the threat model, a request traced end to end, what we store and for how long, who else can see it, how keys are managed, what happens during an incident, and an honest list of what we don’t have yet. Written for the person whose job is to not believe us until they’ve checked.

Version 1.2 — last reviewed 5 August 2026

01 — Summary

The architecture, in five sentences.

Portcullis is a broker between an AI client and a fleet of WordPress sites. Every connected site runs a small companion plugin that acts as a pure resource server: it holds no credential, only Portcullis’s public keys.

To reach a site, the broker signs a short-lived token bound to that one site and to that one request. The plugin validates it offline against the public keys and runs the action as a narrowly-scoped WordPress user. Nothing the AI client sends ever reaches WordPress, and nothing we store can be replayed against your fleet.

The one-line version

There is no WordPress credential anywhere in this system — not on our side, not on yours. That single design decision is what most of the rest of this document is downstream of.

02 — Threat model

What we defend against — and what we don’t.

Defended, in code

  • Credential theft. Companion sites store no WordPress credential — only public keys. There’s nothing on the site, or with us, to steal and replay.
  • Token replay. Leg tokens live ~60 seconds and are DPoP-bound to a key that never leaves the request. A captured token is inert.
  • Cross-tenant access. Every stored read runs through one chokepoint, predicated on both org and site. A wrong tenant matches no row and fails closed — and there's no credential in the row to reach for anyway.
  • Auth-code replay. A Durable Object claims each authorization code exactly once; a replayed code fails closed, even under concurrent exchange.
  • Client-token passthrough. Your MCP client’s token never reaches WordPress. The WordPress leg is a separate, broker-signed token.
  • Privilege escalation via the assistant. The set of permitted abilities is a human-only surface. Tokens that manage it carry no abilities; tokens that run abilities can’t touch it.
  • Server-side request forgery. Site URLs are validated at enrollment (HTTPS, public host, internal ranges and encoded-IP tricks refused), and the broker only ever calls the stored URL — never one from a request.

What we’re plain about

  • AI reads what you point it at. An assistant can only act on content it can read, and that content is processed by the AI provider running it. We scope and minimize it; we don’t pretend it doesn’t happen. (Full disclosure →)
  • While we’re up, we mint tokens. The broker is the one that signs the WordPress leg. We hold no reusable secret and every token expires in about a minute — but the mitigation is “nothing worth stealing,” not “we can’t be reached.”
  • The CI grep-guard is a tripwire, not a proof. It fails the build on stray database access, but it’s porous against cleverly-built queries by design. The real guarantee is one reviewed module plus a cross-tenant test — not the grep.
  • A WordPress admin outranks us on their own site. Someone with full admin on a connected site can already do anything there. They can also edit the ability allowlist — deliberately, because the alternative grants them nothing they lacked.

03 — Request lifecycle

One request, traced end to end.

A companion-site call, from your AI to your WordPress and back. The client token stops at the broker; a separate leg token does the WordPress leg.

  1. 01

    Client → broker

    The client authenticates to Portcullis, not to WordPress

    Your MCP client authenticates over OAuth 2.1 with PKCE (S256 — plain and implicit are refused). Its access token stays at the broker. WordPress never sees it.

  2. 02

    Authorize

    The request is scoped to who is asking

    org and user come from the validated grant, never from client input. The requested site is re-checked live — org-owned ∩ user-allowed — before anything touches WordPress. A foreign site fails closed.

  3. 03

    Mint

    A separate, short-lived leg token is signed

    The broker signs an ES256 (P-256) JWT bound to that one site: aud = the site, scope = the single ability this call runs — not the whole allowlist — plus org + user attribution, a cnf.jkt DPoP binding, and a 60-second expiry. A distinct token from the client’s — no passthrough.

  4. 04

    Bind

    A one-time proof is attached to the call

    A fresh P-256 keypair is generated per request and never persisted. It signs a DPoP proof carrying the method, canonical URL, a timestamp, a jti, and the token hash — so a token captured in flight is inert without the matching key.

  5. 05

    Verify

    The site validates it offline — no callback to us

    The plugin checks the leg token against Portcullis’s public JWKS (cached ~1h, ES256 only), verifies aud and expiry, then confirms the DPoP key’s thumbprint equals the token’s cnf.jkt — compared in constant time.

  6. 06

    Run

    It executes as a limited, scoped user

    The action runs as a service user whose capabilities come from the enrolled allowlist — nothing more. That user has no usable password; it exists only to be authorized one request at a time.

04 — What’s stored where

The whole point, in one table.

The companion plugin is the architecture — the only connection mode, so the guarantee is unconditional. Here's how it compares to the conventional alternative: handing over a WordPress App Password.

Companion plugindesign C App Passwordthe usual way
WordPress credential held by the broker None — ever A long-lived password, stored
What the site itself stores Portcullis public keys only Nothing new — the password just works forever
What reaches WordPress on a call 60-second ES256 leg token + DPoP proof HTTP Basic with the full password
If the broker is breached Nothing forgeable — public keys can’t mint The stored password is the prize
Revocation Stop minting — nothing to rotate Rotate the password everywhere it’s used

05 — Data & retention

Everything we hold, and how long.

The full inventory — not a category summary. If it isn’t on this list, we don’t store it.

Data Where Retention
Account identity Your email, optional display name, organization name and timezone Application database Until you delete the organization
Connected site record Normalized site URL, the service-user login, the allowlist of permitted ability ids. No credential of any kind. Application database Until the site is disconnected
Site snapshot Page title, favicon URL, WordPress version, last-reached timestamp — parsed from the site’s public /wp-json/ root Application database Until the site is disconnected
Images Homepage screenshots, organization logos, user avatars — stored under owner-prefixed keys so no tenant can name another’s object Object storage Overwritten on refresh; deleted with the organization
Audit events Actor, action, site, ability, status. Sign-in events additionally record IP and a truncated user-agent string. Application database 12 months, pruned daily
Assistant conversations The dashboard chat transcript, including what you asked and what a tool returned Application database 90 days, pruned daily
Fleet job records The approved plan and each site’s outcome for a bulk or scheduled run Application database Until the organization is deleted — no automatic prune
OAuth clients, grants, tokens Registered MCP clients and their grants. Token values are stored only as SHA-256 hashes — a database dump yields nothing usable. Key-value store (OAuth library-owned) Until the grant is revoked
Billing Stripe customer and subscription ids, plan name, site cap. No card number, expiry, or CVC ever reaches Portcullis. Application database Until the organization is deleted
Sign-in links A SHA-256 hash of the emailed link — never the link itself Application database 15 minutes, single-use, purged a day past expiry
Second-factor enrolments If you enrol one: an authenticator seed, encrypted at rest under a key derived from the session secret; a passkey’s credential id, its PUBLIC key and signature counter — never a private key; and SHA-256 hashes of your recovery codes, never the codes. Application database Until you remove the factor or delete the organization
Client records If you group sites by the client they belong to: the client name you typed, an optional contact and internal notes, and which sites belong to it. Notes are never printed on a client report. Application database Until you delete the client or the organization
WordPress leg tokens The token that actually reaches your site Nowhere — minted per request, never written down ~60 seconds

Deletion

Disconnecting a site removes its record and any per-member assignments to it immediately; the broker stops minting for it the same moment. Deleting an organization cascades every row belonging to it — sites, snapshots, audit events, conversations, job history, billing, memberships — and removes its images from object storage.

Sessions

A dashboard session lasts 7 days in a cookie that is HTTP-only, secure, and scoped to the broker host alone — never shared with sibling subdomains. Signing out revokes live sessions rather than merely clearing the cookie.

Sign-in begins with an emailed link. A second factor — an authenticator code or a passkey — is available and per-user, not enforced org-wide; a passkey is deliberately a second factor and never an entry point on its own. Adding or removing a factor revokes every other live session for that account, because that is the moment someone acts on “my session may be copied”. A destructive governance action — disconnecting a site, editing an allowlist, deleting an organization, changing a member, or a fan-out past 25 sites — needs a factor proven again within the last five minutes, and that proof is refused if it is presented from a different network than it was earned on. Someone with no factor enrolled is refused those actions rather than waved through.

06 — Subprocessors

Who else can see it.

Named, with what each one actually receives. There are no others.

Processor What it does What it sees
Cloudflare All compute and storage — Workers, D1, Workers KV, R2, Durable Objects, Queues Everything in the data inventory, at rest and in transit
Cloudflare Workers AI Runs the optional dashboard assistant The sites you’ve granted it, the ability names on those sites, and the results of actions you run
Cloudflare Browser Rendering Captures the homepage screenshot on your fleet cards Your sites’ public homepages only — this path holds no token and never touches the signing key
Cloudflare Email Sending Sends sign-in links, team invitations, and scheduled digests The recipient’s email address and the message body
Stripe Subscriptions, checkout, and the billing portal Your billing contact and payment details, entered on Stripe’s own hosted pages

Not a subprocessor of ours

Your MCP client processes tool results through its own model provider, under that provider’s terms — not ours. Choosing which client to connect is choosing that provider. The full disclosure →

No trackers in the product

The dashboard loads no third-party analytics, advertising, or session-replay scripts — nothing watches you work, and no fourth party sees your fleet. This marketing site does run Google Analytics; that boundary is deliberate and detailed in the privacy policy.

07 — Key management

Two secrets, and how they rotate.

Because no WordPress credential exists, exactly two secrets can reach anything of yours: the key that signs the WordPress leg, and the key that signs your session. (We hold ordinary vendor API keys too — Stripe, email — but none of them touches a site or an account.)

Where they live

  • Only as platform secrets. Never in the database, never in a backup, never in source control. A database dump contains no key material at all.
  • Only the public half leaves. The signing key’s public counterpart is published as a JWKS so sites can verify offline. Nothing that can mint ever leaves the broker.
  • Separate per environment. Development, staging, and production never share a signing key.
  • Derived, not reused. Image capability URLs are signed with a key derived from the session secret rather than the secret itself, so a flaw in that path can’t expose the session key.

How rotation works

  • Versioned, with overlap. Keys are registered by version and published under distinct key ids, so old and new validate simultaneously during a rotation.
  • No flag day, no site visit. Sites refetch the key set when they see an unfamiliar key id. You don’t touch a single WordPress install to complete a rotation.
  • Written down in advance. Rotation and compromise procedures are runbooks that exist before they’re needed — a key rotation authored under incident pressure is how second incidents happen.

The honest caveat

Rotation is operator-run, not scheduled automation, and the key is held by the platform’s secret store rather than an HSM. It’s a documented capability we’ve rehearsed — not a cron job.

08 — Tenant isolation

One door in, and it checks two keys.

Every read and write of a site goes through a single module. Single-row reads are predicated on both the org id and the site id — never on a site id alone — and the org id is a required argument that comes only from the validated grant, never from a request body, URL, or header.

So even a correctly-guessed site id from the wrong tenant returns nothing: the query simply doesn’t match. Isolation isn’t a policy you trust — it’s the shape of the query.

The honest caveat

A CI check greps the codebase and fails the build if any raw site query appears outside that module. We keep it because tripwires catch mistakes — but its own header calls it porous against cleverly-built SQL. It is not the proof. The proof is that there’s one small module to review, and a cross-tenant test that fails if it regresses.

09 — Access control

Who inside your org can do what.

People

Three roles, hard gates

Organization admins, admins, and members. Governance moves are admin-exact: disconnecting a site, editing an allowlist, reading the audit log, billing, scheduled runs, and member management all refuse a plain member — not by hiding the button, but at the endpoint.

Scope

A member can be limited to part of the fleet

Restriction is an explicit mode, not an empty list — a restricted member with no assigned sites sees zero sites, not all of them. The failure direction was chosen deliberately.

Abilities

Off by default, gated twice

An action runs only if it’s in both the token’s scope and the site’s own local allowlist — and that scope is the single action being run, so a token minted to read can’t write. A WordPress admin removing an ability revokes it immediately — before we’ve even heard about it.

The assistant

It cannot widen its own permissions

Tokens that manage the allowlist carry a dedicated marker and no abilities; tokens that run abilities never carry the marker. The separation is structural, not a missing feature.

Writes

Least privilege, then a gate that varies

Each write maps to the narrowest WordPress capability that permits it — never an admin-equivalent one — and protected roles, options and cron hooks are refused outright by guards that ship with the plugin. What sits above that differs by entry point: the dashboard assistant normally shows a change for approval but may run one that plainly matches what you just asked; a schedule runs unattended on the plan approved when it was created; an MCP client runs within the scope granted at consent. The allowlist is the limit that holds in every case.

Record

Every dispatched run is logged

Actor, org, site, ability, and outcome — written at the one chokepoint every run passes through, so there’s no path that executes without leaving a row.

10 — Breach math

Pop the broker. Now what do you have?

Nothing to replay

No stored credential

On companion sites we hold no WordPress password — only public keys. There’s nothing on our servers that reaches your fleet.

Public half only

Verify, never mint

The signing key stays with us; only its public half ships. A site can check a request is genuinely ours — it can’t forge one of its own.

Blast radius

One site, ~60 seconds

Every access is one per-site token, cryptographically bound and already expiring. Revoking is “stop minting” — no secret to rotate across the fleet.

11 — Incident response

Written before it’s needed.

There is a standing incident procedure plus scenario runbooks for the failures that would actually matter here: signing-key compromise, session-secret compromise, OAuth compromise, a tenant-isolation breach, platform-account compromise, a destructive fleet action, data loss, and third-party compromise.

How we run one

  • One commander, separate operator. The person deciding is not the sole person touching production, and coordination moves to a channel the suspected attacker can’t reach.
  • Evidence before rollback. Logs, deploy versions, and tenant audit events are exported and hashed before anything that would erase them — timestamps in UTC, secrets never collected.
  • Contain smallest-first. Pause a queue, disable a route, revoke a grant, delete a site row, rotate one secret — before anything broad and destructive.
  • Correctness over uptime. If tenant isolation or the signing key is in doubt, mutation paths get disabled. We do not preserve availability at the risk of a cross-tenant write.
  • Recover in dependency order. Identity and control plane first, then deploy tokens, then application secrets — with negative tests proving the old path fails, reads re-enabled before writes.

What you’d hear from us

What happened, what data or actions were involved, the time window, what containment is complete, what you need to do, and when the next update arrives. Confirmed facts are labelled separately from likely ones and from unknowns. We don’t speculate, and we don’t make breach-notification claims ahead of counsel.

Report a vulnerability

Email hello@portculliswp.com with what you found and how to reproduce it. We’ll acknowledge it and keep you updated through the fix. There is no bug-bounty program today — we’d still rather hear from you than not.

12 — Crypto spec

The primitives, named.

No hand-rolled crypto. Standard constructions, versioned keys, and a rotation runbook behind them.

Purpose Primitive Detail
Leg-token signing ES256 (ECDSA P-256) aud-bound, 60s TTL, jti, versioned signing key
Proof-of-possession DPoP (RFC 9449) ephemeral P-256 key per call; cnf.jkt thumbprint (RFC 7638)
Client authentication OAuth 2.1 + PKCE S256 plain PKCE and implicit flow both refused
Auth-code spend-once SHA-256 + Durable Object CAS compare-and-swap KV can’t give; replays fail closed
Session + link signing HMAC-SHA256 server-side secret; emailed links stored as hashes only
Image capability URLs HMAC-SHA256 over an HKDF subkey derived from the session secret, bound to one exact object key
Public key distribution P-256 JWKS public halves only; validated offline by the site

Read the specs ↗ RFC 9449 DPoP RFC 7638 JWK Thumbprint RFC 7636 PKCE RFC 7519 JWT RFC 7517 JWK / JWKS RFC 5869 HKDF OAuth 2.1 IETF draft

13 — Limitations

What we don’t have.

The section most vendors leave out. If you find a gap here we didn’t disclose, nothing above it deserves your trust — so here they all are.

  • No third-party certification or pen test

    We hold no SOC 2, ISO 27001, or HIPAA attestation, and no external penetration test has been performed. Everything on this page is our own claim about our own code — which is exactly why the page tells you how to check it.

  • No data-residency choice

    Portcullis runs on Cloudflare’s global network. You cannot currently pin your data to a particular region.

  • The audit trail is durable, not guaranteed

    Audit writes go to a retried queue with a dead-letter queue behind it, so a transient database failure doesn’t drop a row. A sustained one still can. It is also not a physically separate append-only store — an operator with database access could alter it.

  • Key rotation is a runbook, not automation

    Rotation is versioned and can be done without downtime or touching a site, but an operator runs it. There is no scheduled rotation and no HSM.

  • Trust concentrates in one signing key

    That is the deliberate tradeoff of this design: your sites hold nothing worth stealing because the broker holds the only key that mints. Audience-binding, DPoP, a 60-second TTL and versioned rotation shrink the consequences of that key leaking — they do not remove the concentration.

  • We cannot undo a WordPress change

    Portcullis records what ran and can stop it running again. Rolling back a change on the site itself is the site owner’s recovery path, coordinated with us.

  • AI reads what you point it at

    An assistant can only act on content it can read, and that content is processed by the provider running it. We scope and minimize it; we don’t pretend it doesn’t happen. Full data-processing disclosure →

Questions this didn’t answer?

Send them to hello@portculliswp.com — a security review that turns up something we should have written down makes this document better. Or start free and inspect the thing itself.