Problem
Every webjs SSR HTML response carries a per-request CSRF Set-Cookie (webjs_csrf, see packages/server/src/csrf.js + the issuance in packages/server/src/ssr.js). This makes webjs SSR pages effectively un-cacheable at a CDN: a CDN skips caching any response with Set-Cookie, and if it did cache one, every visitor would share the same token, defeating the protection. So a webjs page cannot sit on a Cloudflare edge the way a Next.js / Astro / SvelteKit / Remix page can.
This is the dominant first-paint cost in production. Measured on the live webjs.dev (Cloudflare in front of a Railway origin in ams1): cold TTFB is ~385ms, of which ~299ms is request-to-first-byte, dominated by the edge-to-origin round-trip (a non-AMS visitor pays the full transcontinental RTT on EVERY request because the HTML is cache-control: no-store). LCP is otherwise excellent (445ms warm, CLS 0.01), so the framework's no-build payload is lean. The single lever that cuts global TTFB to tens of ms is edge-caching the HTML, and the CSRF cookie is what blocks it.
Design / approach
Adopt the peer-standard model. Next.js (packages/next/src/server/app-render/action-handler.ts, isCsrfOriginAllowed), Astro (core/app/middlewares.ts, the checkOrigin option), SvelteKit, and Remix all defend Server Actions from CSRF by comparing the Origin header host to the request host (with an allowedOrigins allowlist escape hatch), NOT with a per-request token cookie. A cross-site attacker's forged POST carries the attacker's Origin (or none), so it is rejected without any token, which is exactly what lets their SSR/SSG HTML be CDN-cacheable.
Plan:
- Replace the double-submit
verify() in csrf.js with Origin-header verification: the request's Origin (fall back to Referer) host must equal the request host, OR be in a configured webjs.allowedOrigins list. Missing Origin AND Referer follows Next's stance: warn-and-allow (a handcrafted request cannot carry a victim's SameSite cookies cross-site; auth/session cookies stay SameSite=Lax as defense-in-depth). Keep constant-time semantics where relevant.
- Stop issuing the
webjs_csrf cookie on SSR responses (ssr.js htmlResponse / cachedHtmlResponse), and drop the client stub's __csrf() read + x-webjs-csrf header send (actions.js).
- Add a
webjs.allowedOrigins config key (string[]), surfaced through the WebjsConfig type + the JSON Schema + configuration.md.
- With no
Set-Cookie on HTML, a page that sets a public Cache-Control (via metadata.cacheControl) becomes genuinely CDN-cacheable. Then opt the four in-repo apps' static pages into Cache-Control: public, s-maxage=... (+ export const revalidate for the origin memo): website, docs, packages/ui/packages/website, examples/blog (the genuinely-static, visitor-identical pages only).
This keeps the route.ts REST boundary unchanged (already not CSRF-protected; carries its own auth), and CORS via cors() is unaffected.
Implementation notes (for the implementing agent)
- Where to edit (framework):
packages/server/src/csrf.js: replace verify(req) (currently double-submit cookie-vs-header) with an origin-check verify(req, { allowedOrigins }). Keep parseCookies (other code may use it). Remove newToken / cookieHeader / CSRF_HEADER export usage once callers are migrated (grep first).
packages/server/src/ssr.js: htmlResponse() (~L324-348) and cachedHtmlResponse() (~L364-385) both append the CSRF Set-Cookie via readToken/newToken/cookieHeader. Remove that issuance. The BUFFERED_MARKER + conditional-GET funnel already only ETags non-no-store responses, so a now-cookieless public page flows through cleanly.
packages/server/src/actions.js: the CSRF gate in invokeAction (~L462-464, if (!SAFE_VERBS.has(method) && !verifyCsrf(req))) becomes the origin check; the generated client stub emits __csrf() (~L337) and sets CSRF_HEADER on body/url fetches (~L387, L404) — drop those.
packages/server/src/html-cache.js, context.js, testing.js/testing.d.ts: grep for CSRF_COOKIE / CSRF_HEADER / verifyCsrf / readToken and migrate/remove.
- Config: add
allowedOrigins to the webjs block parsing (packages/server/src/ config reader), the WebjsConfig type, and the JSON Schema.
- Where to edit (apps): add
export const revalidate = <seconds> + export const metadata = { cacheControl: 'public, max-age=0, s-maxage=<seconds>, stale-while-revalidate=86400' } to the static pages of website/app/, docs/app/docs/**, packages/ui/packages/website/app/, and examples/blog/app/ (only visitor-identical pages; never a page that reads cookies() / session).
- Landmines:
- The
webjs_csrf cookie is ONLY for CSRF (it is NOT the auth/session cookie). Confirm with a repo-wide grep before removing, but auth lives separately in auth.js.
- Origin check must handle: same-origin with
Origin present (allow), cross-origin not in allowlist (403), Origin: null from sandboxed iframes (treat as cross-origin unless allowlisted, per Next), and BOTH Origin and Referer absent (warn-and-allow, matching Next, else legitimate non-browser clients break).
- Behind a reverse proxy / CDN the host may arrive as
x-forwarded-host; compare against that when present (Next uses x-forwarded-host precedence). webjs.dev sits behind Cloudflare, so get this right or same-origin actions 403 in prod.
- A public
Cache-Control page must NOT read per-user state; the existing #241 revalidate per-user guard (html-cache.js warnDynamicRevalidateOnce) is the model.
- Invariants to respect: AGENTS.md "RPC + REST endpoint security" section; invariant 11 (prose punctuation) in any new docs; the published-package release rule.
- Tests: unit (
packages/server/test/**): origin-check accept/reject/allowlist/missing-origin/forwarded-host, with a counterfactual (cross-origin POST must 403 when the check is active, succeed if reverted). e2e (test/e2e/*): a server action still works same-origin in a real browser; the HTML response has NO Set-Cookie and a cacheable page emits public Cache-Control + a weak ETag (304 on If-None-Match). Smoke: the four apps still boot.
- Docs:
AGENTS.md (RPC security + the .server boundary notes), agent-docs/advanced.md (CSRF/security), agent-docs/configuration.md (webjs.allowedOrigins), agent-docs/built-ins.md (caching: SSR pages are now CDN-cacheable), the docs site configuration + caching/deployment pages. Invoke webjs-doc-sync.
Acceptance criteria
Problem
Every webjs SSR HTML response carries a per-request CSRF
Set-Cookie(webjs_csrf, seepackages/server/src/csrf.js+ the issuance inpackages/server/src/ssr.js). This makes webjs SSR pages effectively un-cacheable at a CDN: a CDN skips caching any response withSet-Cookie, and if it did cache one, every visitor would share the same token, defeating the protection. So a webjs page cannot sit on a Cloudflare edge the way a Next.js / Astro / SvelteKit / Remix page can.This is the dominant first-paint cost in production. Measured on the live
webjs.dev(Cloudflare in front of a Railway origin inams1): cold TTFB is ~385ms, of which ~299ms is request-to-first-byte, dominated by the edge-to-origin round-trip (a non-AMS visitor pays the full transcontinental RTT on EVERY request because the HTML iscache-control: no-store). LCP is otherwise excellent (445ms warm, CLS 0.01), so the framework's no-build payload is lean. The single lever that cuts global TTFB to tens of ms is edge-caching the HTML, and the CSRF cookie is what blocks it.Design / approach
Adopt the peer-standard model. Next.js (
packages/next/src/server/app-render/action-handler.ts,isCsrfOriginAllowed), Astro (core/app/middlewares.ts, thecheckOriginoption), SvelteKit, and Remix all defend Server Actions from CSRF by comparing theOriginheader host to the request host (with anallowedOriginsallowlist escape hatch), NOT with a per-request token cookie. A cross-site attacker's forged POST carries the attacker'sOrigin(or none), so it is rejected without any token, which is exactly what lets their SSR/SSG HTML be CDN-cacheable.Plan:
verify()incsrf.jswith Origin-header verification: the request'sOrigin(fall back toReferer) host must equal the request host, OR be in a configuredwebjs.allowedOriginslist. MissingOriginANDRefererfollows Next's stance: warn-and-allow (a handcrafted request cannot carry a victim's SameSite cookies cross-site; auth/session cookies staySameSite=Laxas defense-in-depth). Keep constant-time semantics where relevant.webjs_csrfcookie on SSR responses (ssr.jshtmlResponse/cachedHtmlResponse), and drop the client stub's__csrf()read +x-webjs-csrfheader send (actions.js).webjs.allowedOriginsconfig key (string[]), surfaced through theWebjsConfigtype + the JSON Schema +configuration.md.Set-Cookieon HTML, a page that sets a publicCache-Control(viametadata.cacheControl) becomes genuinely CDN-cacheable. Then opt the four in-repo apps' static pages intoCache-Control: public, s-maxage=...(+export const revalidatefor the origin memo):website,docs,packages/ui/packages/website,examples/blog(the genuinely-static, visitor-identical pages only).This keeps the
route.tsREST boundary unchanged (already not CSRF-protected; carries its own auth), and CORS viacors()is unaffected.Implementation notes (for the implementing agent)
packages/server/src/csrf.js: replaceverify(req)(currently double-submit cookie-vs-header) with an origin-checkverify(req, { allowedOrigins }). KeepparseCookies(other code may use it). RemovenewToken/cookieHeader/CSRF_HEADERexport usage once callers are migrated (grep first).packages/server/src/ssr.js:htmlResponse()(~L324-348) andcachedHtmlResponse()(~L364-385) both append the CSRFSet-CookieviareadToken/newToken/cookieHeader. Remove that issuance. TheBUFFERED_MARKER+ conditional-GET funnel already only ETags non-no-storeresponses, so a now-cookieless public page flows through cleanly.packages/server/src/actions.js: the CSRF gate ininvokeAction(~L462-464,if (!SAFE_VERBS.has(method) && !verifyCsrf(req))) becomes the origin check; the generated client stub emits__csrf()(~L337) and setsCSRF_HEADERon body/url fetches (~L387, L404) — drop those.packages/server/src/html-cache.js,context.js,testing.js/testing.d.ts: grep forCSRF_COOKIE/CSRF_HEADER/verifyCsrf/readTokenand migrate/remove.allowedOriginsto thewebjsblock parsing (packages/server/src/config reader), theWebjsConfigtype, and the JSON Schema.export const revalidate = <seconds>+export const metadata = { cacheControl: 'public, max-age=0, s-maxage=<seconds>, stale-while-revalidate=86400' }to the static pages ofwebsite/app/,docs/app/docs/**,packages/ui/packages/website/app/, andexamples/blog/app/(only visitor-identical pages; never a page that readscookies()/ session).webjs_csrfcookie is ONLY for CSRF (it is NOT the auth/session cookie). Confirm with a repo-wide grep before removing, but auth lives separately inauth.js.Originpresent (allow), cross-origin not in allowlist (403),Origin: nullfrom sandboxed iframes (treat as cross-origin unless allowlisted, per Next), and BOTHOriginandRefererabsent (warn-and-allow, matching Next, else legitimate non-browser clients break).x-forwarded-host; compare against that when present (Next usesx-forwarded-hostprecedence). webjs.dev sits behind Cloudflare, so get this right or same-origin actions 403 in prod.Cache-Controlpage must NOT read per-user state; the existing#241revalidateper-user guard (html-cache.jswarnDynamicRevalidateOnce) is the model.packages/server/test/**): origin-check accept/reject/allowlist/missing-origin/forwarded-host, with a counterfactual (cross-origin POST must 403 when the check is active, succeed if reverted). e2e (test/e2e/*): a server action still works same-origin in a real browser; the HTML response has NOSet-Cookieand a cacheable page emitspublicCache-Control+ a weak ETag (304 onIf-None-Match). Smoke: the four apps still boot.AGENTS.md(RPC security + the.serverboundary notes),agent-docs/advanced.md(CSRF/security),agent-docs/configuration.md(webjs.allowedOrigins),agent-docs/built-ins.md(caching: SSR pages are now CDN-cacheable), the docs siteconfiguration+caching/deploymentpages. Invokewebjs-doc-sync.Acceptance criteria
webjs.allowedOriginsallows a configured cross-origin caller;x-forwarded-hostis honored behind a proxy.Set-Cookiefor CSRF; a page withmetadata.cacheControl: 'public, ...'returns that header and is CDN-cacheable (weak ETag + 304 verified).Cache-Control+revalidate; all four still boot and serve 200.