Security model
This page tells you, as the operator, where the trust boundary of a canvas-drop instance is, which five guarantees hold inside it, and which config choices keep them intact. Read it before you put an instance in front of colleagues.
canvas-drop hosts arbitrary, often AI-generated, web artifacts for a trusted organization. Everyone who reaches a canvas has already passed your sign-in and the email allowlist. It is not built to defend against the hostile internet. Inside that boundary it holds five hard invariants; beyond them it stays open and permissive, because the product is meant to be frictionless among colleagues.
The two settings that matter most
| Env var | Production choice | Why it matters |
|---|---|---|
CANVAS_DROP_AUTH_MODE |
proxy (or oidc when nothing fronts the app) |
Decides how identity is established (invariant 1). Default dev. |
CANVAS_DROP_URL_MODE |
subdomain |
Gives each canvas its own origin so the browser isolates canvases from each other (invariant 4). |
A production baseline behind an identity-aware proxy that forwards a signed JWT:
CANVAS_DROP_AUTH_MODE=proxy
CANVAS_DROP_URL_MODE=subdomain
CANVAS_DROP_BASE_URL=https://canvases.example.com
CANVAS_DROP_ALLOWED_EMAIL_DOMAINS=example.com
CANVAS_DROP_AUTH_PROXY_JWT_JWKS_URL=https://<your-proxy>/<its-jwks-path>
CANVAS_DROP_AUTH_PROXY_JWT_ISSUER=https://<your-proxy>
CANVAS_DROP_AUTH_PROXY_JWT_AUDIENCE=<audience-your-proxy-sets>
CANVAS_DROP_SESSION_SECRET=<32 or more random characters>
Without a proxy, let the app own login instead:
CANVAS_DROP_AUTH_MODE=oidc
CANVAS_DROP_URL_MODE=subdomain
CANVAS_DROP_BASE_URL=https://canvases.example.com
CANVAS_DROP_ALLOWED_EMAIL_DOMAINS=example.com
CANVAS_DROP_OIDC_ISSUER=https://<your-openid-provider>
CANVAS_DROP_OIDC_CLIENT_ID=<client id>
CANVAS_DROP_OIDC_CLIENT_SECRET=<client secret>
CANVAS_DROP_SESSION_SECRET=<32 or more random characters>
Both are config swaps, never code changes. Config is validated at boot, and the process refuses to start on an unsafe combination:
| Mode | Refuses to start when |
|---|---|
dev |
NODE_ENV=production |
proxy |
neither CANVAS_DROP_AUTH_PROXY_JWT_JWKS_URL nor CANVAS_DROP_TRUSTED_PROXY_IPS is set; a JWKS URL is set without both CANVAS_DROP_AUTH_PROXY_JWT_ISSUER and CANVAS_DROP_AUTH_PROXY_JWT_AUDIENCE; a trusted-IP entry is malformed, IPv6, or /0 |
oidc |
any of CANVAS_DROP_OIDC_ISSUER, CANVAS_DROP_OIDC_CLIENT_ID, CANVAS_DROP_OIDC_CLIENT_SECRET is missing |
proxy or oidc |
CANVAS_DROP_ALLOWED_EMAIL_DOMAINS is empty; CANVAS_DROP_SESSION_SECRET is shorter than 32 characters; CANVAS_DROP_URL_MODE=path without CANVAS_DROP_ALLOW_MULTI_USER_PATH_MODE=true |
See Configuration for every variable and Deploy for the proxy layout.
The trust boundary
A request becomes a member only after the auth gateway resolves an identity
server-side, checks the email allowlist (the CANVAS_DROP_ALLOWED_EMAIL_DOMAINS
domains, or an individual sign-in permit an admin added), maps it to a user, and
rejects blocked users. The gateway runs on every request; nothing is cached between
requests. The only way past it without an identity is the public-link carve-out: an
active, unexpired public_link canvas whose owner may still publish publicly, while
the instance switch is on. It grants an anonymous principal for that one canvas and
nothing else; a password on the canvas is still enforced by the gate.
flowchart TD
Req([Incoming request]) --> Carve{Active public_link canvas?}
Carve -->|yes, no identity| Anon[Anonymous principal]
Carve -->|otherwise| Gate{Auth gateway resolves identity}
Gate -->|none| Unauth["oidc: 302 to login. proxy, dev: 401"]
Gate -->|identity| Allow{Email allowed?}
Allow -->|no| Unauth
Allow -->|yes| Blocked{User blocked?}
Blocked -->|yes| Forbidden[403]
Blocked -->|no| Member([Member principal + live org membership])
Member --> Access{Canvas access decision, per request}
Anon --> Access
Access -->|owner or editor| Full[Canvas + primitives]
Access -->|rung admits| PwGate{Password set?}
PwGate -->|no| Full
PwGate -->|yes| Gate2[Password gate] --> Full
Access -->|public_link, not owner or editor| Static[Static files only]
Access -->|no route in| NotFound[404]
What each denial looks like, so you can read your logs and support tickets:
| Situation | Response | Audit row |
|---|---|---|
| No identity | oidc: 302 to /auth/login?returnTo=; proxy and dev: 401 {"error":"unauthorized"} |
auth_denied / no_identity |
| Email not on the domain list or the permit list | same as no identity | auth_denied / domain_not_allowed |
| User blocked by an admin | 403 {"error":"forbidden"} |
auth_denied / blocked |
| Signed in, but no route into the canvas | 404 {"error":"not_found"}, never a "forbidden" that confirms the canvas exists |
none (the gateway already logged auth_ok) |
| Canvas disabled by an admin | 403 disabled page on the canvas URL, shown to the owner, editors, and admins alike; the runtime API answers 403 {"code":"DISABLED"}, management writes 409 {"code":"DISABLED"} |
none |
| Canvas archived or deleted | 404, opaque even to the owner |
none |
If the allowlist lookup itself fails (a database error), the gateway denies. It fails closed, never open.
Pick the strategy with CANVAS_DROP_AUTH_MODE:
dev: auto-logs-in a fixed local user (CANVAS_DROP_DEV_USER_EMAIL, defaultdev@example.com) with no verification, and makes that user the bootstrap admin. Localhost only. Refused at boot whenNODE_ENV=production.proxy: an identity-aware proxy in front of the app asserts identity. The app is sessionless; the proxy owns the session. The recommended production profile.oidc: the app runs the OpenID Connect Authorization Code + PKCE flow itself and owns the session cookie. The built-in path when you do not run a proxy.
The five hard invariants
These are the guarantees the platform upholds (BUILD_BRIEF.md §12.0). Each section
after this one names the mechanism behind one of them.
- No impersonation. Identity (
me(), write attribution, presence) comes from the server-side auth context, never from anything the client sends. - No credential or canvas theft. No user can read another user's session, canvas API key, or canvas content. Session tokens and API keys are stored only as SHA-256 hashes; an API key is shown once; a session token rides only in an HttpOnly cookie.
- No unauthorized access. A canvas is reachable by its owner, by everyone on its
people-and-teams list (viewers and editors, people and teams, resolved live on every
request and admitted at every General-access choice), and otherwise only as widely as
the owner chose: Restricted (nobody else), Whole org, or Public link. Admins get no special access to
canvases they do not own; cross-owner admin power is limited to the dedicated admin
routes (list, disable/enable/restore, reassign owner). Anyone without a route in gets an
opaque
404; an editor attempting an owner-only act gets an explicit403 OWNER_ONLY. - No cross-canvas reach in subdomain mode. One canvas, its code, its SDK calls, or its socket cannot read, write, or act on another canvas's data, files, AI quota, or realtime channels. Path mode has reduced browser isolation and must be opted into.
- Lifecycle is honored instantly. Revoke, expiry, disable, delete, slug regen, key regen, rung lowering, removal from a list, team, or org, ownership moves, and unpublish take effect on the next request, and sharing changes revalidate live realtime sockets. Nothing grants access from a cache.
Identity is always server-side (invariant 1)
| Mode | Identity comes from | App session | Unauthenticated request |
|---|---|---|---|
dev |
the configured dev user | cookie, used only by /auth/logout |
never happens |
proxy, JWT path |
the proxy's signed JWT: signature against the JWKS, iss, aud, exp, and a string email claim |
none | 401 |
proxy, trusted-header path |
X-Auth-Request-Email (configurable), accepted only from a TCP peer in CANVAS_DROP_TRUSTED_PROXY_IPS |
none | 401 |
oidc |
the app's own session cookie, minted by the OIDC callback | __canvasdrop_session, 14-day rolling expiry |
302 to /auth/login |
Each mode namespaces the stored identity (dev:, proxy:, oidc: prefixes on the
provider subject), so switching modes never merges accounts across trust sources.
proxy mode: one trust path, never two
Exactly one trust path is active, selected by whether
CANVAS_DROP_AUTH_PROXY_JWT_JWKS_URL is set. The two do not compose, so a client cannot
omit the JWT to fall back to the weaker header path.
- JWT / JWKS path (preferred, cryptographic). With a JWKS URL configured, identity
comes only from the token in
CANVAS_DROP_AUTH_PROXY_JWT_HEADER(defaultCf-Access-Jwt-Assertion), verified against the remote JWKS and the configured issuer and audience. The email header is never consulted in this mode. A request that carries an identity header but no valid JWT resolves to anonymous and is logged as a downgrade probe.CANVAS_DROP_AUTH_PROXY_JWT_ISSUERandCANVAS_DROP_AUTH_PROXY_JWT_AUDIENCEare required when the JWKS URL is set. A JWKS fetch failure is logged asproxy JWT verification failed (§12.5), so you can tell "bad token" from "IdP down". - Trusted-header path (only when no JWKS URL is set). The email header (default
X-Auth-Request-Email; the optional name header defaults toX-Auth-Request-Preferred-Username) is honored only when the request's socket peer matches an entry inCANVAS_DROP_TRUSTED_PROXY_IPS. The check gates on the real TCP peer address, never onX-Forwarded-Foror any other header. A header from any other source is ignored and logged asignored identity header from untrusted source (§12.5).
Boot guards for proxy mode: the app refuses to start without a JWKS URL or a non-empty
trusted-IP list, so an unguarded "trust any header" config cannot exist. Every
CANVAS_DROP_TRUSTED_PROXY_IPS entry is validated: malformed IPv4 is rejected, /0 is
rejected (at boot and again at runtime), and IPv6 entries are rejected; use the JWT path
for IPv6 proxies. Two operational rules follow: the app must never be directly
reachable, only through the proxy; and on the trusted-header path the proxy must
overwrite (not append) the identity headers so a client cannot smuggle a second value.
Two IPs, two purposes. The peer IP gates identity trust. The client IP, read from
CANVAS_DROP_CLIENT_IP_HEADER or the rightmost untrusted X-Forwarded-For hop and only
when the peer is a trusted proxy, keys login rate limits and audit rows. It is never an
auth input. That is why CANVAS_DROP_TRUSTED_PROXY_IPS is worth setting in oidc mode
too (for example 127.0.0.1 for a local reverse proxy), even though header-asserted
identity fires only in proxy mode.
oidc mode: the app owns the session
Login uses PKCE (S256), a random state, and prompt=login. The callback rejects a
missing or mismatched state, a failed code exchange, a missing email claim, and
email_verified=false, and only then runs the same allowlist and blocked checks as every
other request. Every rejection writes an auth_denied audit row with its reason
(missing_oidc_state, state_mismatch, token_exchange_failed, no_email_claim,
email_not_verified, email_domain_not_allowed, blocked).
The session token is 256 bits of randomness; only its SHA-256 hash is stored, so the raw
token never lands in the database. The cookie is HttpOnly always, Secure in production,
SameSite=Lax, and scoped to .{baseHost} in subdomain mode. /auth/logout revokes the
session (audit session_revoke). CANVAS_DROP_SESSION_SECRET must be at least 32
characters outside dev mode; it also signs the password-gate grants below.
Agents
Agents connect over MCP at {base}/mcp with OAuth tokens the instance issues itself
(audited as mcp_token_issue, mcp_token_revoke, mcp_authorize_ok,
mcp_authorize_denied). Every tool resolves the caller's user server-side and runs the
same owner-or-editor role gate as the dashboard. The keyed Deploy API
has no user at all: its writes are attributed to the canvas owner. See
MCP server.
Access is decided on every request (invariant 3)
Per-canvas roles and the access ladder are resolved from the server-side principal on each request, never cached on a session or an agent's token. Two roles manage a canvas:
- The owner: the single account on the canvas record. Only the owner can delete the
canvas, transfer ownership, or switch on AI for added people. An editor who tries gets
403 {"code":"OWNER_ONLY"}. - An editor: owner-equivalent for everything else, including settings, sharing, the draft editor, publish, rollback, the deploy key, and adding or removing other editors. An editor is a signed-in member holding a direct editor row or membership of an editor-role team. With an org boundary configured (below), the grant holds only while they are currently a member of the canvas's home org; the check runs on every request. A guest or anonymous visitor never holds a role.
A principal with no role sees the management surface as 404 {"error":"not_found"}.
The people-and-teams list is consulted at every General-access choice; the choice itself
decides who else is admitted:
| Who | Admitted when | Password and expiry |
|---|---|---|
| A person on the list | always — an existing user by id, or a pending email once that exact email has signed in through your configured auth | both apply |
| A member of a team on the list | always. A personal team admits by membership alone; an org team also requires the viewer to be a current member of that team's org, re-joined live on every request, so a stale team row cannot widen access | both apply |
Anyone else at Restricted (private, or its legacy aliases specific_people / team) |
never | — |
| Whole org | any signed-in member. When an org is named (below), members of the canvas's home org only | both apply |
| Public link | anyone, static files only, while the instance-wide switch is on and the owner still holds the publish-public capability | both apply; an anonymous visitor reaches the password prompt |
The owner and editors are admitted at every choice, never see the password prompt, and
are unaffected by expiry. Anyone with no route in gets 404. A password is checked with argon2id; a successful attempt sets an HttpOnly grant
cookie (__canvasdrop_gate), HMAC-signed with CANVAS_DROP_SESSION_SECRET and bound to
the canvas id and its password version, so changing the password invalidates every
outstanding grant at once. In subdomain mode the grant is host-only; in path mode it is
scoped to the canvas path. Attempts are limited to
CANVAS_DROP_RATELIMIT_PASSWORD_GATE_PER_MIN (default 5) per user per canvas, or per
client IP for an anonymous public visitor; over the limit the gate answers 429 with
Retry-After.
Ownership moves are instant and audited. The owner can hand a canvas to an existing
editor who is an org member (POST /api/canvases/{id}/transfer, MCP transfer_canvas).
The previous owner stays on as an editor while their account is active and passes the
org check; if the new owner may not publish publicly, a Public link rung is turned off;
the deploy key is unchanged. An admin can reassign a canvas whose owner has left
from the admin routes, with a recorded reason. A reassign rotates the deploy key in the
same write (the plaintext is discarded; the new owner issues a fresh one) and never gives
the acting admin content access. Both write an audit row (canvas_transfer,
canvas_reassign_owner) and revalidate the canvas's live realtime sockets.
Public links are doubly gated. Admin → Configuration → access.publicLinksEnabled
(default on) is the instance switch; turning it off makes every public-link canvas
Restricted. Each owner also needs the publish-public capability, granted and revoked per
user under Admin → People; revoking it sweeps that owner's public-link canvases back to
Restricted. Both gates are also re-checked on every request, so a canvas whose owner lost
the grant is unreachable even before the sweep lands. A public-link visitor gets files
only: every runtime API call is refused with 403 {"code":"STATIC_ONLY"}, including for
signed-in members who are not the owner or an editor. In proxy mode a public link
works only for requests your proxy lets reach the app.
Admins have no back door. For a canvas they do not own, an admin is an ordinary
member: a Restricted canvas returns 404, a password prompts them, and they cannot open
the editor or change settings. Cross-owner admin power lives on the dedicated admin
routes: the all-canvases list, disable / enable / restore, reassigning the owner when
someone leaves, and gallery featuring. It never extends to canvas content, the runtime
API, or realtime. One recorded exception: the authoring API (CANVAS_DROP_AUTHORING,
default off) still admits an admin to update or revoke an authored share it does not
own.
The retired guest magic-link flow is gone. Old /guest/{token} links return an
invalid-link page, never set a cookie, and never consume a token; the sharing paths
below record pending access against your configured auth instead.
The org boundary (member vs guest)
By default any signed-in user is treated as one org, so whole_org means "anyone who
passed sign-in". Naming an org with CANVAS_DROP_ORG_NAME draws a member-vs-guest
boundary:
- A signed-in user whose verified email domain is in
CANVAS_DROP_ORG_DOMAINS(default: the allowed-email domains) is a member. Everyone else who can sign in, such as an allowlisted contractor or an admin on another domain, is a guest. Membership is independent of the sign-in permit list and ofCANVAS_DROP_ADMIN_EMAILS; those grant sign-in, not membership. - Each canvas has a home org, set once at creation: a member picks Personal or the
org; a guest only gets Personal.
whole_orgthen means "members of the canvas's home org". A guest cannot see it, and a Personalwhole_orgcanvas is an explicit deny to everyone but its owner and editors. - Membership is derived server-side from the identity the gateway resolved. A client can never assert which org it belongs to. Admin is orthogonal: it grants no membership and no content bypass.
The boundary is inert until an org is named, so it is an opt-in tightening. Turning it on
for an existing instance is a one-time, dry-run-first cutover; see
Configuration → Tenancy
and the docs/tenancy.md runbook in the repo.
Adds are auth-delegated (no app-owned credentials)
When someone adds a person who has no account yet, whether to a canvas, to a personal
team, or under Admin → People → Sign-in permits, canvas-drop records pending access,
not a login. There is no app-owned magic-link account and no app-stored password. The
grant materializes the first time that email authenticates through your configured auth
(proxy, oidc, or dev); the identity provider stays the only authority, so there is
nothing to take over. The verified login email is the match key, and pending access
never grants anything on its own.
Who may permit a brand-new email to sign in is gated:
- In
proxymode, an email that cannot already sign in (its domain is not allowed and it holds no permit) must be admitted at the proxy first. canvas-drop cannot widen an upstream IAP, so the add is refused with403 {"code":"AUTH_ADMISSION_REQUIRED"}until then. - An admin can add a permit under Sign-in permits.
- A member can only when the admin setting
invites.allowMemberNewEmailsis on (off by default), or when the email already authenticates. Otherwise the add is refused with403 {"code":"NOT_PERMITTED"}; a member cannot widen who may sign in to your instance.
Add volume is bounded per actor with invites.maxPerActorPerHour (default 20) and
invites.pendingCap (default 50). Both are DB-managed admin settings; see
Sign-in permits and access emails.
No secrets in the browser (invariant 2)
The AI provider key (CANVAS_DROP_AI_API_KEY), every canvas API key, and every Connection
protected header are server-side only. The browser SDK rides the viewer's session, so a
canvas calls the primitives (KV, files, AI, identity, realtime, and admin-granted
Connections) with no secret in its code. The deploy engine lints every upload and warns
when a file appears to contain a canvas API key.
Deploy keys are cd_ Bearer secrets: 32 random bytes, stored only as a SHA-256 hash,
shown once at creation or regeneration. A key works only on its own canvas (403 on any
other), only while that canvas is active (401 the moment it is archived or disabled),
and dies the moment it is regenerated. Cloning a canvas mints a fresh key; it never
copies the source's. The key is per canvas, not per person: removing an editor does not
invalidate a key they copied, so regenerate it when someone leaves (the dashboard offers
this). See the Deploy API.
Connections: bounded outbound authority
A Connection is an admin-created, reusable outbound profile, not a general proxy or a
Lambda runtime. It names one exact HTTPS DNS origin, a set of allowed HTTP methods, and
optional protected headers such as Authorization or a controlled User-Agent. An admin
grants that profile to individual canvases. The canvas supplies only a root-relative path,
query, body, and non-protected request headers; the server adds protected headers last.
Anonymous Public-link viewers cannot call it because the whole runtime API stays
static-only for them.
Protected header values are encrypted with AES-256-GCM under
CANVAS_DROP_CONNECTIONS_ENCRYPTION_KEY, authenticated to the profile id, and are
write-only in the admin API and UI. Logs, usage events, MCP results, and error bodies expose
neither the values nor requested paths/query strings. Backups contain ciphertext but not
the root key; preserve that key separately as described in the operations runbook.
Outbound requests resolve DNS on every hop and pin the selected public address into the socket while retaining the hostname for TLS SNI and certificate validation. Literal IPs, credentials in the URL, private/link-local/loopback/reserved IPv4 and IPv6, mixed public and private DNS answers, redirects to a different origin, and redirects to disallowed methods are rejected. Request and response sizes, total duration, concurrency, and rate are bounded. As defense in depth, restrict the app container's network egress to the APIs your profiles actually need; application-layer SSRF checks should not be the only barrier between a canvas and your internal network. See Connections.
Path mode vs subdomain mode (invariant 4)
CANVAS_DROP_URL_MODE is the most consequential deployment choice.
- Path mode (
{base}/c/{slug}/): every canvas shares one origin with the others and with the dashboard, so the browser does not isolate them. A malicious or compromised canvas could make same-origin requests against other canvases' client-side state. Fine for localhost and single-user hosting. Runningproxyoroidcauth in path mode is multi-user, and the app refuses to boot unless you setCANVAS_DROP_ALLOW_MULTI_USER_PATH_MODE=trueto accept the tradeoff. - Subdomain mode (
{slug}.canvases.example.com): each canvas is its own origin, so the browser isolates them and invariant 4 holds in full. Needs a wildcard DNS record and wildcard TLS at the proxy, and a non-localhostCANVAS_DROP_BASE_URL.
Server-side isolation holds in both modes: blobs, KV, files, AI usage, realtime channels,
and Connection grants are keyed by canvas id, and management and admin writes require a same-origin
request: Sec-Fetch-Site: same-origin (or none), else an Origin whose host matches
CANVAS_DROP_BASE_URL; anything else gets 403 {"error":"cross_origin_forbidden"}.
Subdomain mode adds the browser's origin boundary on top. If you do not run an
identity-aware proxy, run subdomain mode with oidc so you keep that boundary without
standing up a proxy.
Lifecycle changes land on the next request (invariant 5)
Nothing about access is cached. Concretely:
- Roles and rungs are resolved per request, so removing an editor, lowering a rung, or removing someone from a list, team, or org denies them on their very next request. Share, people-list, ownership, password, and unpublish changes also revalidate that canvas's live realtime sockets right away, and sockets that lost access are dropped.
- A heartbeat re-authorizes every live socket once a minute as a backstop, so a share expiry passing, or an admin block or delete (which fire no mutation hook), drops the socket within one tick.
- A blocked user is refused at the gateway on the next request, whatever session or agent token they hold.
- An archived, disabled, or deleted canvas rejects its own deploy key with
401. - Regenerating a slug invalidates the old URL and drops every live socket so clients reconnect under the new one. Regenerating a key invalidates the old key at once.
- Changing a canvas password invalidates every outstanding gate grant.
- Turning off public links, or revoking one owner's publish-public capability, makes the affected canvases Restricted immediately.
Reducing canvas XSS blast radius
Subdomain mode contains the blast radius of a compromised canvas to that canvas's
origin; Restricted-by-default access limits who can be exposed to it. Every response carries
X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, and
Cross-Origin-Opener-Policy: same-origin. Canvas content adds a
Content-Security-Policy of frame-ancestors 'self' {base origin} in subdomain mode
(frame-ancestors 'self' in path mode), so the dashboard can preview a canvas and no
other canvas can frame it. The dashboard itself runs under a strict CSP (script-src 'self', frame-ancestors 'none'). Tell canvas authors to prefer textContent over
innerHTML.
What gets audited
The audit log records auth_ok and auth_denied (with the reason) on every gateway
decision, session_create and session_revoke in oidc mode, and every OIDC callback
rejection. Canvas actions are attributed to the acting user or agent: deploy,
rollback, publish, canvas_unpublish, share_change, password_change,
password_attempt, slug_regen, key_regen, canvas_transfer, among others. Admin
actions are audited by name: canvas_disable, canvas_enable, canvas_restore,
canvas_reassign_owner, canvas_feature, user_block, user_unblock, user_promote,
user_demote, user_grant_public, user_revoke_public, allowed_email_add,
allowed_email_remove, admin_settings_update, and the Connection profile/grant actions.
Rows carry the client IP described
above. Login attempts are limited to CANVAS_DROP_RATELIMIT_LOGIN_PER_MIN (default 10)
per client IP; see Rate limiting.
No telemetry
canvas-drop does not phone home. There is no analytics, usage reporting, or third-party beacon in the product. Nothing leaves your instance unless you configure an outbound integration yourself, such as an OIDC provider, an AI provider, a mail transport, or an admin-granted Connection.