StarterApp Docs
Security

CORS & CSRF

Origin validation and fetch metadata protection

Two complementary layers protect mutation endpoints from cross-site attacks. Origin allowlists validate the request source. Fetch Metadata policies block cross-site state-changing operations.

Pro Tip

CSRF protection happens automatically in Convex HTTP routes (convex/http.ts) and Next.js API routes via assertSameOrigin() checks. No manual configuration needed.

Protection Architecture

Origin Allowlist

The CSRF guard resolves allowed origins with this precedence:

Environment Variable

ALLOWED_WEB_ORIGINS contains a comma-separated list of approved origins:

ALLOWED_WEB_ORIGINS=https://app.example.com,https://dashboard.example.com

Fallback to Base URL

If ALLOWED_WEB_ORIGINS is not set, the system uses APP_BASE_URL:

APP_BASE_URL=https://app.example.com

Development Localhost

Non-production builds automatically add localhost origins:

isProd ? undefined : "http://localhost:3000"
isProd ? undefined : "http://127.0.0.1:3000"

Implementation

import { normalizeOrigins } from "@convex/lib/utils";
import type { NextRequest } from "next/server";

const isProd = process.env.NODE_ENV === "production";

const computeDefaultOrigins = (): string[] => {
  const explicit = normalizeOrigins(
    (process.env.ALLOWED_WEB_ORIGINS ?? "").split(",")
  );
  if (explicit.length) {
    return explicit;
  }

  return normalizeOrigins([
    process.env.APP_BASE_URL,
    isProd ? undefined : "http://localhost:3000",
    isProd ? undefined : "http://127.0.0.1:3000",
  ]);
};

export const defaultAllowlist = new Set<string>(computeDefaultOrigins());

export function assertOrigin(
  req: NextRequest,
  allowlist: Set<string> | string[] = defaultAllowlist
) {
  const method = req.method.toUpperCase();
  if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
    return; // Safe methods bypass origin check
  }

  const whitelist = getAllowedOrigins(allowlist);
  const origin =
    req.headers.get("origin") ??
    (() => {
      try {
        const ref = req.headers.get("referer");
        return ref ? new URL(ref).origin : null;
      } catch {
        return null;
      }
    })();

  const normalizedOrigin = origin ? normalizeOrigin(origin) : undefined;

  if (!(normalizedOrigin && whitelist.has(normalizedOrigin))) {
    const err: Error & { status?: number; code?: string } = new Error(
      "Forbidden"
    );
    err.status = 403;
    err.code = "CSRF_PROTECTION";
    throw err;
  }
}
import { enforceMutationRequest } from "~/lib/security/csrf";
import { secureUserJson, secureErrorJson } from "@workspace/security";

export async function POST(request: Request) {
  enforceMutationRequest(request); // Throws on violation

  const user = await getCurrentUser();
  if (!user) {
    return secureErrorJson(
      { message: "Unauthorized", code: "AUTH_REQUIRED" },
      { status: 401, userScoped: true }
    );
  }

  // Business logic here
  return secureUserJson({ success: true });
}

Referer Fallback

The implementation checks Origin first, then falls back to parsing the Referer header. Browsers send Origin on all state-changing requests (POST/PUT/PATCH/DELETE).

Fetch Metadata Policy

Modern browsers send Sec-Fetch-* headers on every request. These headers reveal the request context, allowing precise CSRF protection.

Allowed Request Types

Implementation

/**
 * Check if request passes fetch metadata security checks.
 *
 * Allows:
 * - Same-origin requests
 * - Same-site requests
 * - Navigation requests (user clicking links)
 * - CORS requests for specific resource types
 *
 * Blocks:
 * - Cross-site state-changing requests (POST/PUT/PATCH/DELETE)
 * - Cross-site requests without proper metadata
 */
export function passesFetchMetadata(req: Request): boolean {
  const mode = req.headers.get("sec-fetch-mode") || "";
  const site = req.headers.get("sec-fetch-site") || "";
  const dest = req.headers.get("sec-fetch-dest") || "";

  // Allow same-origin and same-site requests
  if (site === "same-origin" || site === "same-site") {
    return true;
  }

  // Allow user navigation (clicking links, typing URLs)
  if (mode === "navigate") {
    return true;
  }

  // Allow simple GETs for resources if needed
  // Block all cross-site state-changing requests
  if (mode === "cors" && ["image", "style", "script", "font"].includes(dest)) {
    return true;
  }

  // Block everything else (cross-site POST/PUT/PATCH/DELETE)
  return false;
}

