Authentication
September 17, 2026 · View on GitHub
kody is multi-user. Each signed-in user has a fully isolated assistant: their
own packages, jobs, secrets, memories, MCP servers, email inboxes, and durable
storage. The auth layer is the boundary that establishes which user a request
belongs to before any handler reads or writes data.
kody uses two related authentication models:
- Cookie-based app sessions for browser users
- OAuth bearer tokens for MCP access
Hosted package apps are served from a separate origin in production and use a third, deliberately narrow credential — see Package app origin handoff.
Authorization (roles and permissions) is layered on top of authentication. See
Authorization for the RBAC model, admin routes, and the
any-access exception for account administration.
Browser app sessions
Session cookie behavior is implemented in
packages/worker/src/app/auth-session.ts.
- Cookie name:
kody_session httpOnly: truesameSite: 'Lax'- signed with
COOKIE_SECRET - default max age: 7 days
remember melogin max age: 30 days- remembered sessions are renewed with a fresh 30-day cookie after 14 days of age
- the browser
Max-AgeandresolveRequestAuthboth enforce that absolute lifetime: a cookie whoseissuedAtplus TTL is in the past is treated as signed out and the cookie is cleared. A cookie that omitsissuedAtis rejected the same way.
Referral share links set a separate last-wins kody_ref cookie (one week, not
httpOnly) so a later /signup?ref= overwrites the previous referrer. Signup
persists a referrals row from that cookie. See
Referral Standard credit.
The cookie payload stores:
v: 2stableUserId(the authoritativeusers.stable_user_id)emailissuedAt(epoch ms when the cookie was issued or last renewed)rememberMewhen the login used remember-me
Password reset confirmation and signed-in password change revoke every MCP OAuth
grant for that user, write users.password_changed_at, then revoke again so a
grant created in that window cannot survive. Password-reset confirmation also
disables two-factor authentication, deletes passkeys, and deletes linked sign-in
providers (oauth_connections), then emails the owner listing those cleared
methods.
Session resolution rejects cookies whose issuedAt is missing or at/before that
timestamp, so a reset invalidates every existing browser and package-app
session. It also rejects cookies past the absolute lifetime (issuedAt plus 7
or 30 days) and cookies that omit issuedAt, clearing the cookie. A signed-in
change re-issues the current kody_session cookie with a later issuedAt so
that browser stays signed in and leaves second factors and linked providers in
place. /mcp rejects access tokens whose createdAt is at or before that
timestamp (invalid_token), so already-issued bearers die immediately; hosts
that refresh then hit the revoked grant and must start a new OAuth flow.
users.id never crosses the cookie boundary. Session resolution looks up the
stable id and only then uses the numeric primary key for internal D1 joins.
Version-1 cookies fail closed and require a fresh login.
packages/worker/src/app/handler.ts calls setAuthSessionSecret on each
request so cookie signing and verification are available to handlers.
Login and signup
POST /auth is implemented by packages/worker/src/app/handlers/auth.ts.
- Accepts JSON body with
email,password,mode(loginorsignup), and optionalrememberMefor logins - Uses D1 (
userstable) for user lookups and inserts - Hashes passwords with
@kody-internal/shared/password-hash.ts - Returns signed session cookie via
Set-Cookieon success - Emits structured audit events through
packages/worker/src/audit-log.ts
Signup posture
Anyone can create an account from /signup (password or social). New accounts
start on the free plan. Referral share links (?ref=) are a separate growth
program.
Account signup Kit tagging (password and OAuth signup):
- When
KIT_API_KEYis set and the new account email already exists in Kit, applysigned_up::kody(optional overrideKIT_SIGNED_UP_TAG_ID) - Does not create Kit subscribers for people who were never in Kit
- Leaves existing tags alone
- Best-effort only: Kit errors or a missing key never fail account creation
Exist-only Kit subscriber sync (packages/worker/src/kit/subscriber-sync.ts)
then keeps tags in step with account facts. Lookup by email; skip if missing;
add lifecycle tags; remove only paid tags on cancel. Call sites: signup, email
verify, first MCP connection, first saved package, Stripe plan refresh, and the
hourly kit_subscriber_sync lane. Tags:
signed_up::kodyverified::kodyagent_connected::kodyactivated::kodystandard::kody/pro::kody(cleared when the Stripe plan is no longer paid)
Admins can create a user directly by email from /admin/users. That flow calls
adminCreateUserWithPasswordSetup in
packages/worker/src/identity/admin-user-creation.ts instead of going through
the web route logic directly, so admin MCP capabilities can reuse the same
service. It:
- requires a unique email and either a unique explicit username or an auto-generated unique username derived from the email
- stores a sentinel
password_hashthat never verifies as a usable password - marks
users.email_verified_atimmediately because the admin knows the recipient - creates a
password_resetstoken with a 7-day expiry and returns the/reset-password?token=...setup link to the admin UI - never sends email automatically; the operator copies the displayed setup link into a manual email
There is no privileged "primary user" at runtime. The first admin is still bootstrapped through SQL; after that, admin role assignment happens through admin routes.
Email verification
New signups create an email_verifications token row, send a verification link
through packages/worker/src/app/email/cloudflare-email.ts, and store
users.email_verified_at only after GET /verify-email?token=... succeeds.
Verification tokens expire after 24 hours and only token hashes are stored.
Signup fails hard when the verification email cannot be sent: the created user
row is rolled back so the email/username can be retried. An account must never
exist without a way to verify it. The only exception is non-production runtimes
(local dev, preview, test — see isNonProductionRuntime) with no Cloudflare
email sender configured; there the send is skipped and accounts are verified
through seeded tokens instead.
Additional email destinations (addresses emailSend may use besides
users.email) reuse the same 24-hour hashed-token link pattern:
email_notification_destinations plus
pending_email_destination_verifications, sent from kody@<apex>, confirmed at
GET /verify-email-destination?token=.... HEAD probes inspect the token
without consuming it, so link scanners do not burn an unused link. A successful
GET keeps that token until it expires so a repeat click is idempotent; unused
links from earlier resends stay valid until expiry or the address verifies.
Identity email change and release stay on their existing flows; destinations do
not own users.email. Add, resend, set-default, and remove live at
/account/email, /account/email-destinations.json, and the
emailDestination* capabilities (3 requests per 15 minutes for add/resend).
Re-adding a pending unverified address resends a verification email. The cap is
5 extras besides the identity email. Unverified extras never receive mail.
Destinations expand the verified to set only; mail comes from
{username}@{platform}. A successful Cloudflare send stores that
provider_message_id in transactional_email_delivery_index with kind
email_destination_verification so later Cloudflare lifecycle events can
attribute bounce, deferred, or delivered to the extra-address message. A newer
send retires only the previous index row for that same recipient, so other
pending extras stay attributable. Those events stay on the index (and the
delivery-alert bounce/complaint table); they do not write
users.email_verification_delivery_*.
Signed-in users with an unverified email can request a fresh link with
POST /account/resend-verification.json
(packages/worker/src/app/handlers/account-resend-verification.ts), surfaced as
a "Resend verification email" button on /pending-verification, /account,
/onboarding, and /oauth/authorize. The endpoint reuses
createEmailVerification (invalidating older tokens) and is rate-limited per
user (3 requests per 15 minutes).
users.email_verified_at records whether an account's email is verified.
Accounts with a non-null value are treated as verified; normal signup leaves it
null until GET /verify-email?token=... succeeds. Seeded and test fixture
accounts are created verified. Unverified accounts can sign in and see their
status on /account.
Verification mail is sent from kody@<SYSTEM_EMAIL_DOMAIN> through Cloudflare
Email Sending and sets Reply-To: support@<same domain> so human replies land
on support rather than the transactional sender. Provider accept is not
delivery: the send stores provider_message_id in
transactional_email_delivery_index with kind email_verification and sets
users.email_verification_delivery_status to accepted. Later Cloudflare
lifecycle events (delivered, bounced, failed, rejected, complained)
update that status. A Fastmail-style sender-domain/IP block (RLR613, RLR813,
"blacklisted") is classified as sender_block. The pending-verification,
account, and OAuth authorize UIs surface the bounce instead of staying silently
pending, and POST /account/resend-verification.json refuses to retry into a
known sender_block. The first terminal failure fans
user.email_verification.failed to admin-owned packages. When a send stays
accepted for an hour with no lifecycle event, the hourly
email_verification_stall_alert lane fans user.email_verification.stalled.
/admin/users and adminUserList accept verification=stalled for the same
derived set. See
the admin events guide
and
stalled verification.
Operators can unblock a stranded signup without a raw D1 write:
adminUserVerify(mark_verifiedormint_verify_url) and the matching/admin/usersactions (mark_email_verified,mint_verify_url)mark_verifiedsetsemail_verified_at(idempotent), clears outstanding tokens, and clears the delivery fieldsmint_verify_urlreturns a one-time/verify-email?token=...link so the operator can send it over a path that is notkody.codes- both paths audit the actor and
target_stable_user_id
Unverified accounts can still use browser sessions (sign in, manage account, resend verification), but they must verify before MCP OAuth authorization or assistant features:
- Signup: password signup keeps the authenticated session and lands on
/pending-verification(preserving a saferedirectTosuch as an OAuth authorize URL). Users can resend the verification email and continue once the link succeeds; continue returns toredirectTowhen present, otherwise/onboarding. - Onboarding (
/onboarding): verified users only. Unverified HTML requests redirect to/pending-verification. Loader/API data still exposesemailVerifiedand withholds MCP URL/setup until verified as defense in depth.needsOnboardingmeans incomplete overall setup (!emailVerifiedor no MCP grant). Account keeps an inline verification card for resend/status; home and account banners do not show the connect-agent callout while unverified. - MCP OAuth authorize:
/oauth/authorizerejects approval before creating a grant/token when the account email is unverified (403 email_verification_required). The authorize HTML is server-rendered with client/scopes from/oauth/authorize-infoand the signed-in app session from the SSR shell, so first paint already shows approve, inline login, or verify-email instead of a client/sessionloading state. Approve stays disabled until client hydration so a native GET cannot replace the OAuth query with the honeypot field; the consent form POSTs to the current pathname+search with a hiddendecision=approveif it is submitted before handlers bind. A/oauth/authorizerequest that has noclient_idbut includeskody_hpis treated as that interrupted resubmit and returns a recoverable "start the connection again" message instead ofclient_id is required. The authorize UI keeps inline verification/resend controls and the original OAuth query so verification in another tab can resume without restarting the host connection. - MCP requests:
handleMcpRequestinpackages/worker/src/mcp-auth.tsis the single chokepoint for/mcp. After token validation it checksusers.email_verified_at(viaisAccountEmailVerified) and rejects unverified — or unidentifiable — accounts with a403 email_verification_requiredJSON response pointing at/account. The gate fails closed: when verification cannot be established, the request is rejected. - Inbound email:
handleInboundEmailinpackages/worker/src/email/inbound.tsrejects routed mail for unverified accounts right after username routing (setRejectplus a boundedrejectedemail delivery event); nothing is stored.
Platform suspension (users.suspended_at, set by admins from /admin/users)
follows the same chokepoint pattern and also fails closed: browser session
resolution treats a suspended session as signed out (readAuthenticatedAppUser
/ loadSessionInfo), handleMcpRequest rejects with a 403 account_suspended
JSON response after the verification gate, and both email directions reject
(inbound with a bounded account-suspension rejection event, outbound with an
error). See the "Abuse controls" section of security.md.
- Email capabilities: every capability in the MCP
emaildomain callsrequireVerifiedEmailAccountUser(packages/worker/src/mcp/capabilities/email/require-verified-user.ts) as defense-in-depth for callers that do not pass through/mcp(execute runtime, package jobs). Outbound sending additionally re-checks the account insidepackages/worker/src/email/outbound.tsbefore sending from the platform-assigned{username}@<platform domain>sender address.
Password policy
New passwords (signup and password-reset confirmation) must satisfy the
server-side policy in @kody-internal/shared/password-policy.ts
(minPasswordLength, 8). The server is the trust boundary; the browser hint is
advisory only. Login does not re-check length so pre-existing accounts are
never locked out.
Two-factor authentication and passkeys
Both are opt-in and adapted from the Epic Stack.
TOTP two-factor (packages/worker/src/app/two-factor.ts):
- The
verificationstable (Epic Stack shape:type+targetwith TOTP config) stores secrets. An active row withtype = '2fa'andtarget = <db user id>is the "two-factor enabled" flag; a2fa-verifyrow holds a pending setup that only activates once the user confirms a generated code at/account/two-factor(managed bypackages/worker/src/app/handlers/account-two-factor.ts). - When a 2FA account logs in with a password (or social login), the handler does
not issue
kody_session. It sets the short-lived signedkody_verifycookie (packages/worker/src/app/verify-session.ts, 10 minutes) and the client redirects to/verify.POST /verify/2fa.json(packages/worker/src/app/handlers/verify.ts) checks the TOTP code and only then issues the real session cookie. Passkey sign-in skips this step: a verified WebAuthn assertion already requires possession of the authenticator plus user verification (biometric/PIN), so it is treated as MFA-complete. - Disabling 2FA requires a fresh code. The inline OAuth password form
(
packages/worker/src/oauth-handlers.ts) rejects 2FA accounts and directs them to establish a browser session first, since that flow has no TOTP step.
Passkeys / WebAuthn (packages/worker/src/app/webauthn.ts,
packages/worker/src/app/passkeys.ts):
- Registration and authentication ceremonies live in
packages/worker/src/app/handlers/webauthn.tsusing@simplewebauthn/server; challenges ride in the short-lived signedkody_webauthn_challengecookie, so no server-side ceremony state exists. - The relying party id/origin derive from the request host. WebAuthn requires a
registrable domain, so Playwright passkey tests navigate via
localhostrather than127.0.0.1. - Passkeys are stored per user in the
passkeystable and managed at/account/passkeys. Passkey sign-in is MFA-complete on its own (userVerification: 'required'): accounts with TOTP enabled go straight to a session and do not visit/verify. POST /verify/2fa.json,POST /account/two-factor.json, andPOST /webauthn/authenticationshare the per-IP auth rate-limit bucket with the other credential-accepting endpoints (packages/worker/src/index.ts).- Re-enrolling a new authenticator while two-factor is active is rejected; users must disable first (which requires a current code), so a hijacked session cannot silently swap the second factor.
- Known limitation: sessions are stateless signed cookies, so enabling two-factor (like changing a password) cannot revoke session cookies issued earlier; they stay valid until they expire. Starting a new login does clear that browser's session cookie while the second factor is pending.
Account deletion
POST /account/delete is implemented by
packages/worker/src/app/handlers/account-delete.ts and orchestrated by
packages/worker/src/app/account-deletion.ts.
- Requires an active
kody_sessioncookie and a JSON body withconfirmationset toGOODBYE KODY. Accounts that have a usable password also re-enterpassword. Social-login and admin-created accounts with a sentinel hash confirm with the phrase alone. Failures emit an audit event withaction: 'account_delete',result: 'failure'. - The Account settings page opens a modal for this confirmation before posting
POST /account/delete. - Successful deletion best-effort fans
user.deletedto admin-owned packages. Successful password signup, social-login signup, and admin person account creation fanuser.created. See the admin events guide. - On success, runs a full per-user cascade across:
- all
user_id-scoped D1 tables (children before parents), - the shared Vectorize capability index, removing memory, job and saved-package entries by id,
BUNDLE_ARTIFACTS_KVkeys captured frompublished_bundle_artifactsandarchived_job_artifacts,- the user's
StorageRunnerDurable Objects via the user-scopedstorageRunnerRpcstub, - all OAuth grants for the user (and the provider clients the user minted) via the bound OAuth provider,
- the user row itself last so a partial failure can be retried.
- all
env.OAUTH_PROVIDERis injected by@cloudflare/workers-oauth-provideronly inside its ownfetchwrapper, so it exists forPOST /account/deletebut not for the hourly unverified-account purge (JobsHost.runScheduledLaneRPC on origin) or foradminUnverifiedAccountPurgeRunwhen served from the sessionfulMCPDurable Object on kody-platform. Those paths callresolveOAuthHelpers(packages/worker/src/oauth-helpers.ts), which returnsenv.OAUTH_PROVIDERwhen present and otherwise builds the sameOAuthHelpersImplthrough the library'sgetOAuthApi(options, env)overOAUTH_KV. The non-handler provider options (endpoints, scopes, TTLs, CIMD,onError) live inpackages/worker/src/oauth-provider-options.tsand are spread into both the originOAuthProviderand the fallback, so storage semantics cannot drift; the fallback supplies inert 404 handlers because the helpers API never routes a request. The fallback loads the library from the pre-bundledoauth-provider.mjsadditional module (tools/build-worker-bundler-modules.ts,find_additional_modules) because wrangler inlines plain dynamic imports into the main module; the startup bundle check forbids the provider package in the platform/runtime entries so it stays off their startup path. Deletion only reports "OAuth grants were not revoked" when bothOAUTH_PROVIDERandOAUTH_KVare missing. Account export'soauth_grantssection (packages/worker/src/account/export.ts) uses the same reader forlistUserGrants, soaccountExportManifest/accountExportSectionserved from the platformMCPDurable Object include grant metadata too.- After the user row is gone, origin clears the UserMeter deletion tombstone
purge()restored.users.stable_user_idis SHA-256 of the signup email, so a later account with that email reuses the same Durable Object id and must not inherit the previous deletion fence. Username reuse with a different email is a differentstable_user_idand does not share that object. - Returns a structured
{ ok, deletedRowCounts, deletedKvKeys, revokedOAuthGrants, clearedDurableObjects, deletedVectors, warnings }payload alongside aSet-Cookiethat destroys the session.
Related handlers:
GET /loginandGET /signup:packages/worker/src/app/handlers/auth-page.tsPOST /logout:packages/worker/src/app/handlers/logout.tsPOST /session:packages/worker/src/app/handlers/session.tsfor session status checksGET /account:packages/worker/src/app/handlers/account.ts(redirects to login if missing session)
Client session refresh behavior
The app shell (packages/worker/client/app.tsx) refreshes session state after
initial load and on client-side navigation events. Navigation-triggered
refreshes are throttled (30s) to avoid a /session round trip on every SPA
navigation, but the throttle never applies to refreshes that follow a mutation:
the client router (packages/worker/client/client-router.tsx) emits a
mutation event after every form POST it submits (exposed as
listenToRouterMutations), and the shell marks its session state stale so the
follow-up redirect navigation refreshes it regardless of the throttle —
auth-changing POSTs like /logout update the top nav immediately. If an
in-flight refresh is aborted, the client keeps the last known ready session
instead of overwriting it with null. This prevents transient logged-out UI
during concurrent re-renders.
Password reset
Password reset handlers are in
packages/worker/src/app/handlers/password-reset.ts.
POST /password-resetcreates a one-time token and stores only its hash- the response is uniform for registered and unregistered addresses in both body
and latency: after the user lookup the token writes and the email send are
deferred past the response (
packages/worker/src/deferred-work.ts, kept alive byctx.waitUntil), so response time cannot be used to enumerate accounts POST /password-reset/confirmverifies token hash and expiry, updates the password, revokes every MCP OAuth grant for that user, stampsusers.password_changed_at, then revokes again so a grant created in that window cannot survive. It also disables two-factor authentication, deletes passkeys andoauth_connections, and sends a confirmation email listing those cleared sign-in methods- Session cookies and package-app sessions whose
issuedAtis missing or at or beforepassword_changed_atfail closed;/mcprejects access tokens whosecreatedAtis at or before that timestamp (invalid_token) - reset tokens expire after 1 hour
- when configured, email delivery is done via Cloudflare Email API
- when required Cloudflare Email API credentials are unset, the helper logs a redacted diagnostic without the email body or token URL to prevent token leakage in logs
- public reset forms include a honeypot whose field name is not an HTML
autocomplete token (
kody_hpinpackages/worker/universal/public-form-protection.ts), so password managers do not treat it as a login website field
Password change
Signed-in password change is POST /account/password.json
(packages/worker/src/app/handlers/account-password.ts), exposed on /account.
- Requires the current password when the account has a usable password hash
- Accounts that only sign in with a connected provider or passkey can set a first password without a current password
- New passwords go through
getPasswordPolicyError - Shares
applyPasswordChangewith reset confirmation: revoke MCP grants, stampusers.password_changed_at, revoke again, delete outstanding reset tokens. Unlike reset confirmation, this path does not clear two-factor, passkeys, or linked providers - Re-issues the current session cookie with
issuedAtstrictly afterpassword_changed_atso this browser stays signed in - Joins the shared auth rate-limit bucket and a per-user password-change budget
Package app origin handoff
Hosted package apps run on their own registrable domain in production
(PACKAGE_APP_BASE_URL) so author-supplied code is cross-site from the app
origin — see
Hosted package app origin isolation
for why. Production serves each owner's apps on a per-user subdomain of that
domain (https://{username}.kody.run/packages/{kodyId}/...); the apex only
redirects. That means kody_session never reaches them, so the package-app
subdomain needs its own, deliberately smaller credential.
Handoff token (packages/worker/src/app/package-app-handoff.ts). When a
signed-in owner requests /@{username}/packages/{kodyId}/... on the app origin,
the app origin mints <base64url payload>.<HMAC-SHA256>:
- signed with
COOKIE_SECRETover a purpose-labelled message (kody-package-app-handoff:v2), so it is not interchangeable with any other signed value - payload binds
{ stableUserId, username, kodyId, exp, jti }; the package-app subdomain rejects a token whoseusername/kodyIddo not match the requested path - 60 second lifetime
- single use:
jtiis burned inBUNDLE_ARTIFACTS_KVfor 60 seconds on first use. The burn happens after the path binding is checked, so a token presented on the wrong package path is refused without being consumed — a mistyped URL must not cost the owner a handoff they still hold. Replay protection is best effort (KV is eventually consistent) and is skipped when the binding is missing; signature, expiry, and the path binding always fail closed.
It travels in the __kody_handoff query parameter of a cross-origin redirect to
the owner's package-app subdomain, which is why it is deliberately this weak. A
token in a URL is exposed to browser history, referrers, and anything that logs
URLs; the subdomain redirects straight to the same URL without it, which reduces
that exposure but cannot eliminate it. The 60-second expiry and the single-use
burn are what bound the damage when a token does leak. A request that still
carries the parameter is rewritten without it before package code sees it.
Mint and consume must share COOKIE_SECRET. In production the app-origin mint
(/@{username}/packages/...) is forwarded to kody-runtime, and the subdomain
exchange is served by that same script's zone routes. Production CI therefore
syncs COOKIE_SECRET onto the unsuffixed kody-runtime script (--env "" plus
--name); wrangler secret bulk --env production --name kody-runtime still
writes kody-runtime-production, which the runtime deploy does not serve. If a
leftover main-worker route still receives {username}.kody.run, the main Worker
must forward it too (isRuntimeWorkerOwnedRequest matches every package-app
host, not only the apex). A COOKIE_SECRET mismatch, or a missing secret
swallowed as "invalid token", leaves the visitor on the 403 page with
__kody_handoff still in the URL and no Set-Cookie. Missing COOKIE_SECRET
on consume fails closed with 500; signature / expiry / path / replay rejects log
a reason without the token and set X-Kody-Handoff: rejected.
Package-app session cookie
(packages/worker/src/app/package-app-session.ts). Exchanging a valid token on
the owner's subdomain sets __Host-kody_pkg_session on secure requests (plain
kody_pkg_session on insecure local HTTP only):
httpOnly: true,sameSite: 'Lax',path: '/',secureper request- max age is the remaining lifetime of the
kody_sessionthat minted the handoff (7 days, or 30 days with remember-me), snapshotted into the token assessExpand into the cookie asexpiresAt. BrowserMax-Ageand a server-sideexpiresAtcheck inreadPackageAppSessionboth use that instant, so the package-app cookie cannot outlive the parent session. Remember-me renewal on the app origin does not extend an already-issued package-app cookie; a later handoff mints a new snapshot. Tokens withoutsessExp, and cookies withoutexpiresAt, use a 12 hour lifetime from mint/issuedAt - signed with a derived secret,
sha256Base64Url('kody-package-app-session:v2:' + COOKIE_SECRET), so a value signed for this cookie can never verify as akody_session - payload is
{ v, stableUserId, pkgUsername, issuedAt, expiresAt? }— a shape the app session schema rejects, so the two cannot be confused even by name substitution. New cookies always setexpiresAt; readers treat a missing field as the 12 hour mint/issuedAtfallback above. - the
__Host-prefix on secure requests forbids aDomainattribute, so sibling subdomains cannot plant a shadow cookie under this name - sibling subdomains are still same-site (until the Public Suffix List entry),
so mutating requests additionally require any
Originheader to match the subdomain itself — see the same-site paragraph in security.md
It authorizes hosted package-app serving for one account on that account's
subdomain and nothing else: the app origin has no code path that reads it, and
the package-app domain has no first-party routes. Every request re-resolves the
account from D1 (resolvePackageAppOwnerByStableUserId) and fails closed for
unknown, deleting, or suspended accounts, for sessions issued at or before
users.password_changed_at — the same rules browser sessions follow — and when
the session account's username does not match the subdomain label and requested
package path. Confirmed local, preview, and test runtimes with
PACKAGE_APP_BASE_URL unset never mint either credential; they serve package
apps inline behind kody_session. Production requires a separate registrable
package-app origin and returns 500 instead of falling back inline when that
configuration is missing or unsafe.
Account secret reveal
The account secrets API (packages/worker/src/app/handlers/account-secrets.ts)
returns a decrypted secret value to the owner only, and only for the
selected secret:
GET /account/secrets.json?selected=<secretId>resolves the value into theselectedSecret.valuefield of the JSON payload- Requires an active
kody_sessioncookie; the value is scoped to the authenticated user'smcpUser.userId, so a session can only ever read its own secrets - All responses set
Cache-Control: no-store - There is no separate
/account/secrets/revealendpoint and no password reauthentication step — revealing a secret is inside the owner's own trust boundary (same-origin, session-authenticated)
This is an intentional design decision, not an oversight. The exfiltration concern (XSS or a stolen session reading the owner's secrets) is mitigated by:
- the strict first-party
Content-Security-Policy(script-src 'self', no'unsafe-inline') plusHttpOnly+SameSite=Laxsession cookies (seedocs/contributing/security.md), which make script-injection theft hard - decryption at rest and per-user scoping on every read
Residual risk: a stolen session cookie can read the owning user's own secrets
until it expires (sessions are stateless — see the "Accepted residual risks"
section of docs/contributing/security.md). If a future change needs a stronger
control, the recommended approach is a password-reauthenticated reveal endpoint
combined with server-side session invalidation. Do not silently reintroduce
plaintext reveal without also considering that hardening.
Social login (GitHub / Google / X / Discord)
Kody can act as an OAuth 2.0 client of GitHub, Google, X, and Discord for
browser sign-in. Provider identities live in the oauth_connections table;
handlers live in packages/worker/src/app/handlers/auth-provider.ts with the
provider definitions in packages/worker/src/app/oauth-providers.ts. Discord
connections also best-effort join the official Kody Discord (guilds.join plus
the operator bot) and assign or remove configured guild roles
(packages/worker/src/discord/guild-role.ts) without persisting the login
token. The member role is assigned on connect; Standard and Pro roles follow
users.stripe_plan.
POST /auth/:providerstarts the flow (CSRF state + PKCE verifier in the signedkody_oauth_logincookie);GET /auth/:provider/callbackcompletes it and issues the normalkody_sessioncookie. The first-party UI fetches the start endpoint withAccept: application/jsonand navigates to the returned authorize URL itself, because the CSP locksform-actionandconnect-srcto'self'- Existing connections sign in directly; the two-factor gate applies exactly as for password logins (passkey sign-in skips TOTP)
- A signed-in user whose live
email_verified_atis set hitting the callback links the provider identity to their account, managed from the/account"Connected accounts" card backed by/account/connections.json(disconnect is refused when the connection is the only sign-in method). An unverified signed-in session is refused (email-unverified) so a password squat cannot attach a provider and skip the unverified-account purge./discordis the public Connect Discord page (one action joins the official server and links the account) - A provider-verified email matching an existing verified account auto-links
and signs in. A match against an unverified account reclaims that row
first (unusable password sentinel,
password_changed_atlockout, TOTP / passkeys / otheroauth_connections/ reset tokens cleared) so a squatted password signup cannot keep access after the real owner signs in with the provider; otherwise a new account is created. Signup is open and new accounts start on thefreeplan - Buttons only render for providers whose client id/secret env vars are set;
MOCK_-prefixed client ids activate an in-worker mock flow on non-production runtimes for dev and E2E tests
Setup and operational details: docs/contributing/social-login.md.
OAuth for MCP
OAuth endpoints are implemented in packages/worker/src/oauth-handlers.ts and
routed from packages/worker/src/index.ts.
- Authorization endpoint:
/oauth/authorize - Token endpoint:
/oauth/token(via provider; also RFC 7009 revocation) - Client registration:
/oauth/register(via provider), plus Client ID Metadata Documents (clientIdMetadataDocumentEnabledinpackages/worker/src/origin-handler.ts): a client may present an HTTPS URL as itsclient_idwith no registration step. Signed-in users can also mint a confidential pre-registered client from/account/mcp-oauth-clients(Account → Connections → Advanced). That page is user-minted clients, not inbound host grants. Account → Connections (/account/connections) lists inbound grants fromlistUserGrants(paged) joined withlookupClientfor a best-effort label, authorized time, and revoke. The account UI groups those uniqueclientIds by display name, shows a public icon when the host kind already has an SVG, and sorts last-used newest-first, then connected time. Timestamps are grantcreatedAt(connected time) plus last-used from UserMeterinbound_mcp_connection_last_used(successful/mcpbearer validation, keyed by inbound OAuthclientId, 5-minute Durable Object debounce,waitUntilso it is not on the awaited hot path). Unknown last-used renders as "never" and is the revoke signal; Connected remains grantcreatedAt. Revoke deletes the last-used row with the grant. Onboarding Step 3 completion is uniqueclientIds ≥ 2, not raw grant count and notusers.mcp_client_name.user_mcp_oauth_clientsstores the account-owned metadata. The provider stores the secret hash inOAUTH_KVviaenv.OAUTH_PROVIDER.createClient(). List and revoke are scoped to the owninguser_id. The plaintext secret is shown once and never written to D1. MCP2026-07-28deprecates RFC 7591 dynamic registration in favor of CIMD, so both stay enabled: clients without a pre-registered credential that do not use CIMD register via/oauth/register. Failed CIMD fetches throwCimdFetchError: authorize maps that to an unknown-client page, and the token endpoint still returns genericinvalid_client. Any DCR retry after that is the client's own recovery, not a server-side fallback. CIMD metadata fetches rely on theglobal_fetch_strictly_publiccompatibility flag inpackages/worker/wrangler.jsoncfor SSRF safety; the provider only advertisesclient_id_metadata_document_supportedwhen both are set. ChatGPT CIMD documents preferprivate_key_jwtwhile also offeringnone; the provider negotiates the mutually supported public methodnoneand requires PKCE.onError.internalcategoryclient-id-metadata-documentis reported to Sentry. Authorization-server metadata advertisesS256PKCE only. - Kody-as-client (user-added remote MCP servers) hosts its own CIMD at
/oauth/client-metadata.json. The document is origin-exact:client_idmatches the fetch URL, andredirect_urislists{origin}/account/mcp-servers/oauth/callback. The MCP client OAuth provider setsclientMetadataUrlto the canonical HTTPS document URL so the SDK prefers CIMD and falls back to DCR. Localhttporigins omitclientMetadataUrl. - The official CLI (
@kodycodes/cli) hosts its CIMD at/oauth/cli-client-metadata.json. The document is origin-exact:client_idmatches the fetch URL, andredirect_urislists the fixed loopbackhttp://127.0.0.1:43742/callbackso the CLI can use SEP-991 instead of deprecated DCR. - Supported scopes:
openid,profile,email(additive;openidenables ID tokens and the UserInfo endpoint). These are OIDC identity claims, not a permission menu. MCP access is one grant: a valid token for this origin's/mcpaudience receives the full assistant./oauth/authorizedescribes that grant in plain language and keeps the OIDC names in a technical disclosure. See 0049. - Kody's MCP authorization server is an OAuth 2.1 + OpenID Connect
Authorization Code provider with CIMD and RFC 9728 resource metadata. Issuer
is the app origin (
getAppBaseUrl).subis the accountstable_user_id. ID tokens are RS256 JWTs signed withOIDC_SIGNING_PRIVATE_KEY_PEM(kid=OIDC_SIGNING_KEY_ID). Discovery:/.well-known/openid-configuration; JWKS:/.well-known/jwks.json; UserInfo:/oauth/userinfo(Bearer access token; fail-closed when email is unverified). RP-Initiated Logout:/oauth/logout. RFC 7009 revocation is the token endpoint (/oauth/token); both/.well-known/openid-configurationand/.well-known/oauth-authorization-serveradvertise that URL asrevocation_endpoint. OpenID Connect discovery also listsrevocation_endpoint_auth_methods_supportedmatchingtoken_endpoint_auth_methods_supported(none,client_secret_basic,client_secret_post) so public clients are not left with RFC 8414's omitted default ofclient_secret_basiconly. Authorization-server metadata from@cloudflare/workers-oauth-provideromits that auth-methods array. Token responses from/oauth/tokengain anid_tokenwhen the granted scope includesopenid(authorization_code and refresh_token grants; refresh omitsnonce). The provider handles the token path internally and does not injectenv.OAUTH_PROVIDERthere (or on UserInfo/logout, which run beforeoauthProvider.fetch), so those OIDC helpers come fromresolveOAuthHelpersoverOAUTH_KV. Implicit and Hybrid response types are not advertised or accepted. Authorization responses that send the client back toredirect_uri(successfulcoderedirects and OAuth/OIDC error redirects) include RFC 9207issequal to the discovery issuer (getAppBaseUrl). Authorization-server metadata advertisesauthorization_response_iss_parameter_supported: true.@cloudflare/workers-oauth-provideraddsisson success only whenAuthRequest.issueris set; Kody assigns that field fromgetAppBaseUrland stampsisson every outbound client redirect so a missing provider field cannot omit it. Local HTML or JSON authorize errors that do not redirect to the client do not includeiss. Kody is not OpenID Certified./api/meremains the OAuth-protected JSON helper for grant props; it is not the OIDC UserInfo endpoint. - On
/oauth/authorize, unauthenticated users can log in inline or via top-nav auth links; those links preserve the full authorize URL inredirectToso successful login returns to the original OAuth request. Password signup lands on/pending-verificationwith that saferedirectTopreserved for continue-after-verify. The authorize tab itself should stay open when the email link is opened elsewhere, so the original OAuth query remains resumable. Signed-in vs signed-out chrome on that page comes from the SSR-embedded app session; the route does not wait on a separate browser/sessionfetch before rendering approve or login. The approve control stays inert until hydration so the visible button cannot submit a GET that dropsclient_id. - Approval is rejected before
completeAuthorizationwhen the account email is unverified, so no grant/token is created until verification succeeds.
Token lifetimes are set on the OAuthProvider in
packages/worker/src/oauth-provider-options.ts:
- Access tokens keep the provider default of 1 hour
- Refresh tokens are issued with no expiry (
refreshTokenTTL: undefined). The provider default is 30 days; omitting the option keeps that default, so the explicitundefinedis required - Dynamically registered clients are stored with no KV expiry
(
clientRegistrationTTL: undefined). The provider default is 90 days. Clients created throughOAuthHelpers.createClient()are unexpiring. A DCR client record that already has a KV TTL still expires at that instant unless the host re-registers
The provider still rotates refresh tokens on use and keeps only the current hash
plus the immediately previous hash on the grant. MCP hosts on one machine often
share one stored client and each keep their own copy of the last refresh token
they saw, so a second host presenting the previous token must not mint a third
token and invalidate the sibling. packages/worker/src/oauth-refresh-family.ts
intercepts POST /oauth/token refresh grants:
- Reuse of the current family's previous refresh token returns the stored current access and refresh tokens. The grant does not rotate again, even when that access token is near expiry — only presenting the current refresh token rotates. The sibling can then refresh with the current token.
- A one-hour replay record keyed by the consumed refresh-token hash returns that
same current family when the hash still matches the grant's current token,
even when the stored access token is near expiry. After the next rotation the
old replay no longer matches and the consumed token is
invalid_grant. The one-hour TTL is maximum retention, not guaranteed acceptance. - Tokens that are neither current, previous, nor a still-matching replay do not mint. Stolen refresh tokens therefore cannot walk the family forever; they work only while they remain the current or previous token, or while a replay still matches the grant's current hash.
- Previous-token reuse and matching replay skip the isolate lock so a
current-token rotation cannot turn a still-valid previous token into
invalid_grant. Provider rotation for oneuserId/grantIdpair is serialized in the handling isolate. After a rotation the isolate remembers the new family in memory so a waiter can reuse it even when Workers KV still serves the pre-rotation miss. Isolate memory keeps only the current replay entry and is dropped when the handling isolate revokes that grant. Revoke marks the grant forgotten immediately so lock-skipping reuse cannot serve cached tokens, then waits for any in-flight persist so a later remember cannot rewrite deleted-grant tokens. That is isolate-local, not a cross-isolate Durable Object lock. Two current-token refreshes that land on different isolates can still race the provider before a snapshot is visible. A revoke that lands on another isolate can leave residual memory until that isolate exits. - Encrypted snapshots live in
BUNDLE_ARTIFACTS_KVunderderived-cache:v1:mcp-oauth-refresh-family:/-replay:with KV TTLs of two hours and one hour. Retention is the TTL, so account deletion does not sweep those keys. Snapshot writes are best-effort: a KV or encrypt failure does not replace the provider's minted response.
/mcp is protected by packages/worker/src/mcp-auth.ts:
- Requires
Authorization: Bearer <token> - Token is validated via OAuth provider helpers (
unwrapToken) - Audience must match the app origin or
<origin>/mcp - Token OIDC scopes are not checked for MCP capability access. Audience, identity, email verification, suspension, and password-change gates apply; the token is then the full assistant
- Requests without a Bearer token return
401withWWW-Authenticatecarrying RFC 9728resource_metadata(and scopes). RFC 6750 omitserrorwhen credentials are absent - A present but rejected Bearer token returns
401with RFC 6750error="invalid_token"pluserror_descriptionand the sameresource_metadata, so hosts that refresh on that challenge can do so without starting a new browser login - The account email must be verified; unverified accounts receive a
403 email_verification_requiredresponse (see the email verification section above)
What to read when changing auth
packages/worker/src/index.tsfor route order and integration pointspackages/worker/src/oauth-handlers.tsfor OAuth authorization logicpackages/worker/src/oauth-refresh-family.tsfor refresh-token family reuse on/oauth/tokenpackages/worker/src/mcp-auth.tsfor MCP token enforcementpackages/worker/src/app/auth-session.tsfor cookie format/signingpackages/worker/src/app/handlers/auth.tsfor app login/signup flowpackages/worker/src/identity/admin-user-creation.tsfor admin-created account setup linkspackages/worker/src/app/email-verification.ts,packages/worker/src/app/handlers/verify-email.ts, andpackages/worker/src/app/handlers/account-resend-verification.tsfor verification tokens and resendspackages/worker/src/email/destinations.ts,packages/worker/src/email/destination-verification.ts, andpackages/worker/src/app/handlers/account-email-destinations.tsfor extra email destinationspackages/worker/src/app/handlers/account-secrets.tsfor owner-scoped secret revealpackages/worker/src/app/deployment-env.tsfor non-production runtime detection (developer-only routes, mock OAuth)