Security
September 16, 2026 · View on GitHub
Security-relevant patterns in the Worker, and the reasoning behind them. This doc is the authoritative record of what is protected, what is intentionally out of scope, and the invariants future changes must not regress. See the 2026-05-01 and 2026-07-01 internal security audits for the underlying findings, and the 2026-09-16 codebase audit for a later cross-cutting review.
Kody is a multi-worker Cloudflare app: a Remix 3 browser UI and OAuth-protected
MCP HTTP on origin, platform Durable Objects on kody-platform, package apps on
kody-runtime, and cron on kody-jobs. It is multi-user, so the overarching
invariant is that every read/write path is scoped by userId (see
AGENTS.md). Cross-user data sharing is a bug, not a
feature. See architecture.
Invariants for future agents (do not regress)
Read this list before touching auth, routing, response construction, or the package-app surfaces:
- First-party HTML keeps its security headers. All trusted account/auth
pages must go through
render()(packages/worker/src/app/render.ts), which appliespackages/worker/src/app/security-headers.ts. Never add'unsafe-inline'to the CSPscript-src. The scroll-restoration restore script is allowed only by its sha256 hash, not by relaxingscript-src. - Untrusted surfaces stay off the strict CSP. Hosted package apps
(
https://{username}.<package-app host>/packages/*in production, or/@username/packages/*when served inline) execute author-supplied HTML/JS and intentionally do not use the first-party CSP. Do not "unify" the two. Because they have no CSP backstop, their isolation comes from the origin they run on and the credentials they never receive — see invariant 9. - Developer-only routes fail closed. Anything that runs attacker-authored
content or aids debugging must be gated behind
isNonProductionRuntime(packages/worker/src/app/deployment-env.ts) so it is unreachable in production. - Credential endpoints are rate limited. Any new endpoint that accepts a
password, token, or reset code must join the shared auth rate-limit bucket in
packages/worker/src/origin-handler.ts(rateLimitedAuthPaths). - New passwords go through the shared policy. Use
getPasswordPolicyErrorfrom@kody-internal/shared/password-policy.tswherever a password is set. - OAuth PKCE stays S256-only. Keep the
getPkceValidationErrorcheck inoauth-handlers.ts(rejectcode_challenge_methodother than S256 when a challenge is present). Do not set the provider'sallowPlainPKCE: true— see the OAuth section below. - Every data path is
userId-scoped. New D1 queries, Durable Object names, and Vectorize filters must includeuserId. Prefer parameterized SQL (.prepare(...).bind(...)); never interpolate user input into SQL. - Untrusted markdown renders through the safe renderer only. Community
READMEs (and any future third-party-authored markdown shown on first-party
pages) must go through
packages/worker/client/markdown-view.tsx, which builds JSX from an allowlist ofmarkedlexer tokens: raw HTML renders as escaped text (first-party guides may additionally emit allowlisted<details>/<summary>and GitHub-style> [!TIP]callouts; untrusted markdown never does), and links are restricted to absolutehttp:/https:/mailto:URLs with/@...user-scope paths and/packages/...package-app mount paths refused on any host so a README can never point viewers at hosted package endpoints.<img>is emitted only for in-repo relative image paths rewritten to that package's first-party/assets/route (published or pinned package bytes, sniffed type, size-capped) and only when the viewed markdown is that same commit. Remote and other author-chosen URLs stay links. Never render third-party markdown via an HTML string,innerHTML, or a markdown-to-HTML renderer. - Package code never receives first-party credentials, and never executes on
the app origin in production. Every request handed to package code goes
through
createPackageCodeRequest(packages/worker/src/app/handlers/package-app.ts), which stripsCookie,Authorization,Proxy-Authorization, and everyX-Kody-*header — for the forwarded HTTP request and the realtime connect upgrade, because the realtimeconnecthook is handed the request headers too. Production also setsPACKAGE_APP_BASE_URLso package apps run on their own registrable domain, cross-site from the app origin. Do not add a first-party route to the package-app origin, and do not reintroduce cookie forwarding "just for convenience". See Hosted package app origin isolation. - CSRF protection is
SameSite=Lax+ JSON content types, not tokens. Every mutating first-party endpoint must keep requiring a JSONContent-Type(which cross-site form posts cannot send) and thekody_sessioncookie must staySameSite=Lax. Do not add a mutating endpoint that acceptsapplication/x-www-form-urlencodedormultipart/form-datafrom the browser, and do not relax the cookie toSameSite=None, without adding CSRF tokens at the same time. - Email change requires a verified current address.
POST /account/email-change.jsonrefuses to start a change whenusers.email_verified_atis null (403, audit reasonemail_unverified). A former-email claim collision at password or social-login signup is a controlled 409 with audit reasonformer_email_claimed(copy tells the person to sign in with the email that account uses now, or release the address from Account settings — never leaking the current email). The owner re-verifies the former address (POST /account/email-claim-release.jsonplus/verify-email-claim-release) to drop the claim; that path is rate limited. Operators inspect leftover implicit sha256 collisions withadminUserStableIdConflict(metadata only).users.stable_user_idis never recomputed for an existing account. - Unverified accounts are reclaimed on a provider-verified social match.
When a social login profile presents a verified email that matches
users.emailandemail_verified_atis null, treat the row as a possible squat before linking: rotatepassword_hashto an unusable sentinel, stamppassword_changed_at(browser sessions and MCP bearers fail closed), revoke MCP grants, delete TOTP rows, passkeys, otheroauth_connections, and outstandingpassword_resets, then link the provider and mark the account verified. An already-verified match only links and signs in. - Unverified person accounts older than seven days are purged. The hourly
unverified_account_purgelane deletespersonaccounts whoseemail_verified_atis still null,created_atis older than seven days, and nooauth_connectionsrow exists. Theoauth_connectionsexemption is sound only because signed-in provider linking refuses a liveemail_verified_at IS NULLrow (the insert is fenced onemail_verified_at IS NOT NULL AND deleting_at IS NULL) and unauthenticated social sign-in always verifies. Each candidate is claimed with an atomicUPDATEthat restampsdeleting_atonly while that eligibility still holds. Once claimed, email verification and social-login reclaim refuse the fenced row. A claim-created fence is released only on pre-cleanup failures (active writers or inventory); a partial-cleanup failure leaves the fence for retry. Never-attempted rows are processed before retries. Deletion uses the inventory-driven account-deletion path. Each purge writes anunverified_account_purgedaudit row; each failed deletion writes anunverified_account_purge_failedaudit row (hashed email, reason<ErrorClassName>: <first inventory/cleanup warning or message>with email addresses redacted and bounded to 200 characters) and a Sentry event taggedscheduled.lane, so a lane that fails every hour is visible without Workers Logs. Both are best-effort: anAUDIT_DBor Sentry outage is logged and skipped, not fatal. Operators run one pass interactively with the admin-onlyadminUnverifiedAccountPurgeRunMCP capability (dryRunpreviews the next claim page; results carry stable user ids, never emails or usernames). Password signups are the only unverified path; social-login accounts are verified at creation. Signed-in linking of a provider to a password account also requires that live verified email. - Password-reset confirmation clears second factors and linked providers.
POST /password-reset/confirmdisables TOTP, deletes passkeys andoauth_connections, and tells the owner in the confirmation email. Signed-inPOST /account/password.jsonleaves those factors in place. - User-secret trust grants are never applied by package runtimes.
secretLockreturns a website approval URL and does not writeallowed_packages.communityForkAdoptwidens implicit user-secret read/use for a fork and is restricted to an interactive MCP caller with empty package/app/storage context (same class of gate aspackageAppFetchand platform-feedback submit). Package apps, jobs, webhooks, and other package runtimes cannot adopt or grant themselves secrets.
First-party HTTP security headers
render() attaches the header set in
packages/worker/src/app/security-headers.ts to every trusted HTML response
(login, signup, account pages, the OAuth consent screen, and the SPA shell):
Content-Security-Policywithscript-src 'self' https://cdn.usefathom.com(no'unsafe-inline'; the Fathom Analytics tracker is the only allowed external script; its image pageview beacon is allowed inimg-srcand itssendBeaconduration/event pings are allowed inconnect-src; the scroll-restoration restore script is an inline classic script allowed only by its sha256 hash),frame-ancestors 'none',base-uri 'self',object-src 'none',form-action 'self',worker-src 'self' blob:(for Sentry Session Replay), andconnect-srclimited to'self'plus the Fathom, Cloudflare Web Analytics, and Turnstile beacon hosts. The client bundle loads as an external module.style-srcallows'unsafe-inline'because SSR-streamed styles arrive as inline<style>tags; style injection is far lower risk than script injection.X-Frame-Options: DENYplusframe-ancestors 'none'— stops clickjacking of the OAuth consent screen and account pages.X-Content-Type-Options: nosniff.Referrer-Policy: strict-origin-when-cross-origin.Strict-Transport-Security(ignored by browsers over plain HTTP, enforced over HTTPS).
These headers are deliberately not applied to hosted package apps, which need their own looser policies to run author-authored code.
Hosted package app origin isolation
Hosted package apps execute author-supplied HTML, JS, and worker code. In
production they run on per-user subdomains of the package-app domain
(https://{username}.kody.run/packages/{kodyId}/...); confirmed non-production
runtimes may serve them inline on the app origin at
/@{username}/packages/{kodyId}/... instead. Anything that shares an origin
with a package app is inside its reach, and package apps get no CSP backstop
(invariant 2), so the origin boundary is the control.
Threat model. Author-supplied package code must not act as the owner, and one user's package apps must not reach another user's browser state. If package apps shared the app origin and the handler forwarded a clone of the original request into the package worker, author code would have two ways to act as the owner:
- server-side, the forwarded request would carry the owner's
kody_sessioncookie (and anyAuthorizationheader), so package code could read it and replay it; - client-side, a same-origin package page could
fetch('/account/secrets.json', { credentials: 'include' })from author JS and read the owner's secrets or mutate their account.
If every user's package apps shared one origin on the package-app domain, one owner's package could also read or plant cookies and call same-origin endpoints for another owner's packages. That is a real vulnerability, not an accepted risk. Three independent controls close it.
1. Credential stripping (always on). createPackageCodeRequest builds the
request package code sees without Cookie, Authorization,
Proxy-Authorization, or X-Kody-* headers. It is applied to the forwarded
HTTP request and to the realtime WebSocket upgrade, because
PackageRealtimeSession's connect hook receives the upgrade request's
headers. This holds regardless of hosting mode, so local dev and preview are
covered too.
2. A separate registrable domain (production). PACKAGE_APP_BASE_URL
(production Worker var, https://kody.run — the apex; the deploy publishes apex
and wildcard zone routes on the runtime Worker for that host, never a
Workers custom domain in this zone — see
setup-manifest.md) makes package apps cross-site from
the app origin, so the SameSite=Lax, HttpOnly kody_session cookie never
attaches to them and cross-origin fetch from package pages has no CORS grant
(withCors only reflects same-origin, plus /mcp). It must stay a separate
registrable domain: a subdomain of the app origin would still be same-site for
cookie purposes. getPackageAppBaseUrl (packages/worker/src/app-base-url.ts)
resolves the apex origin, and getAppBaseUrl refuses to resolve the package-app
origin as the app origin so package runtime callbacks and first-party links
always point back at the app.
3. Per-user subdomains (production). Each owner's hosted apps are served
from {username}.<package-app host> (buildPackageAppSubdomainOrigin in
packages/shared/src/public-urls.ts), so browser state (cookies, storage,
document access) never crosses accounts. The username label in the hostname
must be a valid single DNS label: lowercase letters, digits, and hyphens only,
3–32 characters, alphanumeric edges (dnsSafeUsernamePattern in
packages/shared/src/public-urls.ts). Every username satisfies this shape —
underscores are rejected everywhere, and there is no lenient recognition tier
(decision 0017). Wildcard DNS still routes invalid or nested labels to the
Worker, so hostnames that are not exactly one valid username label fail closed
with 404.
Usernames are also {username}@<platform domain> mail locals. The built-in
reserved list in packages/worker/src/identity/reserved-usernames.ts blocks
brand, infrastructure, mailbox, and trust labels (autodiscover, mta-sts,
wpad, system-email locals, and the rest of the denylist) so those labels
cannot become package-app subdomains or inbound mailboxes. Operators add or
unreserve names at runtime through platform-settings:v1:reserved-usernames
without a deploy; system-email locals and kody-prefixed built-in names stay
permanently locked. Signup, username change, admin user creation, and social
username generation consult the effective set (built-in plus KV). Inbound
mailbox routing (resolveInboundMailboxRoute) uses only the RFC 5233 base local
with isPermanentlyReservedUsername / isSystemEmailLocal — never the plus-tag
and never the claim-denylist substring matcher — so alice+kody@inbox… is user
mail for alice.
A new claim fails when the case-insensitive (lowercase-trimmed) username is an
exact reserved token or when the hyphen/underscore-stripped form equals a
reserved token. Substring matching is narrower: only KV-added tokens and
built-in brand/system roots (kody, kent, permanent system-email locals, and
other kody-prefixed built-ins) block compact substrings, and only when the
compact token is length 4 or more. Infrastructure labels such as user, test,
and help stay exact/compact-equal only; abuse and impersonation also lean on
KV-added terms and operator discretion on /terms.
Underscores are stripped for matching only; stored handles still allow letters,
digits, and hyphens (3–32, alphanumeric edges). Three-letter tokens still block
exact and compact-equal claims (f-aq matches faq) but do not substring-match
(assistant is allowed even if ass is added in KV). The user-facing error is
This username is reserved. Accounts that already hold a colliding username
keep it at signup; adminReservedUsernameList conflicts lists those holders
when invoked. Resolve conflicts before expanding the built-in list; do not treat
conflicts as a mandatory post-expansion run. Operators may still change an
existing username at their discretion when a handle is abusive, reserved,
impersonating, or otherwise a problem — that policy is stated on
/terms. Platform accounts still claim an exact
token from the effective reserved set (built-in plus KV-added, minus removed),
not a substring collision. Generated usernames keep numeric suffixes only when
the preferred base is claimable but taken; a substring-eligible reserved base
skips to a random compact candidate because support-2 still contains
support.
Dispatch lives in packages/worker/src/app/package-app-origin.ts, called first
in the Worker fetch handler:
- App origin,
/@{username}/packages/*: never executes package code. Safe methods redirect (302) to the owner's package-app subdomain with a handoff token; other methods get a307to that subdomain. Unauthenticated visitors are sent to/loginon the app origin first. - Package-app apex (
kody.run): serves no package code./redirects to the app origin. Legacy path-based URLs (/@{username}/packages/*) redirect (302/307) to the owning user's subdomain. Everything else — including/account/*,/login,/mcp, and the/@{username}/api/package-invocations/*and/webhooks/*machine APIs — is404. Those APIs stay on the app origin on purpose: they are authenticated by their own bearer tokens or URL secrets, they are never called by package browser code, and hosting them on the package-app domain would only widen its surface. Retired/@{username}/connectors/*paths also 404. - Per-user package-app subdomain (
{username}.kody.run): serves only/packages/{kodyId}/*for that hostname's username label./redirects to the app origin; every other path is404.
Handoff session and fixation defense. The app origin mints a short-lived
single-use handoff token; the owner's subdomain exchanges it for a host-scoped
package-app session cookie (packages/worker/src/app/package-app-session.ts).
On secure requests the cookie is named __Host-kody_pkg_session; plain HTTP
local dev uses kody_pkg_session because browsers refuse __Host- cookies on
insecure origins. The __Host- prefix requires Secure, Path=/, and no
Domain attribute, so browsers reject any variant with a Domain — this blocks
cross-subdomain cookie tossing even before the package-app domain is on the
Public Suffix List. Serving additionally requires the resolved session account's
username, the subdomain label, and the path's owner username to all match
(servePackageAppRequest in
packages/worker/src/package-runtime/package-app-serve.ts), so a handoff minted
for one account cannot authorize another user's subdomain.
Sibling subdomains also stay same-site with each other until the domain is
on the Public Suffix List, so a SameSite=Lax cookie still attaches to their
cross-origin requests. In a browser holding sessions for two accounts (a shared
machine), one user's package code could otherwise send a credentialed mutating
request to the other user's app — CORS blocks the response, not the side effect.
Mutating requests on a subdomain therefore require any Origin header to match
the subdomain itself; requests without one (non-browser clients, synthetic
dispatch) authenticate through their own paths. Same-site credentialed GETs
remain possible until the PSL entry lands; package apps must not mutate on GET,
which HTTP already demands.
- No redirect cycle: the app origin only ever redirects to a package-app
subdomain, the apex only redirects to a subdomain or the app origin, and a
subdomain only redirects within itself to drop a consumed token from the URL
(plus its bare
/, which goes home to the app origin — a terminal hop, not part of the handoff). A package-app request with no usable session terminates in a403that links back to the app origin, so a browser that refuses the cookie fails visibly instead of ping-ponging between hosts.
Production fails closed with 500 before executing package code when
PACKAGE_APP_BASE_URL is missing, invalid, equal to APP_BASE_URL, or on the
same registrable domain. There is no production inline fallback.
Preview, tests, E2E, and npm run dev may keep serving package apps inline when
no package-app origin resolves. That path is still credential-stripped, but it
is same-origin, so do not treat a non-production inline run as representative
of the production isolation boundary. To exercise the two-origin flow locally,
set PACKAGE_APP_BASE_URL=http://packages.localhost:<port> in
packages/worker/.env.
The cross-site handoff (how the owner is recognized on the package-app subdomain
without giving package code a first-party session) is documented in
architecture/authentication.md.
Because hosted package apps are served from this deployment's own origins, code
that decides whether a URL is "ours" must accept every production shape.
parsePackageSearchIdentity
(packages/worker/src/mcp/tools/package-search-identity.ts) accepts a
https://{username}.<package-app host>/packages/{kodyId} URL, a
/@{username}/packages/{kodyId} URL on the app or package-app apex, and
/account/packages/{packageId} on the app origin — so a URL copied out of a
running package app resolves to that package — while other /account/* paths
stay app-origin only. Untrusted markdown is the opposite case:
getSafeMarkdownLinkHref refuses any /@... user-scope path and any
/packages/... package-app mount path regardless of host, so a community README
cannot link into either origin's package surface.
Out of scope (deliberate). User-to-user isolation is complete: each owner's
apps run on a distinct origin. Two packages owned by the same user still
share that user's subdomain origin, so one of that owner's package apps can
reach another's __Host-kody_pkg_session-authorized endpoints from the browser.
That is a smaller blast radius than first-party access (all of it stays inside
one owner's own data, since serving is userId-scoped and the session is bound
to one account), but it is not zero.
Operational follow-up (not code). Submit kody.run to the
Public Suffix List for defense-in-depth
(sibling subdomains treated as separate registrable domains by browsers). That
requires a _psl TXT record on the zone and a PR to
publicsuffix/list by the domain owner,
plus 2+ years remaining on the registration. Abuse/contact mail is
psl@kody.codes, a reserved operator system inbox (same storage as abuse@ and
security@). Do not enable allowPrivateDomains on tldts:
readPackageAppZoneName must keep resolving the public-suffix zone without the
PRIVATE list. The __Host- cookie is the primary cookie-tossing control; PSL
entry is an additional layer.
Non-canonical hosts
All three product workers keep workers_dev = true so secret bulk-reapply does
not drop zone routes. That leaves a second hostname (*.workers.dev) pointing
at the same scripts as kody.codes and kody.run. WebAuthn rpID derives from
the request host, and CSP/CORS reasoning assumes one origin, so production must
not serve the app, /mcp, or OAuth on that trigger.
refuseNonCanonicalProductionHost (packages/worker/src/app/canonical-host.ts)
runs at the start of origin, platform, and runtime fetch. In production
(!isNonProductionRuntime(env)), a request whose host is not the APP_BASE_URL
host, not a package-app apex or {username}.<package-app host> shape
(parsePackageAppRequestHost / getPackageAppBaseUrl), and not an
APP_LEGACY_HOSTS entry, receives 404 { error: 'not_found' } with
Cache-Control: no-store. Preview, local, and test runtimes skip the check
because preview URLs are *.workers.dev.
Deploy health checks hit the origin at APP_BASE_URL/health when that var is
set, and fall back to the workers.dev URL from wrangler deploy output.
Platform and runtime health checks always use the workers.dev URL
(/__platform/health, /__runtime/health). Those exact probe paths stay
reachable on any host; every other workers.dev path in production is 404.
Auth rate limiting
Credential-accepting POST endpoints share one per-IP auth rate-limit bucket
(auth:ip:<ip>, packages/worker/src/app/rate-limit.ts, default 10 requests
per 60-second window). Deployed environments use the Cloudflare rate-limit
binding (AUTH_RATE_LIMITER in packages/worker/wrangler.jsonc); local dev,
tests, and self-hosted configs fall back to a D1 atomic limiter. Production
fails closed: env validation (packages/worker/src/app/env.ts) rejects a
production runtime without the binding, so the D1 fallback can never silently
become the production limiter. The shared bucket means brute-force attempts
cannot fan out across parallel paths. Covered paths (rateLimitedAuthPaths in
packages/worker/src/origin-handler.ts):
POST /auth(password login/signup)POST /auth/github,POST /auth/google,POST /auth/x,POST /auth/discord(social login start)POST /oauth/authorize(inline OAuth login)POST /password-reset(reset request)POST /password-reset/confirm(reset confirmation)POST /account/password.json(signed-in password change or first-time set)POST /verify/2fa.jsonandPOST /account/two-factor.json(two-factor)POST /webauthn/authentication(passkey authentication)
Public signup uses Turnstile when both TURNSTILE_SITE_KEY and
TURNSTILE_SECRET_KEY are configured.
Excess requests receive 429 Too Many Requests with a Retry-After header. The
D1 approach uses a batched INSERT + COUNT in a single transaction, avoiding the
read-then-write race that KV-backed limiters suffer under concurrency.
Sentry tunnel rate limiting
POST /sentry-tunnel is unauthenticated and exempt from cross-origin
protection, and its DSN check authorizes nothing: a Sentry DSN ships inside the
client bundle. The forward target is always derived from the Worker's own
SENTRY_DSN, so the exposure is ingestion quota and a polluted error stream
rather than an open proxy. The handler therefore consumes a per-IP bucket
(sentry-tunnel:ip:<ip>, 120 requests per 60-second window) before it buffers
the body, using the SENTRY_TUNNEL_RATE_LIMITER binding when deployed and the
same D1 limiter elsewhere. The ceiling clears steady error-replay traffic from
one browser while capping a scripted flood. Requests without a content-length
header are refused with 411 Length Required so no unbounded body is read
before the 10 MB cap can apply.
Password policy
Signup, password-reset confirmation, and signed-in password change enforce a
minimum password length via @kody-internal/shared/password-policy.ts. The
server is the trust boundary; the browser hint is advisory. Login does not
re-check length, so existing accounts are never locked out.
OAuth / MCP hardening
- PKCE is validated at the application layer:
getPkceValidationError(packages/worker/src/oauth-handlers.ts) rejects an authorize request whosecode_challenge_methodis anything other thanS256when acode_challengeis present. Plain PKCE offers no protection against code interception.@cloudflare/workers-oauth-provider0.10+ defaultsallowPlainPKCEto false (S256-only) while allowing confidential clients to omit PKCE. Keep the app-layer check; do not setallowPlainPKCE: true. - Dynamic client registration (
/oauth/register) is intentionally open: the MCP OAuth spec requires it, and clients (including native/public clients using PKCE) rely on it. This is a deliberate acceptance, not a gap. Do not adddisallowPublicClientRegistrationwithout a plan for how MCP clients register. /mcprequires a bearer token whose audience matches the origin (packages/worker/src/mcp-auth.ts).
MCP denial visibility
MCP authentication and authorization denials are recorded in audit_events
(category: 'auth', result: 'failure') via recordMcpAuthDenial
(packages/worker/src/mcp/auth-audit.ts), not in Sentry. Browser auth events
(signup, login, 2FA, password reset, passkeys, verification, and account
credential changes) persist to the same audit_events table through
logAuditEvent with db: auditDatabaseFromEnv(env). A single denial is a
routine agent turn, so it is not an error; a burst from one principal is how
permission probing or a compromised account would look, and the audit log is the
surface built for that — hashed identifiers, 180-day retention, an admin-only
query (adminAuditLogQuery), and the failure-per-day and failure-per-hour
charts on /admin/insights. Two sites record:
handleMcpRequestrejecting a resolved grant (mcp_token_rejected): unidentifiable grant, unverified email, suspended account.assertCallerCanAccessCapabilityrefusing a capability (mcp_capability_denied): missing user, role, permission, or feature flag. This is the single choke point every capability call passes through, so it covers the whole authorization surface.
An hourly cron lane (auth_denial_alert in packages/worker/src/scheduled/,
implemented by checkAuthDenialBurstAndNotify in
packages/worker/src/app/auth-denial-alerts.ts) fans auth.denial.burst to
admin-owned packages when MCP auth denials in the last 60 minutes cross a
threshold (default 50). A KV cooldown prevents re-paging on the same sustained
spike. Charts on /admin/insights remain the browse surface; the event is the
input a notifier package can page from.
A second hourly lane (email_delivery_alert, implemented by
checkEmailDeliveryBurstAndNotify in
packages/worker/src/app/email-delivery-alerts.ts) fans email.delivery.burst
when platform-wide Cloudflare Email Sending outcomes of complained or
bounced in the last 60 minutes cross a threshold (default 20). Those match the
outbound-abuse reputation signals (failed / rejected are not counted).
Cooldown is 6 hours via BUNDLE_ARTIFACTS_KV. This complements the per-user
outbound pause in outbound-abuse.ts — that path stops one account; the cron
pages when the shared sending domain is under platform-wide pressure. Review the
Email delivery health chart on /admin/insights.
A third hourly lane (email_verification_stall_alert, implemented by
checkEmailVerificationStallsAndNotify in
packages/worker/src/app/email-verification-stall-alerts.ts) fans
user.email_verification.stalled when an unverified person account's latest
signup/verify send is still accepted after 60 minutes with no Cloudflare
lifecycle event. The scan pages 50 rows at a time and advances a
BUNDLE_ARTIFACTS_KV watermark so later sends are not starved. Terminal bounces
still use user.email_verification.failed; this lane covers silent drops that
never produce a bounce.
Deliberately not recorded: rejections that happen before a grant resolves —
a missing, empty, or unparseable bearer token. Those are reachable by any
anonymous request, so auditing them would let a stranger drive unbounded D1
writes, and "someone sent a bad token" is not attributable to a principal. The
consequence is that brute-forcing or replaying tokens against /mcp does not
appear in the audit log; flood control for anonymous traffic belongs at the
edge (Cloudflare rate limiting / WAF), not in application writes.
Retired connector routes
Former user-scoped connector ingress (/@{username}/connectors/... and
/connectors/...) is removed. Those paths return 404 for every method,
including WebSocket upgrades. Home automation and other outbound tools use
normal user-added MCP servers (kody.mcp["name"]) instead.
Maintenance route guard
Any /__maintenance/* path that does not match a known handler returns 404
with a JSON body. This prevents unhandled maintenance paths from falling through
to the SPA shell and silently returning 200 OK. Known maintenance handlers are
guarded by a bearer secret comparison.
Secrets and user code execution (in-scope model)
- Saved secrets are encrypted at rest with AES-GCM under
SECRET_STORE_KEYand scoped byuserId(packages/worker/src/mcp/secrets/). Ciphertexts are versioned (v2.<iv>.<ct>) and bound via AES-GCM additional authenticated data to their purpose and owning identity (user:<userId>for user secrets,app:<slug>for platform OAuth client secrets), so a ciphertext copied into another user's row fails to decrypt. User-facing decrypt accepts onlyv2. Unversioned (2-part) payloads are invalid on read. The operator pass atPOST /__maintenance/reencrypt-secretsdecrypts that leftover shape with a maintenance-only helper, re-encrypts asv2(same KEK; optimistic compare so a concurrent user rotation wins; decrypt failures are counted and left unchanged), and is the restore path after a D1 export sealed before the 2026-08-17 format-upgrade pass. Integration-owned OAuth tokens and user-lane client secrets live only as ciphertext onuser_integrations/user_oauth_apps; they are not stored insecret_entries. A 2-part ciphertext carries no AAD, so a copied row would decrypt under the maintenance helper until rewritten. Row swaps already require write access to the database, so this is defense-in-depth, not a standing hole. SECRET_STORE_KEYis escrowed for disaster recovery as a passphrase-sealed blob in the DR backup bucket (solo operator; see Disaster recovery and Secret rotation). The plaintext key must not appear in backup SQL, manifests, or repository files.- Outbound secret use goes through the fetch gateway
(
packages/worker/src/mcp/fetch-gateway.ts), which is deny-by-default: a secret placeholder is only substituted for a host the user explicitly approved in the account UI. Policy writes (allowed hosts/capabilities/packages) are only reachable through the authenticated account UI. - User code runs in a Cloudflare Worker Loader isolate without the parent
env; capabilities are RPC'd back to handlers that enforce the caller'suserId. - The DR control-plane Admin UI is protected by Cloudflare Access plus in-worker
Cf-Access-Jwt-Assertionverification; production restore is a graduated prepare → typed confirmation → Workflow path, never a single click.
Inbound email spam controls
Inbound mail is classified at receive time (accepted or quarantined) before
package subscription dispatch. Per-user sender rules run first (exact address or
domain with subdomain matching; address rules beat domain rules): block
rejects at SMTP before quota is charged, quarantine stores as flagged, allow
bypasses the auth-verdict quarantine. When no rule matches, Kody parses
Authentication-Results (DMARC fail, or SPF fail/softfail without DKIM pass →
quarantine; missing header fails open). Accepted user mail dispatches
email.message.received; quarantined user mail dispatches
email.message.quarantined instead. Quarantined operator system-inbox mail is
stored but suppresses email.system-message.received. Successful
reserved-sender sends fan email.system-message.sent (admin-only) with the sent
correspondence. Reclassification never retroactively fires subscription events.
Users manage rules via email_sender_rule_* (200-rule cap), reclassify via
emailMessageClassify or /account/email, and delete stored messages via
emailMessageDelete or the delete action on /account/email; operators use
admin_system_email_sender_rule_* for system inboxes. Upstream, Cloudflare
Email Routing already rejects mail failing both SPF and DKIM and honors sender
DMARC policy.
Abuse controls (suspension, email pause, compute quotas)
One bad actor can poison shared platform identity — every user sends mail from one platform domain through one Cloudflare Email Sending account, and every sandbox fetch leaves through the same Worker egress. Three controls bound that blast radius:
- Platform suspension (
users.suspended_at). An admin-set kill switch, distinct from a community ban (which only blocks community-surface actions). Enforced fail-closed at every chokepoint: browser session resolution (readAuthenticatedAppUser/loadSessionInfotreat a suspended session as signed out), MCP bearer auth (handleMcpRequestreturns a 403account_suspendedresponse, mirroring the email-verification gate), and both email directions (inbound storage rejects with a boundedaccount-suspensionrejection event; outbound send throws). Set and cleared through the auditedsuspend_user/unsuspend_useractions onPOST /admin/users.json. - Automatic outbound-email pause (
users.email_outbound_paused_at). The delivery queue evaluates provider delivery events (packages/worker/src/email/outbound-abuse.ts): one spam complaint, or five or more bounced sends within a UTC day, pauses that account's outbound email and fansuser.email_outbound.pausedto admin-owned packages. The pause write is idempotent (only transitions NULL), the send path rejects while paused, and the auditedresume_email_outboundadmin action clears it after review. The/admin/insights"Email delivery health" chart shows platform-wide outcome trends so reputation trouble is visible before providers act on it. The hourlyemail_delivery_alertcron fansemail.delivery.burston a platform-wide spike (see above) without replacing this per-user pause. - Compute quotas.
execute_calls_per_dayandoutbound_fetches_per_dayentitlements bound sandbox compute and egress per user per UTC day, with public-ladder weekly hard caps on the same meters (Monday–Sunday; whichever window hits first blocks). Seearchitecture/entitlements.md.
Accepted residual risks and out-of-scope items
These were reviewed and intentionally left as-is for this project. Document any change to these decisions here so future agents do not relitigate them.
- Stateless sessions revoke after a password change and expire at the absolute
lifetime.
kody_sessionis a signed cookie with no server store, so there is no separate "log out everywhere" button. Password reset confirmation and signed-in password change (POST /account/password.json) both revoke every MCP OAuth grant for the user, stampusers.password_changed_at, then revoke again. Password-reset confirmation also disables TOTP, deletes passkeys, and deletesoauth_connections. Browser and package-app sessions carryissuedAt;resolveRequestAuthrejects cookies issued at or before that timestamp (missingissuedAtfails closed once a password change exists) and also rejects cookies whoseissuedAtplus the cookie TTL (7 days, or 30 with remember-me) is in the past, clearing the cookie the same way as a password-change revocation. A cookie that omitsissuedAtis treated as already expired. Package-app sessions expire server-side viaexpiresAt(12 hour fallback fromissuedAtwhen the field is missing)./mcpapplies the password-change timestamp to the access tokencreatedAt(Unix seconds) so already-issued bearers fail closed asinvalid_token; MCP bearer lifetime stays the OAuth provider TTLs. A signed-in password change re-issues the current browser cookie so that tab stays signed in; every other session still dies. Reclaiming an unverified account on a provider-verified social match uses the samepassword_changed_atlockout. - OAuth authorize client reset is grant-scoped. A signed-in user can reset a
mismatched DCR client for their grants only.
deleteClientruns only whenuser_mcp_oauth_clientsshows they own that registration. Shared host clients (Cursor, Claude, Gemini) stay registered for other users. - Account secret reveal is owner-scoped, not password-reauthenticated. See
the "Account secret reveal" section of
architecture/authentication.md. - Signup does not confirm whether an email is registered, except through the
session cookie.
POST /authwithmode: signupfor an address that already has an account returns the same200body as a fresh signup (emailVerificationRequired: true), creates nothing, sends nothing, and auditssignup/email_exists. Only the fresh signup carries aSet-Cookie: kody_sessionheader, so a scripted caller that inspects headers can still distinguish the two; the shared per-IP auth rate limit bounds that probing to 10 attempts a minute. Closing the header side channel means not issuing a session at signup at all (session on verification instead), which is a larger onboarding change than the copy-level fix. Usernames are public identifiers, so a duplicate username still returns409. - Turnstile tokens must come from the request's own hostname.
verifyPublicFormProtectionrejects a siteverify success whosehostnamediffers from the request URL's hostname (logged asturnstile-hostname-mismatch), so a token minted on a preview deployment or a third-party page embedding the same sitekey is not accepted on production. - Sandbox
fetchhas no general SSRF denylist. Secret-bearing requests are constrained by per-secret host allowlists; non-secret requests rely on the Cloudflare Workers platform egress model. - PBKDF2-SHA256 (100k iterations) is used for password hashing rather than a
memory-hard KDF. Workers' WebCrypto has no argon2/scrypt, and Cloudflare's
production runtime rejects PBKDF2 above 100,000 iterations (deriveBits throws
NotSupportedError; local workerd does not enforce the cap), so 100k is the strongest setting the platform allows — below OWASP's 600k PBKDF2-SHA256 guidance. Iteration counts above the runtime cap are rejected during verification (they could never derive in production), and lower-iteration hashes are transparently re-hashed after a successful login (packages/worker/src/password-upgrade.ts), so the setting can be raised without a migration if the platform cap ever lifts. - No CSRF tokens. State-changing requests are protected by
SameSite=Laxcookies plus JSONContent-Typeon mutating endpoints. This is a deliberate decision, restated as invariant 10 above: it holds only while both halves hold, so revisit if any mutating endpoint starts accepting cross-site form posts orSameSite=None. - Production
workers.devhealth probes stay reachable. Origin/health, platform/__platform/health, and runtime/__runtime/healthanswer on the workers.dev trigger so deploy and status probes can hit the script directly. The rest of those hostnames return404. - Package inbound webhook replay protection is opt-in. HMAC over the raw
body without a
replaydeclaration does not bind a timestamp or delivery id, so a captured signed payload can be replayed until the URL secret is rotated. Authors opt in per webhook withreplay.timestampHeaderand/orreplay.deliveryIdHeader.