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 });
  },
});

CORS Configuration

Juice does not set CORS headers by default. If your API serves requests from a different origin (e.g., a mobile app or SPA on a different domain), add CORS headers in middleware:

// app/routes/api/middleware.ts
export default async function cors(req: Request, next: () => Promise<Response>) {
  // Handle preflight
  if (req.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': 'https://app.example.com',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400',
      },
    });
  }

  const res = await next();

  // Add CORS headers to all responses
  const headers = new Headers(res.headers);
  headers.set('Access-Control-Allow-Origin', 'https://app.example.com');
  return new Response(res.body, { status: res.status, headers });
}

Never use Access-Control-Allow-Origin: * with credentials. If your API uses cookies for auth, specify the exact allowed origin.

Secrets Management Across Runtimes

Each runtime has a different mechanism for secrets. Juice does not abstract over them because each has different security properties:

  • Cloudflare Workers: use wrangler secret put KEY. Secrets are passed as the env parameter to the fetch handler. Never put secrets in wrangler.toml (that file is committed to git).
  • Bun / Node.js: use environment variables via .envfiles (never committed) or your deployment platform's secret management. Access via process.env.KEY or Bun.env.KEY.
  • Deno: use Deno.env.get('KEY'). Deno requires explicit --allow-env permission, adding an extra layer of protection.

Share secrets with route components via context. Set them once in middleware or onBeforeRequest, then read with getContext. Never pass secrets through URL parameters or client-visible props.

Server Action Security

Server actions are exposed as HTTP endpoints. They accept POST requests with either FormData or the RSC wire format. Juice protects them with:

  • CSRF protection: the same Origin header validation applies to server action requests (they are POST requests).
  • Action ID validation: the action ID from the client is validated with Object.hasOwn() against the manifest to prevent prototype pollution (documented above).
  • No source exposure: server action source code is never sent to the client. Only opaque action IDs are exposed.

For actions that mutate data (create, update, delete), consider adding idempotency keys to prevent accidental duplicate submissions:

async function createOrder(formData: FormData) {
  'use server';
  const idempotencyKey = formData.get('idempotency_key') as string;

  // Check if this key was already processed
  const existing = await db.orders.findByIdempotencyKey(idempotencyKey);
  if (existing) return { order: existing };

  const order = await db.orders.create({
    data: { /* ... */ },
    idempotencyKey,
  });
  return { order };
}

Rate Limiting

Juice does not include built-in rate limiting. Implement it in middleware using your runtime's available storage:

// app/routes/api/middleware.ts
const rateLimit = new Map<string, { count: number; reset: number }>();

export default async function rateLimiter(req: Request, next: () => Promise<Response>) {
  const ip = req.headers.get('CF-Connecting-IP')
    ?? req.headers.get('X-Forwarded-For')?.split(',')[0]
    ?? 'unknown';

  const now = Date.now();
  const window = 60_000; // 1 minute
  const limit = 100;

  let entry = rateLimit.get(ip);
  if (!entry || now > entry.reset) {
    entry = { count: 0, reset: now + window };
    rateLimit.set(ip, entry);
  }

  entry.count++;

  if (entry.count > limit) {
    return new Response('Too Many Requests', {
      status: 429,
      headers: { 'Retry-After': String(Math.ceil((entry.reset - now) / 1000)) },
    });
  }

  return next();
}

For production, use your CDN's rate limiting (Cloudflare Rate Limiting, AWS WAF) or a distributed store (Redis, KV) instead of in-memory state.

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.