This boilerplate uses browser cookies for authentication, so the backend must own every security decision. Frontend checks are only UX.
@etus/auth(v0.5.0+) owns OAuth, sessions, users, accounts, memberships, invitations (including delivery via theMailerAdapterconfigured insrc/server/auth/setup.ts), and audit logs.- The browser never receives OAuth client secrets, refresh tokens, session identifiers in JavaScript, or long-lived bearer tokens.
- Sessions are stored in Cloudflare KV and referenced in D1 through
auth_sessions. - Session cookies are HTTP-only,
SameSite=Lax, andSecureon HTTPS. - Session fingerprinting is enabled in
src/server/auth/setup.ts.
- Package-owned auth/admin/account/invitation/audit routes are mounted in
src/server/index.ts. - App-owned
/api/*routes run throughauth.middleware()andauth.accountMiddleware()before route handlers execute. - App routes use permission guards from
src/server/auth/guards.ts. - Product roles are declared in
src/server/auth/matrix.ts:owner > admin > member > guest. - Account membership mutations intentionally accept only
admin | member | guestthroughsrc/server/auth/package-compat.ts. v0.5.0's package validates roles againstaccess.roles(our 4-role list), but we deliberately keepownerout of membership writes — it's a product-level concept tracked onauth_accounts.owner_id, not a membership role. The shim enforces that narrowing. @etus/authv0.5.0 ships invitation delivery — we wire aMailerAdapterinsetup.tsthat bridges the package contract to our SendGrid wrapper (src/server/lib/email.ts). The invitation URL is built by the package using themultiTenant.invitationUrlcallback we provide (${env.APP_URL}/invite/${inv.token}).defaultRoleismember: invites without an explicit role land asmember(collaborative), notguest(read-only) oradmin(too much). This is a v0.5.0 change — earlier versions hardcodedviewer.
- Mutating browser requests must pass
csrfProtection(): trustedOrigin/Refererand JSON content type for JSON endpoints. The boilerplate does not send a decorative CSRF token header; if@etus/authexposes a session-bound token later, it should be validated as data, not just checked for presence. csrfProtection()accepts options to extend (never replace) the defaults:exemptPathsskips all checks (for routes with their own gate, e.g. dev-login's localhost guard);emptyBodyPathsandemptyBodyPatternsallowPOST/PUT/PATCHwithoutContent-Typefor legitimately empty requests (logout, invitation accept/decline);nonJsonPathPrefixesopts whole route trees out of the JSON-only contract (storage uploads, webhook receivers). The415error message names these options so the fix is discoverable from the failing request.- Credentialed CORS is allowlist-only.
CORS_ORIGINS=*is rejected in production byvalidateEnv(). Outside production it is permitted butvalidateEnv()emits a one-shot warning (per env object viaWeakSet, so it fires once per isolate and once per test) explaining that csrf's exact origin matcher silently drops the wildcard — operators should list each origin explicitly even in dev to avoid silent rejections. - Request bodies are capped by
requestBodyLimit()before route parsing. Direct R2 upload routes use an explicit larger upload cap instead of bypassing size checks. The upload cap defaults to 25 MiB and can be raised via theMAX_UPLOAD_BYTESenv var (positive integer in bytes, up to the Workers runtime cap of ~500 MiB).validateEnv()parses the value at boot so a bad value fails the first request with a clear message instead of crashing inside the middleware on the first upload.
- Client code should use cookie-backed
fetch(..., { credentials: 'include' }). - Auth tokens and secrets must not be stored in
localStorageorsessionStorage. - Dangerous DOM and code execution sinks are forbidden in
src/clientbytests/unit/client/security/frontend-security.test.ts. - Static assets are served with CSP, Trusted Types, referrer policy,
frame-denial, nosniff, HSTS, and restrictive permissions policy from
public/_headers. - The CSP keeps
<style>elements strict (style-src 'self' …) but explicitly allows inline style attributes via the CSP3 directivestyle-src-attr 'unsafe-inline'. This is required because Radix UI primitives (Dialog overlay, Popover positioning) emitstyle="…"attributes from their own code that would otherwise be blocked, silently breaking modals and dropdowns. Element-level injection (the actual XSS vector) is still rejected —style-src-elemfalls back to the strictstyle-src. Both the runtime middleware (src/server/middleware/security-headers.ts) and the staticpublic/_headersfile ship this split. - Browser compatibility note:
style-src-attris CSP3 and requires Chrome 75+, Firefox 74+, or Safari 15.4+ (released 2022). Older browsers ignore the directive and fall back to the strictstyle-src, which means Radix primitives will render with broken positioning. The boilerplate targets browsers released within the last ~3 years; if you need to support older Safari, either restore'unsafe-inline'instyle-src(weaker defense) or migrate to a UI library that doesn't rely on inline style attributes.
Run these before shipping auth/security changes:
pnpm typecheck
pnpm test:unit:server
pnpm test:unit:client
pnpm buildpnpm lint is also expected for source files. If the repo has local generated
agent files, lint those separately or exclude them intentionally before making
the full lint command a release gate.