StarterApp Docs
Security

Overview

Built-in protection layers for production applications

Security works through standardized helpers in the @workspace/security package. Using these helpers ensures consistent protection across all endpoints without manual header configuration.

Pro Tip

All security helpers (secureUserJson, securePublicJson, secureErrorJson) automatically apply correct cache controls, security headers, and Vary directives.

Protection Architecture

The security system operates across three layers:

Browser Protection

Content Security Policy (CSP) enforced via middleware:

  • Marketing app: Static CSP with 'unsafe-inline' for simplicity
  • Dashboard app: Per-request nonce with 'strict-dynamic' for maximum security

Every HTML response receives security headers that block inline scripts and restrict resource loading to approved origins.

Network Security

HTTPS enforcement with HSTS in production. BetterAuth manages secure, httpOnly cookies:

  • Production: __Host-session prefix (requires HTTPS + no domain attribute)
  • Development: session cookie (allows HTTP localhost)

All authentication tokens remain httpOnly and secure, preventing client-side access.

Application Layer

Zod validation at all boundaries. Convex rate limiting with durable storage. Cache controls prevent user data leaks:

  • User data: Cache-Control: private, no-store
  • Public data: Cache-Control: public, max-age=60
  • API routes: Automatic Vary headers for auth-bearing requests

Default Security Benefits

Every endpoint receives appropriate security headers without developer configuration. User data stays private through proper caching directives. Public content benefits from CDN distribution while maintaining security.

Automatic Protection

Error responses sanitize internal information automatically. The framework handles all header complexity so developers focus on business logic.

Developer Experience

Security happens transparently during development:

import { secureUserJson } from "@workspace/security";

export async function GET(request: Request) {
  const user = await getCurrentUser();

  // Automatically adds: Cache-Control: private, no-store
  // Automatically adds: Vary: Cookie, Authorization
  return secureUserJson({ user });
}

The @workspace/security package exports functions that handle complex header logic:

Rate limiting requires a single function call in Convex actions:

import { userAction } from "./_helpers/builders";
import { assertLimitAction } from "./rateLimits";

export const updateProfile = userAction({
  args: { name: v.string() },
  handler: async (ctx, { name }) => {
    // ctx.viewerId is auto-injected from userAction builder

    // 10 updates per 60 seconds
    await assertLimitAction(ctx, {
      scope: "profile-update",
      viewerId: ctx.viewerId,
      max: 10,
      windowMs: 60_000,
    });

    // Business logic here
    await ctx.runMutation(api.users._updateProfile, { name });
  },
});

CSRF Protection

All state-changing operations (POST/PUT/PATCH/DELETE) enforce same-origin checks:

Convex HTTP Routes

CSRF protection happens automatically in convex/http.ts:

// All mutating methods wrapped with CSRF guard
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);

http.route = ((spec) => {
  if (MUTATING_METHODS.has(spec.method)) {
    const originalHandler = spec.handler;
    return originalRoute({
      ...spec,
      handler: httpAction(async (ctx, request) => {
        const violation = enforceHttpMutationGuards(request, csrfAllowlist);
        if (violation) return violation; // Returns 403
        return originalHandler(ctx, request);
      }),
    });
  }
  return originalRoute(spec);
});

Next.js API Routes

API routes in the dashboard app check Origin, Referer, and Sec-Fetch-Site headers. See apps/dashboard/app/api/auth/[...all]/route.ts for the assertSameOrigin() implementation.

Implementation Structure

Security implementation follows clear patterns throughout the codebase:

import { buildDynamicCsp } from "~/lib/csp";

const nonce = createNonce();
const csp = buildDynamicCsp({ nonce, reportUri, isProd });

const response = NextResponse.next({ request: { headers: reqHeaders } });
response.headers.set(headerName, csp);
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), browsing-topics=()");

Middleware in apps/marketing/middleware.ts and apps/dashboard/middleware.ts handle page-level headers for their respective surfaces.

import { auth } from "@workspace/auth/server";
import { secureUserJson, secureErrorJson } from "@workspace/security";
import type { NextRequest } from "next/server";

export async function POST(request: NextRequest) {
  // Check authentication
  const session = await auth.api.getSession({ headers: request.headers });

  if (!session?.user?.id) {
    return secureErrorJson(
      { message: "Unauthorized", code: "UNAUTHENTICATED" },
      { status: 401, userScoped: true }
    );
  }

  // Business logic here

  return secureUserJson({ success: true });
}

API routes use helpers from @workspace/security for consistent response headers.

import { userAction } from "./_helpers/builders";
import { assertLimitAction } from "./rateLimits";

export const createTicket = userAction({
  args: { title: v.string(), description: v.string() },
  handler: async (ctx, { title, description }) => {
    // ctx.viewerId is auto-injected and authenticated

    // Rate limit: 5 tickets per hour
    await assertLimitAction(ctx, {
      scope: "support:create",
      viewerId: ctx.viewerId,
      max: 5,
      windowMs: 3600_000,
    });

    // Business logic
    await ctx.runMutation(api.support._insert, { title, description });
  },
});

Rate limiting configuration lives with the endpoint logic. Use userAction (not mutation) for rate limiting.

Input validation happens at API boundaries using Zod. Authentication flows through BetterAuth with Convex session management.

Learn More