← All guides

CORS in Next.js API Routes: Fix the Error Without Opening a Security Hole

Updated 2026-10-27

Short answer: fix a CORS error by allowing only the specific origins that need to call your API, answering the OPTIONS preflight request, and never combining a reflected or wildcard origin with Access-Control-Allow-Credentials: true. And remember that CORS is not security for your API. It only controls which websites a browser will let read the response. curl, scripts and bots ignore it completely, so every route still needs its own auth check.

If you build with Lovable, Bolt, v0, Cursor or Replit, you've probably seen this error in the console:

Access to fetch at 'https://api.example.com/api/data' from origin 'https://myapp.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

The fastest fix an AI assistant will suggest is "allow everything". Sometimes that's fine. Sometimes it quietly lets any website on the internet act as your logged-in users. Here's how to tell the difference.

What CORS actually does

Browsers enforce the same-origin policy: JavaScript on https://evil.com can send requests to https://myapp.com, but it can't read the responses unless myapp.com says it's allowed. CORS headers are how your server says that.

For "non-simple" requests (for example a POST with Content-Type: application/json, or any request with an Authorization header), the browser first sends an OPTIONS preflight request. If the preflight response doesn't allow the origin, method and headers, the real request is never sent.

Three things follow from this:

  1. CORS only matters for browsers. Anyone can call your API from a terminal. CORS doesn't protect data, auth does.
  2. CORS errors mean the browser blocked reading the response, not that your server rejected the request. For simple requests, the server may have already done the work.
  3. The dangerous misconfigurations involve credentials. If browsers send cookies along with cross-origin requests and your server lets any origin read the response, a malicious site can read your users' private data while they're logged in.

The insecure fixes AI tools often generate

Reflecting the Origin header with credentials

// vulnerable: trusts every origin AND allows cookies
const origin = req.headers.get("origin") ?? "*";
return new Response(body, {
  headers: {
    "Access-Control-Allow-Origin": origin,
    "Access-Control-Allow-Credentials": "true",
  },
});

This is the worst one. Browsers refuse Access-Control-Allow-Origin: * together with credentials, so code like this "works around" that rule by echoing back whatever origin asked. Now any site a logged-in user visits can call your API with their cookies and read the result. Modern browsers' default SameSite=Lax cookie behavior limits some of this, but cookies set with SameSite=None (common for cross-site setups) are fully exposed.

Weak origin matching

// vulnerable: "https://myapp.com.evil.com" and "https://evilmyapp.com" both pass
if (origin.includes("myapp.com")) allow(origin);
if (origin.endsWith("myapp.com")) allow(origin);

Match exact strings from a list instead.

Wildcard on routes that use cookies

Access-Control-Allow-Origin: * without credentials is fine for truly public data (a public product list, a status endpoint). It's a problem when the route relies on something the browser attaches automatically, or when it returns data that should only be visible from your own site.

The safe pattern in Next.js App Router

Most apps don't need CORS at all. If your frontend and API are on the same origin (your Next.js pages calling /api/... on the same domain), there's no cross-origin request and nothing to configure. CORS errors usually appear when the frontend and API are on different domains or ports, for example a Vite frontend on localhost:5173 calling a Next.js API on localhost:3000, or a separate marketing site.

When you do need it, use an explicit allowlist:

// lib/cors.ts
const ALLOWED_ORIGINS = new Set([
  "https://myapp.com",
  "https://www.myapp.com",
  ...(process.env.NODE_ENV === "development" ? ["http://localhost:5173"] : []),
]);

export function corsHeaders(origin: string | null): Record<string, string> {
  if (!origin || !ALLOWED_ORIGINS.has(origin)) return { Vary: "Origin" };
  return {
    "Access-Control-Allow-Origin": origin,
    "Access-Control-Allow-Credentials": "true",
    "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "600",
    Vary: "Origin",
  };
}
// app/api/notes/route.ts
import { corsHeaders } from "@/lib/cors";
import { createClient } from "@/lib/supabase/server";

export async function OPTIONS(req: Request) {
  return new Response(null, { status: 204, headers: corsHeaders(req.headers.get("origin")) });
}

export async function GET(req: Request) {
  const headers = corsHeaders(req.headers.get("origin"));
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return Response.json({ error: "Unauthorized" }, { status: 401, headers });

  const { data } = await supabase.from("notes").select("*").eq("user_id", user.id);
  return Response.json(data ?? [], { headers });
}

A few notes on this pattern:

You can also set CORS headers for many routes at once in next.config.js headers() or in middleware.ts, but static config can't do allowlist matching for multiple origins, and middleware shouldn't be your only security layer (see the middleware bypass CVE-2025-29927 guide).

Supabase Edge Functions

Supabase's examples use a shared corsHeaders object with 'Access-Control-Allow-Origin': '*'. That's acceptable when the function authenticates with the Authorization: Bearer <jwt> header (which a malicious site can't obtain for your user) and checks the user inside the function. It's not acceptable if the function skips auth or trusts a user_id sent in the body.

// supabase/functions/my-fn/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "https://myapp.com",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } } },
  );
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response("Unauthorized", { status: 401, headers: corsHeaders });

  // ... do work for this user only
  return Response.json({ ok: true }, { headers: corsHeaders });
});

Locking the origin to your domain is still a good habit for functions that cost money, like ones that call an AI API, though it won't stop scripted abuse. For that you need a rate limit, covered in rate limiting OpenAI API routes.

CORS does not replace these

Because CORS errors are so visible, it's easy to think getting them right means the API is locked down. It doesn't. Before launch, still check that:

Test your CORS setup

Use curl to see what your server returns for an untrusted origin:

curl -i -X OPTIONS https://myapp.com/api/notes \
  -H "Origin: https://evil.example" \
  -H "Access-Control-Request-Method: GET"

You should not see Access-Control-Allow-Origin: https://evil.example in the response, especially not with Access-Control-Allow-Credentials: true. Then repeat with your real origin and confirm it's allowed. Finally, call the route with no cookies or token and confirm it returns 401. For a full pre-launch list, see the pre-launch security checklist for AI-built apps.

Get an automated check

PreflightPR reviews AI-built apps for API routes without auth checks, leaked keys, missing Supabase RLS, unverified webhooks and other launch blockers. Run a free scan on a public repo for a launch score and a prioritized fix list, or install the PreflightPR GitHub App to check every pull request before it merges. It's an automated best-effort review, so still run the curl tests above against your deployed app.

Check your repo in about a minute

Paste a public GitHub URL and get a free launch score.

Run a free scan

More guides