Skip to content
Dee Wan 1.0 is not yet released (pre-MVP). See the 1.0 product boundary.

Plugin security

What a Dee Wan installation token is, how it is stored and rotated, how a person’s authority reaches a plugin command, what a grant actually bounds, and what someone who takes over a plugin can and cannot do with it.

Every example here is replayed against the real routes by Dee Wan’s own test suite. The ids and secrets are synthetic.

See also Protocol v1 for the route-by-route surface and Writing a plugin for the installation flow.

browser ── user session ──► Dee Wan admin Worker
│ control database: installations, token hashes, grants, launch codes
│ tenant database: content, versions, workflow, audit
│
│ HTTPS + installation bearer token
plugin service ◄──────────────┘
│ its own database, its own clock, its own sessions
└── HTTPS + installation bearer token ──► /api/plugin/v1 ──► the same lifecycle service a person uses

Three properties hold that boundary up:

  1. The plugin API is not the admin API. It lives under /api/plugin/v1, has its own middleware, and shares only the lifecycle service — there is no second implementation of a transition that could drift from the one a person drives.
  2. Plugin UI runs inside its reviewed boundary. Declarative cards use core controls. Optional frames use the reviewed plugin origin, an isolated sandbox and a bound MessageChannel. Frames receive neither admin sessions nor installation tokens. Field edits use ordinary Save; declared operations use live host authorization. Vendor HTML/CSS/JS runs inside a separate opaque frame in previews and supported site renderers, without access to the admin or site’s parent document.
  3. No plugin database access. A plugin gets no Prisma client, no D1 binding, no R2 credentials and no Cloudflare account access. Everything it can read or change goes through the routes under /api/plugin/v1, and through the answers core asks it for — an operation result, a policy decision or a media source listing — which core validates before it uses them.
Shape dwp_ plus 43 URL-safe characters — 256 bits of randomness from crypto.getRandomValues
Given to the plugin Once, in the activation hand-off, over HTTPS to the manifest’s own origin
Stored by core SHA-256 hash only. There is no code path that can print the token again
Sent by the plugin Authorization: Bearer <token>, on every request
Never in A URL, a query parameter, an error body, a log line, an audit entry or a fixture
Scope One installation, which is one plugin on one site

The prefix is there so a leaked string is recognisable as a Dee Wan plugin credential. The entropy is what makes it a credential. A malformed bearer value is rejected before any database read, so these guessing attempts never trigger a database lookup.

Rotation is a hand-off, not a reset, and it needs a fresh one-time code from the plugin — otherwise anybody who could reach the admin route could silently replace a credential.

  1. The administrator gets a new activation code from the plugin.
  2. Core mints a new token and sends it, with that code, to the pinned activation_url from the stored manifest. Core does not use a URL supplied at rotation time.
  3. Only on a 2xx does core swap the hash. If the plugin refuses or cannot be reached, the old token keeps working — a failed rotation must never lock a working installation out.
  4. The previous hash stays valid for at most 10 minutes, so a request already in flight is not killed mid-rotation.
  5. The first request carrying the NEW token ends that overlap immediately. From that moment the old token answers plugin_unauthorized, which is terminal.

Step 5 matters if your plugin runs more than one worker. If one worker starts using the new token while another still holds the old one in memory, the second worker’s next request is refused with a terminal 401 plugin_unauthorized, and if it settles its work on that answer, the work is lost. Either switch every worker to the new token together, or, on a plugin_unauthorized, re-read the stored token once and retry with it only if it has changed since the request was sent.

Core never stores and never logs the activation code. The plugin that issued it needs only enough to check it: the Scheduling plugin stores a SHA-256 hash of each code with its expiry, never the code itself.

disabled and revoked both stop the plugin API at the door — before tenant access, before the body is read. revoked is terminal: the hashes are dropped and that token can never be valid again. Uninstall revokes first, and Dee Wan makes no claim about data it cannot see.

