security
Platform security handbook
Platform Security Handbook
Platform Security Handbook
Security layers
flowchart TB
ID[Identity] --> AUTHZ[Authorization]
AUTHZ --> TEN[Tenant/store fencing]
TEN --> RLS[Database RLS]
RLS --> SEC[Secret management]
SEC --> EXT[Provider boundary]
EXT --> AUD[Audit / detection]
style ID fill:#0D4261,color:#fff
style AUTHZ fill:#4194B3,color:#fff
style RLS fill:#8A1538,color:#fff
style AUD fill:#129B82,color:#fffAuthentication
Authentication establishes identity. It does not establish permission to every tenant/store.
Session handling must fail closed on ambiguous authorization.
Every Route Handler that changes state with the ambient session cookie must reject a cross-site, null or missing Origin with 403 before it reads the session (isSameOriginRequest, apps/web/src/lib/security/sensitive-function-bridge.ts). apps/web/src/app/api/cookie-mutation-origin.test.ts covers the routes that call it inline; routes guarded through a shared helper (requireAuthenticatedContext, account, billing, domains and storefront session helpers) are tested with that helper. An explicit Authorization: Bearer credential (the theme CLI) is not ambient and is exempt; it is still authorized in SQL.
Authorization
Authorization must be enforced server-side for:
- merchant role actions;
- store membership;
- admin operations;
- developer scopes;
- native capabilities;
- AI actions;
- provider configuration.
RLS
RLS provides database-level tenant/store isolation. Application filtering is additive, not a substitute.
Never disable RLS as a support technique.
Secrets
Secrets include:
- service-role keys;
- provider credentials;
- webhook secrets;
- OAuth client secrets;
- private certificates/keys.
Rules:
- server-side only;
- encrypted/managed secure storage;
- masked UI hints only;
- no secrets in docs, issues, logs, PR bodies or browser bundles.
Webhooks
Verify:
- exact raw bytes if the provider contract requires it;
- timestamp/replay window;
- signature/authentication;
- event identity/idempotency;
- expected provider/account;
- target tenant/store mapping.
Uploads/media
Validate file type, size and access model. Private media must not become publicly enumerable by accident.
Store media (store-media, private bucket) accepts JPEG/PNG/WebP/AVIF images up to 10 MB, re-encoded by sharp, and MP4/MOV/WebM product videos up to 40 MB, verified from their bytes and stored as uploaded (migration 182). Every asset is served through /api/media/<id>, whose service-only resolver enforces tenant suspension, soft deletion and rate limits; videos are then handed to Storage through a one-hour signed same-origin URL.
The public metadata view public.public_media_assets (id, store, dimensions, alt text, usage, created time) runs with the caller's rights (security_invoker, migration 270), so RLS on media_assets, stores and tenants decides its rows:
- Visible: clean media of a store whose tenant is active and has subscription access. Lapsed subscriptions are hidden, as their stores already are.
- Grants:
anonandauthenticatedhold column-level SELECT on those columns plusdeleted_at/cleanup_statusonly. The tenant, storage path and bucket, filename, MIME type, size, uploader and source columns are never readable, and there is no table-level SELECT or write. - Members and super admins can also read these metadata columns for their own tenant's (or, for super admins, every tenant's) non-public media through the existing member policy. Full media details still go through
list_tenant_media.
Security regression
The repository contains security validation scripts for platform baseline, checkout boundary, payment boundary, shipping boundary, delivery RBAC and theme imports. These should remain part of release confidence.
Incident trigger
Any cross-tenant access, secret exposure, privilege escalation, unauthenticated webhook mutation or unexplained financial write is an incident, not a normal support ticket.