Guides / Clerk API route protection

last checked oct 2026

Are your Clerk API routes actually protected? Why middleware isn't enough

Your sign-in page works, the dashboard hides itself when you're signed out, and middleware.ts has a list of protected routes. That feels done. But the data your app serves comes from Route Handlers and Server Actions, and if those don't check who's calling, anyone with curl can call them too.

The short answer

Put the auth check inside every server resource that reads or changes private data, not only in middleware. Clerk's own docs now say middleware "is not the best place to protect routes" and that you should protect access "as close to the resource as possible". In a Next.js App Router app, that means:

Client-side checks don't protect anything. useAuth(), useUser() and signed-in/signed-out components only change what's shown. Clerk's docs call them "a UX affordance, not a security boundary". The API the page calls still answers anyone who asks.

Why middleware isn't enough

For a long time the usual setup was createRouteMatcher() plus auth.protect() in clerkMiddleware(). Clerk has deprecated createRouteMatcher(). It still works, it logs a deprecation warning, and Clerk says it will be removed in the next major version of @clerk/nextjs. Their migration guide gives the reasons:

Keep clerkMiddleware() itself. Clerk still needs it for auth() to work, and the matcher in Clerk's docs always runs it on /api routes. Just stop relying on it as the lock.

How to check your app

1. Call your API while signed out

Open DevTools, use your app while signed in, and note the /api/... requests in the Network tab. Then call each one from a terminal with no cookies:

# Replace with your own deployed URL and route. No cookies, no token.
curl -s -o /dev/null -w '%{http_code}\n' https://your-app.com/api/projects

# Try writes too
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  -H 'Content-Type: application/json' -d '{}' \
  https://your-app.com/api/projects

A 401 or 404 is what you want. A 200 means the route answered a stranger. These commands print only the status code, so nothing private ends up in your terminal history. Only test apps you own.

2. Find Route Handlers that never call auth

From your project root, list every route.ts or route.js file that doesn't mention auth( or getAuth(:

grep -rL --include='route.ts' --include='route.js' -E 'auth\(|getAuth\(' app src/app 2>/dev/null

Each file in that list is either meant to be public (a webhook, a health check) or a gap. Webhooks should verify their own signature instead, like Stripe webhooks do.

3. Find Server Actions and check each one

grep -rln --include='*.ts' --include='*.tsx' "'use server'" app src 2>/dev/null

Open each file and make sure every exported function checks the user at the top, before any database call.

4. Look for routes that trust a user ID from the client

grep -rnE --include='*.ts' --include='*.tsx' '(body|searchParams|params)\.(userId|user_id|ownerId)' app src 2>/dev/null

If a route reads the user's ID from the request, a signed-in user can swap in someone else's ID. Use the userId that auth() returns instead.

5. See what middleware is still doing

grep -rn 'createRouteMatcher' --include='*.ts' --include='*.js' . --exclude-dir=node_modules
npm ls next

Any createRouteMatcher() hit is a list of routes that need their own checks. If you self-host Next.js, make sure the installed version is at or above the patched release for your major version.

How to fix it

Protect a Route Handler

The shortest version uses auth.protect(). For API requests it doesn't redirect. Signed-out requests get a 404:

// app/api/me/route.ts
import { auth } from "@clerk/nextjs/server";

export async function GET() {
  const { userId } = await auth.protect(); // 404 if signed out
  return Response.json({ userId });
}

If you'd rather return a clear 401, check isAuthenticated yourself:

// app/api/projects/route.ts
import { auth } from "@clerk/nextjs/server";

export async function GET() {
  const { isAuthenticated, userId } = await auth();
  if (!isAuthenticated) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Scope the query to the signed-in user. Your DB call goes here.
  const projects = await db.project.findMany({ where: { ownerId: userId } });
  return Response.json(projects);
}

The db call is an example. The point is the where: filter by the userId from auth(), so one signed-in user can't read another's rows by changing an ID.

Protect a Server Action

// app/actions.ts
"use server";
import { auth } from "@clerk/nextjs/server";

export async function deleteProject(projectId: string) {
  const { userId } = await auth.protect(); // 401 for signed-out Server Action calls

  // Only delete rows this user owns
  await db.project.deleteMany({ where: { id: projectId, ownerId: userId } });
}

Protect pages, not just layouts

// app/dashboard/page.tsx
import { auth } from "@clerk/nextjs/server";

export default async function Page() {
  await auth.protect(); // redirects signed-out visitors to sign-in
  return <h1>Dashboard</h1>;
}

Clerk notes that layouts don't re-render on client-side navigation, so a layout check alone doesn't re-check the session on every route change. If Cache Components is on (the default for new Next.js apps), add a loading.tsx next to pages that read auth data.

Pages Router API routes

// pages/api/projects.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { getAuth } from "@clerk/nextjs/server";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const { userId } = getAuth(req);
  if (!userId) return res.status(401).json({ error: "Unauthorized" });
  // ...query scoped to userId
  return res.status(200).json({ ok: true });
}

Then simplify middleware

Once each resource has its own check, you can drop the route list and keep plain clerkMiddleware() with your existing config.matcher:

// middleware.ts (proxy.ts on Next.js 16+)
import { clerkMiddleware } from "@clerk/nextjs/server";

export default clerkMiddleware();

export const config = {
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
    "/__clerk/(.*)",
  ],
};

Clerk also ships a lint rule, @clerk/next/require-auth-protection in @clerk/eslint-plugin, that warns when an App Router resource is missing a check. It's marked experimental, so pin the version. Clerk's migration guide covers setup and a bulk fixer that inserts auth.protect(). Review what it changes before you ship. After any change, run the signed-out curl checks from step 1 again.

next step

Check your live app for free

Our free Leak Checker looks at your deployed app the way any visitor's browser does and flags exposed secret keys, public source maps and missing security headers. It won't test your API routes for you, but it's a quick first pass on what your frontend gives away.

If you want auth checked too, the $99 Scan + Report covers your repo and live app, including auth checks on API routes, leaked keys, deploy settings and database access rules.

Sources

Every product step above was checked against these official pages. Last checked Oct 2026. Products change; if an API moved, the docs below are the source of truth.