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 Validation
Checks the Origin or Referer header against approved domains
Fetch Metadata
Validates sec-fetch-site, sec-fetch-mode, and sec-fetch-dest headers
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