A plugin’s management UI is a separate page, and Dee Wan session cookies never leave Dee Wan. The link between the two is a launch code:

  • random, one-time, stored hashed, and valid for 60 seconds
  • bound to installation, site, user, action and — for a content action — one content instance
  • exchanged by the plugin’s BACKEND, with its own bearer token, for a delegation record
  • a code minted for one installation cannot be exchanged by another
  • the launch response carries Referrer-Policy: no-referrer. The plugin’s own management page must send the same header, load no third-party resource before the exchange, and redirect to a URL without dee_wan_launch once the exchange is done.

Core checks, before minting: the requester has a session and access to the site, a bare manage requires site:plugins, a manage launch naming a declared view (the editorial calendar or releases) requires one granted model the requester may read — every entry, or only their own — and a content action requires read access to that specific item and is refused (model_not_granted) when the item’s model is not in the installation’s grant. The admin UI only offers a plugin’s content actions on items of granted models. The delegation records model_ids, the exact granted models the requester may read, with read or read:own (for a content action, the item’s model). Every later read and command is refused for an item outside them, or one the person can no longer read.

The delegation is not a credential. It carries no capability. What it carries is identity — and that is what makes the authority re-read possible:

Every plugin command names a delegation_id, and core re-reads that person’s CURRENT authority from the control database before it writes anything. Authority recorded when a schedule was created is never carried forward.

What a delegation reaches:

  • A content-action delegation is bound to one instance, and acts only on items in that instance’s translation group — the group moves together on publish, so that is the unit. Naming it for any other item is requester_unauthorized.
  • A manage delegation has no instance. It reaches an item only when the item’s model is still granted, is in the delegation’s model_ids, and the person can read that item now. A person who reads only their own entries reaches only their own entries. Anything else is requester_unauthorized or content_not_found.
  • Core does not check the delegation’s action on a command. The action tells the plugin which screen to open. It is not a permission.

A delegation does not expire by time. That is deliberate: a schedule created today may run next month, and it must still be able to name the person who asked. What bounds it instead:

  • the person’s CURRENT authority, re-read on every command — they must still be an active user, still have access to the site, and still hold the permission the transition needs
  • the installation — a disabled installation, an uninstalled one, or a token that is no longer valid stops every request before any delegation is read
  • the grant — the item’s model must still be granted, and the capability must still be held.

Core refuses the command at execution in all of these cases, even if the delegation was valid when it was created: the person lost their role on the site, their account was deactivated, their role no longer carries the publish permission, the item is outside the delegation’s translation group, or the delegation belongs to another installation. A read with delegation_id checks the delegation the same way: an unusable delegation is refused as requester_unauthorized rather than answered with a quietly shorter list, and a usable one narrows the offered transitions to what the person may do now.

The person who asked no longer holds the authority it needs

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0009:r1" \
--data '{"kind":"publish","expected_current_version_id":"cdocsversion00000000000001","expected_workflow_revision":0,"delegation_id":"pld_0000000000000000000000000000d1ee"}'
{
"outcome": {
"code": "requester_unauthorized",
"message": "The person who requested this no longer holds the authority it needs.",
"retry": false
}
}

Nothing was written. This is terminal: a plugin must not retry it, and must show its user that the schedule needs a person with the right authority.

The one deliberate exception is a replay. An Idempotency-Key that was already applied returns the first result even if the requester has since lost their authority — the work is already done, and re-answering it is not a new grant of permission.

The site comes from the token and from nothing a caller sends. There is no X-Site-Id on a plugin request, no site in a body, and no database name anywhere: both are refused outright rather than ignored, so a bug in a plugin cannot become a cross-tenant read.

Naming a database

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "X-D1-Binding: SOME_OTHER_TENANT"
{
"outcome": {
"code": "invalid_request",
"message": "A plugin request cannot name a database.",
"retry": false
}
}

Model access is the intersection of a capability and a stored list of content-model ids. A request for out-of-scope content answers content_not_found — the same answer as “no such content”, so a plugin cannot map the shape of a site by probing.

An id from another site