/**
 * Assert request passes fetch metadata checks.
 * Throws an error if the request fails security checks.
 */
export function assertFetchMetadata(req: Request): void {
  if (!passesFetchMetadata(req)) {
    const error: Error & { status?: number; code?: string } = new Error(
      "Cross-site request blocked by fetch metadata policy"
    );
    error.status = 403;
    error.code = "CSRF_PROTECTION";
    throw error;
  }
}
import { assertFetchMetadata } from "./fetch-metadata";

export function enforceMutationRequest(
  req: NextRequest,
  allowlist: Set<string> | string[] = defaultAllowlist
) {
  assertOrigin(req, allowlist);
  assertFetchMetadata(req);
}

export { assertFetchMetadata, passesFetchMetadata } from "./fetch-metadata";
import { enforceMutationRequest } from "~/lib/security/csrf";
import { secureErrorJson } from "@workspace/security";

export async function POST(request: Request) {
  try {
    enforceMutationRequest(request);
  } catch (err) {
    if (err.code === "CSRF_PROTECTION") {
      return secureErrorJson(
        { message: "Forbidden", code: "CSRF_PROTECTION" },
        { status: 403 }
      );
    }
    throw err;
  }

  // Protected business logic
}

Browser Compatibility

Fetch Metadata headers are supported in all modern browsers (Chrome 76+, Edge 79+, Firefox 90+). The check fails closed: requests without headers are blocked.

BetterAuth CORS

BetterAuth runs through Convex HTTP routes. The framework applies the same origin allowlist to BetterAuth endpoints.

Convex Integration

import { auth } from "better-auth/convex";
import { httpRouter } from "convex/server";
import { normalizeOrigins } from "@convex/lib/utils";

const allowedOrigins = normalizeOrigins(
  (process.env.ALLOWED_WEB_ORIGINS ?? "").split(",")
);

export const http = httpRouter();

http.route({
  path: "/auth/*",
  method: "POST",
  handler: auth.handler,
});

The BetterAuth SDK validates origins before processing authentication requests. Configuration happens once in convex/http.ts.

Testing CSRF Protection

Valid Same-Origin Request

const response = await fetch("http://localhost:3000/api/update", {
  method: "POST",
  headers: {
    "Origin": "http://localhost:3000",
    "sec-fetch-site": "same-origin",
    "sec-fetch-mode": "cors",
  },
  body: JSON.stringify({ data: "test" }),
});

expect(response.status).toBe(200);

Blocked Cross-Site Request

const response = await fetch("http://localhost:3000/api/update", {
  method: "POST",
  headers: {
    "Origin": "https://evil.com",
    "sec-fetch-site": "cross-site",
    "sec-fetch-mode": "cors",
  },
  body: JSON.stringify({ data: "test" }),
});

expect(response.status).toBe(403);
expect(await response.json()).toMatchObject({
  ok: false,
  error: { code: "CSRF_PROTECTION" },
});

Allowed Navigation

const response = await fetch("http://localhost:3000/page", {
  method: "GET",
  headers: {
    "sec-fetch-site": "cross-site",
    "sec-fetch-mode": "navigate", // User clicked a link
    "sec-fetch-dest": "document",
  },
});

expect(response.status).toBe(200);

Safe Methods Exception

GET, HEAD, and OPTIONS requests bypass origin validation. These methods should not perform state changes:

export function assertOrigin(req: NextRequest, allowlist) {
  const method = req.method.toUpperCase();
  if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
    return; // Safe methods bypass check
  }

  // Validate origin for POST/PUT/PATCH/DELETE
}

GET Side Effects

Never perform state changes in GET handlers. Browsers prefetch GET requests, search engines crawl them, and CSRF protection does not apply.

Integration Coverage

The framework includes comprehensive CSRF tests:

Dashboard API Tests

apps/dashboard/__tests__/auth-route.test.ts validates origin checking on Next.js API routes

Convex HTTP Tests

convex/__tests__/http.csrf.test.ts confirms BetterAuth endpoints reject invalid origins

Tests verify that:

  • Same-origin requests pass
  • Cross-site requests fail with 403
  • Fetch Metadata headers are respected
  • Referer fallback works correctly
  • Development localhost is allowed
  • Production environment variables are honored

Learn More