← All guides

Securing Next.js API Routes with Auth (App Router)

Updated 2026-10-14

When an AI tool builds your Next.js app, it creates route handlers like app/api/projects/route.ts that read and write data. The UI only shows these features to logged-in users, so it's easy to assume the API is protected too. It isn't. Every route handler is a public URL that anyone can call with curl.

This guide shows how to lock route handlers down properly, using Supabase Auth as the example. The same ideas apply to Auth.js, Clerk or any other provider.

The two questions every handler must answer

  1. Authentication: who is making this request? If nobody, return 401.
  2. Authorization: is this user allowed to touch this specific record? If not, return 403 or 404.

Most vulnerabilities in AI-built apps come from skipping the second one. The route checks that someone is logged in, then trusts an id from the request body.

An unprotected handler (what AI tools often generate)

// app/api/projects/[id]/route.ts  (don't ship this)
import { NextResponse } from "next/server";
import { db } from "@/lib/db";

export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  await db.project.delete({ where: { id } });
  return NextResponse.json({ ok: true });
}

Anyone who can guess or find a project ID can delete it. No login needed.

Step 1: Get the user on the server

With Supabase, create a server client using @supabase/ssr. In Next.js 15, cookies() is async:

// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (list) => {
          try {
            list.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
          } catch {
            // called from a context where cookies can't be set; safe to ignore
          }
        },
      },
    }
  );
}

Then write a small helper so every handler does the same thing:

// lib/auth.ts
import { createClient } from "@/lib/supabase/server";

export async function requireUser() {
  const supabase = await createClient();
  const { data: { user }, error } = await supabase.auth.getUser();
  if (error || !user) return { user: null, supabase };
  return { user, supabase };
}

Use getUser(), not getSession(), for authorization on the server. getUser() checks the token with Supabase Auth, while getSession() reads the cookie without verifying it.

Step 2: Check auth and ownership in the handler

// app/api/projects/[id]/route.ts
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";

export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const { user, supabase } = await requireUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { data, error } = await supabase
    .from("projects")
    .delete()
    .eq("id", id)
    .eq("owner_id", user.id)
    .select("id");

  if (error) return NextResponse.json({ error: "Server error" }, { status: 500 });
  if (!data?.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
  return NextResponse.json({ ok: true });
}

Two things are happening here:

Returning 404 instead of 403 for someone else's record avoids confirming that the ID exists.

Step 3: Never trust user IDs from the request

This pattern is very common and very wrong:

const { userId, name } = await req.json();
await supabase.from("projects").insert({ owner_id: userId, name });

The client can send any userId it likes. Always take identity from the verified session:

const body = await req.json();
await supabase.from("projects").insert({ owner_id: user.id, name: String(body.name ?? "") });

The same goes for roles. Don't read isAdmin from the body, a query string, or user-editable metadata. Look it up from a table or app_metadata on the server.

Step 4: Validate input

Use a schema library like Zod so unexpected fields never reach your database:

import { z } from "zod";

const CreateProject = z.object({ name: z.string().min(1).max(100) });

export async function POST(req: Request) {
  const { user, supabase } = await requireUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const parsed = CreateProject.safeParse(await req.json().catch(() => null));
  if (!parsed.success) return NextResponse.json({ error: "Invalid input" }, { status: 400 });

  const { data, error } = await supabase
    .from("projects")
    .insert({ owner_id: user.id, name: parsed.data.name })
    .select()
    .single();
  if (error) return NextResponse.json({ error: "Server error" }, { status: 500 });
  return NextResponse.json(data, { status: 201 });
}

Picking fields explicitly also prevents mass assignment, where a user sends { "plan": "pro" } and it gets written straight into their row.

Why middleware alone isn't enough

Next.js middleware is useful for redirecting logged-out users away from pages and refreshing auth cookies. But don't make it your only check:

Treat middleware as a convenience, and put the real check in each handler or in a shared data-access function.

Don't forget the service role

If a handler uses the Supabase service role key, RLS is bypassed, and your code is the only thing protecting the data. Keep service role use to the few places that truly need it (webhooks, admin jobs), and still verify who the caller is. Never expose that key to the browser. See exposed keys in NEXT_PUBLIC_ and VITE_.

Handlers that are public on purpose

Some routes should be callable without a login, like a Stripe webhook or a contact form. Protect them differently:

Test it like an attacker

Log out, then call your API directly:

curl -i -X DELETE https://your-app.com/api/projects/SOME_ID

You should get 401. Then sign in as user A, copy a project ID belonging to user B, and try to read, update and delete it from user A's session. Each request should fail.

Check every route at once

Going route by route works, but it's easy to miss one, especially after an AI tool adds three new handlers in a single prompt. PreflightPR flags API routes that read or write data without an auth check, along with leaked keys and missing RLS. Run a free scan on a public repo to see which of your routes need attention. It's an automated best-effort review, so pair it with the manual tests above.

Check your repo in about a minute

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

Run a free scan

More guides