Terminal window
curl "https://cms.example/api/plugin/v1/content/cotherinstance0000000000001" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "content_not_found",
"message": "No such content in this installation’s scope.",
"retry": false
}
}

A narrowed grant applies to the very next request:

After an administrator removes the model from the grant

Terminal window
curl "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "content_not_found",
"message": "No such content in this installation’s scope.",
"retry": false
}
}

Grants store model ids, so renaming a model keeps the grant and deleting one makes that part of it inert. The administrative UI shows the current slugs beside the ids.

A user cookie is not plugin authentication. Plugin routes read Authorization and nothing else, so a stolen browser session cannot be replayed against the plugin API:

A session cookie on a plugin route

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-b "__Secure-better-auth.session_token=$SESSION"
{
"outcome": {
"code": "plugin_unauthorized",
"message": "The installation token is missing, unknown, disabled or revoked.",
"retry": false
}
}

The reverse holds too: a plugin token presented to an administrative route is refused as unauthenticated. Those routes want a user session, the site header, and site:plugins. A plugin has none of the three.

A disabled installation, before any tenant access

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "plugin_unauthorized",
"message": "The installation token is missing, unknown, disabled or revoked.",
"retry": false
}
}

One answer covers missing, unknown, disabled and revoked. Nothing tells a caller which applies.

Requests are limited per installation — 120 per 60-second window — using the same enforcement primitive as the rest of the product, and answered as the protocol’s own envelope with Retry-After. These are abuse controls. Nothing here is measured, reported or retained as telemetry.

Over the limit

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "rate_limited",
"message": "Too many requests for this installation.",
"retry": true
}
}

Respect Retry-After, and refuse your own record rather than waiting if the delay exceeds a maximum you chose in advance.

Core calls a plugin for four things: the manifest fetch, the activation or rotation hand-off, an invocation of a declared operation, policy or media source, and the download of a media source file a person chose. All are bounded and pinned.

  • HTTPS only. Credentials in the URL and fragments are refused.
  • Destinations are judged from the host name in the URL:
    • An IPv4 literal is refused if it is private, loopback, link-local, shared (CGNAT), multicast, reserved, benchmarking, or one of the documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24). A public IPv4 literal is accepted.
    • Decimal and hexadecimal spellings such as https://2130706433/ or https://0x7f000001/ are normalised to dotted form by the URL parser first, and then judged the same way.
    • Every IPv6 literal is refused.
    • A name with no dot is refused, and so are localhost, localdomain, local, internal and home.arpa, and any name ending in one of them (printer.local, db.internal).
  • Redirects are refused, not followed. A 3xx is remote_redirect_refused.
  • The response body is capped at 32 KB and the call at 5 seconds. An invocation is capped at 64 KB and 10 seconds for the request, the exchange and the answer together.
  • Every URL — manifest, base, activation, management, operation and policy endpoints — must share one origin, so no call can be pointed somewhere the reviewed manifest did not name.
  • An invocation sends no content and no credential: only the installation id, the invocation id, a one-time code and its expiry. The plugin exchanges the code with its own token. Core accepts the answer only from that installation, only after the exchange, and only while the installation is active and its grant still covers the operation or policy.
  • An operation’s exchange also returns a delegation_id. Core records it with the invocation, bound to the caller, site and installation, and to the model and entry of an entry operation. A site operation’s delegation has no entry and reaches the granted models the caller reads, like a manage launch; site operations already need site:plugins. It is never a code and cannot be exchanged at /launch/exchange. Every use is checked exactly like a launch delegation.
  • The remote response is parsed as untrusted input against the manifest schema. An unknown key is a refusal, not something to ignore.
  • A media source answer is accepted only when every thumbnail_url and download_url is on the manifest’s origin and a delivery_url is on one of the source’s declared delivery_origins. Core GETs a chosen download_url with no credential, no cookie and no redirect, within 20 seconds and the media file limit, and then checks the bytes like any upload. The picker loads previews from the plugin with referrerpolicy="no-referrer", so neither the admin address nor any Dee Wan credential reaches the plugin. A copy is the site’s own file; nothing public loads from the plugin. A file added in reference mode is the one exception: core never fetches its delivery_url, but published pages, site copies and generated sites load it from the declared delivery origin, so that origin is part of what the site’s visitors’ browsers contact.
  • Workers cannot resolve DNS before fetch, so this is what the URL itself names. A public name that resolves to a private address is outside what the check can see.
  • Local HTTP is possible only when ENVIRONMENT=development and the exact origin is listed in PLUGIN_DEV_ORIGINS. No wildcard, no private-range shortcut, no production fallback.

