Guides / Clerk API route protection
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:
- Every Route Handler (
app/**/route.ts) callsauth()orauth.protect()before touching data. - Every Server Action (
'use server'functions) checks the user inside the function itself. - Every page that reads private data checks too. A check in a layout alone isn't enough.
- The
userIdyou trust comes fromauth(), never from the request body or a query string.
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:
- Server Actions are called by ID, not by path. If an action defined under a protected folder gets called while the user is on a public page, middleware sees the public path and lets the request through.
- Pattern mistakes are easy. A URL regex that doesn't quite match your folder structure (route groups,
[locale]segments) silently leaves a route public. - Framework-level bypasses happen. In 2025, CVE-2025-29927 let requests skip Next.js middleware entirely on self-hosted apps by sending an internal
x-middleware-subrequestheader. Next.js patched it in 15.2.3, 14.2.25, 13.5.9 and 12.3.5, and said in its postmortem that it doesn't recommend middleware as the only way to protect routes. Vercel, Netlify and Cloudflare Workers deployments weren't affected, but the lesson applies everywhere.
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.
- Clerk: Protect content from unauthenticated users (Next.js)
- Clerk: auth() and auth.protect() reference
- Clerk: clerkMiddleware() reference (protecting routes, matcher)
- Clerk: Migrate away from Middleware-based auth checks (createRouteMatcher deprecation, lint rule)
- Clerk: Authorization checks (roles and permissions)
- Next.js: Postmortem on Next.js Middleware bypass (CVE-2025-29927)