Skip to content

Security

Most frameworks ship with security off. You add CSRF protection when you remember. Juice ships with it on. Here is what is enabled by default, why, and when to turn it off.

CSRF Protection (On by Default)

The attack: a malicious website creates a form that POSTs to your app. The user's browser sends the request with their cookies attached. Your server processes it as a legitimate request because the cookies are valid. The user just unknowingly transferred money, changed their password, or deleted their account.

The defense: Juice validates that the Origin header (or Referer as a fallback) matches the request's Host header on all POST requests and server actions. If they do not match, the server returns 403 Forbidden. This is simple and effective: browsers always send the Origin header on cross-origin POST requests, and malicious sites cannot forge it.

Coming from Express: you probably used csurf or csrf-csrf middleware with tokens. Juice's approach is simpler: no tokens, no hidden fields, no session storage. The Origin header check is sufficient for modern browsers.

Configuration

// Default: CSRF on (same-origin only)
createRouter(manifest);

// Allow additional trusted origins (e.g., admin on a different subdomain)
createRouter(manifest, {
  csrfProtection: {
    allowedOrigins: ['https://admin.example.com'],
  },
});

// Disable entirely
createRouter(manifest, {
  csrfProtection: false,
});

When to Disable CSRF

Disable CSRF protection for public APIs that accept POST from any origin (webhook endpoints, public form submissions from third-party sites). You are explicitly saying: "I know requests can come from anywhere, and my auth layer (API keys, bearer tokens) handles validation instead of origin checking."

For APIs that use bearer tokens instead of cookies, CSRF is irrelevant anyway: the attacker's page cannot read the token from your site, so they cannot include it in the forged request. But cookies are sent automatically by the browser, which is why cookie-based auth needs CSRF protection.

Prototype Pollution Protection

When Juice resolves a server action, it looks up the action ID in the manifest: manifest.serverActions[actionId]. The action ID comes from the client. An attacker can send constructor, __proto__, or toString as the action ID.

Juice uses Object.hasOwn() to validate that the action ID is an actual property of the action map, not an inherited prototype property. Without this check, an attacker could trigger unexpected behavior by invoking prototype methods through the action dispatch.

JSON payloads are parsed with standard JSON.parse() (which does not create prototype properties). Form data uses the web-standard FormData API, which is immune to prototype pollution.

Client Boundary Enforcement

Imagine a 'use client' component that accidentally imports node:fs. Without enforcement, this would ship to the browser bundle and crash at runtime. Or worse: a server-only module with database credentials gets bundled into client JavaScript, visible in DevTools.

Juice catches this at build time with an AST check. The Vite plugin enforces a strict boundary: files without 'use client' are server-only and cannot be imported by client components. Server actions ('use server') are extracted into separate modules and exposed only through opaque action IDs. The client never sees the action source code.

Coming from Next.js App Router: same concept, but Next.js sometimes catches these errors at runtime (you see a "server-only module in client component" error in the browser). Juice catches them at build time, before the code ships.

CSP Nonce

Juice injects inline scripts in three situations: CSS bootstrapping (ensuring styles load before paint), streaming error recovery (replacing failed Suspense boundaries), and React hydration hints. If you have a strict Content-Security-Policy that blocks inline scripts, these break.

createRouter(manifest, {
  nonce: crypto.randomUUID(),
});

The nonce is passed to React's renderToReadableStream, so any scripts React injects during streaming also include the nonce attribute. Set your CSP header to match:

Content-Security-Policy: script-src 'nonce-<value>'

Generate a new nonce per request. Do not reuse nonces across requests, as this defeats the purpose of the protection.

Production Error Suppression

In dev mode (mode: 'development'), Juice shows full stack traces with syntax-highlighted source code in the browser. This is great for debugging.

In production (mode: 'production', the default), the runtime returns a generic "Internal Server Error" with no details. This is intentional. Stack traces leak file paths, dependency versions, internal architecture, and sometimes environment variables. An attacker can use this information to find vulnerabilities.

createRouter(manifest, {
  mode: 'production',
  onError: (err, req) => {
    // Log internally (to your monitoring service, stdout, etc.)
    console.error('[app]', req.url, err);

    // Return a generic error to the client
    return new Response('Something went wrong', { status: 500 });
  },
});

When NOT to Worry About Juice's Security Defaults

Juice's security features protect against common web vulnerabilities. They do not replace application-level security. You still need:

  • Input validation (Juice parses the body, but does not validate your schema)
  • Authentication (Juice provides context passing, but does not verify credentials)
  • Authorization (Juice has middleware, but does not enforce who can access what)
  • Rate limiting (use your CDN or a middleware -- Juice does not include this)
  • SQL injection prevention (use parameterized queries -- this is a database concern)

Coming from Remix: Remix has similar CSRF protection via same-origin checking. The main difference is that Juice enables it by default instead of requiring you to add it.