An operator may list catalogue URLs in PLUGIN_CATALOGUE_URLS, comma-separated. Settings → Plugins → Discover reads them. None is set by default, and Dee Wan has no built-in registry.

  • A catalogue URL, every next page and every URL in an entry pass the same rules as a manifest URL. Redirects are refused. next must stay on the first page’s origin.
  • At most 4 catalogues, 5 pages each, 128 KiB per page, 100 entries per page and 200 per catalogue. A catalogue over a limit is left out whole, with its reason shown.
  • An entry is id, name and manifest_url, plus optional summary, categories, homepage and icon. Unknown keys are refused. An invalid entry is left out and listed by position.
  • Two entries with one id: the first wins, in configured order, then page order, then entry order.
  • The icon is shown only as an image, with no referrer.
  • A listing grants nothing. Review sends the entry’s manifest URL to the ordinary review, and Install fetches and validates the manifest again. The page format is in Protocol v1, under GET /api/plugins/catalogue.
  • A catalogue failure never touches installed plugins or install by manifest URL.

A plugin is an actor, not a user. No fake user is created, and no installation id is written into a user foreign key.

A plugin transition records the installation id, the accepted plugin id, name and version, the protocol version, a digest of the idempotency key — never the key itself — the resulting state and publication pointer, and the email of the person whose delegation authorised it. Installation identity lives in the control database and workflow audit in the tenant database, with no cross-database foreign key: the tenant event stores an immutable snapshot. That is also why uninstall cannot erase attribution.

Administrative acts — install, grant change, manifest acceptance, rotation, disable, enable, uninstall — are written to the admin audit ledger by the acting user, and refuse (503 audit_unavailable) rather than mutating unaudited.

A write a plugin makes for a person — a field save, a media upload, a proposal — runs as that person: the version or media row is theirs, and the admin audit ledger records them as the actor with the installation id and plugin id in the change (plugin.content.save, plugin.media.upload, plugin.proposal.create). It refuses (temporary_failure) rather than writing unaudited. A proposal also stores the plugin’s name and the person’s email, which the entry editor shows. Working drafts are the person’s own unsaved work: they are neither versions nor audit, and a plugin reads only the drafts of the person whose delegation it names.

An operation run or a policy check is audited as plugin.operation.run or plugin.policy.check, with the person who asked as the actor and the installation, the declared id, the classified outcome and digests of the input and the answer in the change. The values and the answer themselves are not stored there. An administrator’s choice of what an unanswered policy means is audited as plugin.policy.mode.

A policy sees the facts of one write — model, entry, language, changed field keys and their values — and nothing else: no credential and no other content. It answers allow, warning or refuse. A save or restore policy may instead answer transform: replacement values, links, media or SEO overrides for the same entry and language it was asked about. Core validates a transformed candidate like the person’s own input and stores it as part of that person’s write. No policy answer can reach another entry or language, move a workflow, or replace Dee Wan’s own checks, which run first. A write is committed only while the version the policy was shown is still current; a change in between is the ordinary conflict.

If a plugin is fully compromised, an attacker holds one installation token for one site. It can use only the capabilities the administrator granted, only on the granted models, and every write needs a delegation_id whose person still holds the authority for that write, within that delegation’s reach (one translation group for a content action; for manage, the granted models the person can still read). Within those limits it can:

  • with content:read: read summaries and, with a live delegation, saved values and retained revisions projected to the person’s model, field, locale and ownership limits
  • with workflow:publish, workflow:unpublish or workflow:locale: publish or unpublish content, pinning the exact version and matching the current workflow revision
  • with content:write: save values, relations and media on existing entries; with content:create: create entries; with content:delete: move entries to Trash, under the person’s normal lifecycle checks
  • with draft:write: read, write and discard the delegated person’s own working drafts
  • with media:write: upload files; with media:read: read existing files and metadata; with media:update: edit metadata, under the person’s corresponding current media authority
  • with content:propose: create proposals a person reviews, and read its own proposals
  • through a granted save or restore policy: replace values, links, media or SEO overrides in the write it was asked about; through any granted policy: see that write’s facts, and warn or refuse it
  • through a granted operation or media source: answer what core asked, which core validates
  • open the exchange route with codes it already holds.

It cannot:

  • reach another site, or another model, or another installation’s delegations
  • use a capability, operation or policy the grant does not hold
  • write without a delegation, or beyond what the delegated person may do now
  • read users, roles, sessions, settings, model definitions or audit, or list the whole media library
  • read saved values or retained revisions outside the delegated person’s readable projection
  • use media reads or metadata edits without the installation capability and the person’s media authority; protected bytes keep the same site and visibility checks
  • execute code in the parent admin document, or reach the database, R2 or any Cloudflare credential
  • publish a newer draft than the one pinned, traverse a changed workflow, override dependent content, or use any override route — there are none in protocol v1
  • act for a person who has lost their authority, or forge one: delegation_id is a lookup, not an assertion
  • replay a mutation into a second effect, or save a version, upload media or create a proposal without its audit record. Working drafts are not audited (see Audit).

Delegations are long-lived by design. A delegation obtained today can still be used months from now, because scheduled work needs that. The window is bounded by people and grants, not by a clock: it closes when the person loses the authority or the site access, when the grant loses the model or the capability, or when the installation is disabled, uninstalled or its token revoked. An administrator who wants every outstanding delegation of a plugin to stop at once uninstalls it.

If a launch code leaks — a referrer, a log, a shared URL — it is one-time, 60 seconds old at most, stored hashed, and useless without the installation token that exchanges it.

An agent can create a launch link. A management credential or connected app with plugins:use gets the same link through the management API or MCP, minted by the same code with the same checks, as the credential’s creator. The answer is only the link and its expiry, never a plugin result. The delegation that follows is the creator’s, limited to the credential’s models and capped by its scopes: it carries only the installation capabilities those scopes allow (content:read for content:read and workflow:read; lifecycle:publish for workflow:publish, workflow:unpublish and workflow:locale; content:write for content:write, content:create and draft:write; content:trash for content:delete; media:write for media:write, media:read, media:update and media:source; content:propose). A command outside that ceiling is capability_refused. A scope only takes away.

An agent can run a declared operation. With plugins:use, the management API and MCP also run an interactive operation the manifest declares and return its result. That is the only plugin result an agent gets. Core POSTs your endpoint a one-time code, never the management credential or the caller’s values; you exchange the code with your installation token for the bound input. Core rechecks the plugin state, the grant and the caller’s live permissions before each run and before it replays a keyed result. The audit keeps digests of the values and the result, not the values. An automation operation is never offered to an agent.

If an administrator is compromised, an attacker can install a plugin and grant it the permissions held by that account. This risk is why site:plugins is a separate permission and is never implied by publish permission. Of the roles a new site starts with, only admin holds it. It is also why every installation step is audited and every grant is chosen manually from an explicit manifest request.

What Dee Wan cannot defend against is the plugin’s own operation: its uptime, its clock, its retry policy, what it keeps in its database, and whether it deletes your data when you ask. The plugin’s own documentation has to answer those, and an administrator has to read it before granting anything. See the Scheduling plugin for what that looks like.

Creation, deletion, existing-media reads and metadata edits each need their own reviewed capability. Existing content:write and media:write grants do not gain these powers. Changed relation targets, including links inside components, must fit both the installation’s model grant and the launch delegation’s model list. An unchanged opaque link can remain during another edit.


Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes