Clerk API route not protected: how to fix it
Clerk does not protect an API route unless you add a check, so call auth() inside the route handler and return a 401 when userId is null. The Clerk middleware reads the session. It does not block a request by itself. If your route returns data to a request with no cookie, the route has no check.
This guide shows how to confirm the problem with curl. It then shows how to fix the route, fix the matcher, and check the damage.
What does “Clerk API route not protected” mean?
It means that a person who is not signed in can call your API route and get a normal response. The sign-in page of your app does not change this. The browser shows a login screen, but the API URL stays open to any HTTP client.
Clerk gives you the tools to protect a route, but it does not apply them for you. The Clerk docs for clerkMiddleware() show the basic setup as export default clerkMiddleware() with no checks. The same page says that middleware is not the best place to protect routes. It tells you to protect access close to the resource.
AI coding tools often create this problem. The tool adds clerkMiddleware() and a sign-in button. The dashboard page redirects an anonymous visitor, so the app looks safe. The tool then writes app/api/projects/route.ts and reads the database with no user check.
There are five common causes:
| Cause | What happens |
|---|---|
| The middleware has no check | The request passes with no session |
The matcher skips /api |
The middleware does not run for the route |
| The public route list is too wide | A pattern such as /api/(.*) marks every route as public |
The handler never calls auth() |
The handler trusts every caller |
| The handler trusts a user ID from the request | The caller chooses the identity |
How do I confirm that the route is open?
Send a request with no cookie and no token. Do not use the browser, because the browser sends your session cookie. Use curl in a terminal.
- List the API routes of your app. In Next.js, look for
route.tsfiles underapp/apiand for files underpages/api. - Run
curlagainst each route that returns or changes data:
curl -i https://your-app.example/api/projects
curl -i -X POST https://your-app.example/api/projects \
-H "Content-Type: application/json" \
-d '{"name":"test"}'
- Read the status code and the body.
| Response | Meaning |
|---|---|
200 with data |
The route is open. Fix it now |
401 |
The route checks the user |
404 from Clerk |
The route is protected with auth.protect() |
307 or 302 to a sign-in page |
The middleware protects the route as a page |
400 or 500 |
The route ran. Read the code, because the route may still be open |
Warning: Do not test a destructive route (DELETE, or a route that sends email or charges money) on your production data. Test on a copy of the app, or read the code of the route.
Also search the code for routes that have no check. Run this in the repository:
grep -rL "auth()" app/api --include="route.ts"
The command lists the route files that do not contain the text auth(). Some of these files are public on purpose, such as a webhook route. Review each file in the list.
How do I fix the route now?
Fix the most dangerous routes first. These are the routes that return personal data, change data, or call a paid API.
- Add an auth check to the handler. The Clerk guide to protect content shows this pattern:
import { auth } from '@clerk/nextjs/server'
export const GET = async () => {
const { isAuthenticated, userId } = await auth()
if (!isAuthenticated) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
return Response.json({ userId })
}
- Use the
userIdfromauth()in every database query. Do not read a user ID from the body, the query string, or a header. See the next section. - Choose between
auth()andauth.protect(). The Clerk docs forauth()state whatauth.protect()returns for a request that is not a page request. For an unauthenticated session request, it returns a 404. A 404 is safe, but it hides the cause when you debug. Use the explicit check above if you want a 401. - Add the same check to every Server Action. The Clerk guide says that a Server Action can be called from any Client Component, so the action must verify the user itself.
- Deploy and run the
curltest again. The route must return 401 or 404 with no cookie.
Caution: A client-side check is not a security control. The Clerk guide describes client-side checks as a user interface help. Hide a button with useAuth() if you want, but always check on the server.
Why does the middleware not protect my API route?
Check three things in the middleware file. In Next.js 16 and later, the file name is proxy.ts. In older versions, the name is middleware.ts. The Next.js proxy docs say to put the file in the project root, or inside src if you use that folder, at the same level as app. A file in the wrong folder does not run.
First, check the matcher. The matcher controls the paths where the middleware runs. The Clerk docs recommend this entry in the matcher, with the comment “Always run for API routes”:
'/(api|trpc)(.*)'
Many Next.js examples use a matcher that excludes api. The Next.js docs show '/((?!api|_next/static|_next/image|.*\\.png$).*)' as an example of a negative match. If you copy this pattern, the middleware does not run for any /api path. Compare your matcher with the one in the Clerk docs.
Second, check for a check. clerkMiddleware() with no callback does not block anything. To block an API request in the middleware, add a callback:
import { clerkMiddleware } from '@clerk/nextjs/server'
export default clerkMiddleware(async (auth, req) => {
const path = req.nextUrl.pathname
if (path.startsWith('/api/') && !path.startsWith('/api/webhooks/')) {
await auth.protect()
}
})
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)(.*)',
],
}
This code protects every API route except the webhook routes. A webhook route must be public, because the sender has no Clerk session. Verify the signature of the sender inside each webhook route.
Third, check the public list. If your code has a list of public routes, read each pattern. A pattern such as /api/(.*) is public for every API route. Replace it with the exact paths that must be public.
The middleware is a second layer. Do not rely on it alone. The Next.js docs make the same point for Server Functions: a matcher change can remove the middleware coverage without a warning.
What if the route must stay public?
Some routes must be public. A health check, a public product list, and a webhook receiver are examples. A public route is safe only when it returns data that every visitor may read.
Make a list of the public routes and review each one. Ask three questions for every route on the list. Does the response contain any field that belongs to one user? Does the route change data or cost money? Can a caller send a very large number of requests?
If the answer to any question is yes, the route is not a good public route. Add the auth check, or add a rate limit and reduce the fields in the response. A webhook route needs its own control. The route must verify the signature of the sender and reject any request with a wrong signature.
The Clerk docs for auth() also describe tokens for machine requests, such as API keys. If other services call your route, check the token type in the handler. Do not accept a machine token on a route that is only for signed-in users.
How do I stop a user from reading data that belongs to another user?
A signed-in user can still read the data of another user if the route does not filter by owner. This is a different bug, but it often appears together with a missing auth check. The check says who the caller is. A second step must say what the caller may read.
Use the userId from auth() as a filter in the query. This example uses a Postgres table with an owner_id column:
import { auth } from '@clerk/nextjs/server'
import { sql } from '@/lib/db'
export const GET = async (
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) => {
const { isAuthenticated, userId } = await auth()
if (!isAuthenticated) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const rows = await sql`
select id, name from projects
where id = ${id} and owner_id = ${userId}
`
if (rows.length === 0) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(rows[0])
}
The query uses a parameter for each value, not string concatenation. The route returns 404 when the row belongs to another user. The caller then cannot learn that the row exists. For more patterns, read BOLA in AI-generated CRUD and authorization patterns.
How do I check the damage?
Assume that anyone who knew the URL could call the route. Check the data and the logs for the period in which the route was open.
- Find the date the route became open. Use the git history of the route file. Look for the first commit that has no
auth()call. - Read the request logs of your host. Open the logs of Vercel, Netlify, or your server. Filter by the path of the route. Look for requests from IP addresses that you do not know, and for requests with high volume.
- Look for requests with no session. If your logs show the
Authorizationheader or the cookie names, look for requests with neither. Many hosts do not log headers. In this case, you cannot prove that nobody used the route. - Check the data. Look for rows that you did not create. Check for rows with a null owner column, rows with a strange name, and rows that changed in the period.
- Check paid APIs. If the route calls an AI model or sends email, read the usage page of that vendor. Look for a spike in the period.
- Check the Clerk dashboard. Open the user list and look for accounts that you do not know. A route that creates data without a user can also be a sign that the app lacks a real sign-up check.
If the route returned personal data of your users, you may have a duty to tell those users. Ask a lawyer or a security engineer, because the rules depend on your country.
How do I prevent the next unprotected route?
Use a default that denies access. Then a new route that you forget is closed, not open.
- Protect all API routes in the middleware. Use the callback above and keep a short list of public paths. A new route is then protected from the first request.
- Check the user in the handler, as well. The two layers cover different mistakes. The middleware covers a forgotten check. The handler covers a matcher change.
- Write one helper for the check. A function such as
requireUser()returns theuserIdor a 401 response. Each route calls it on the first line. A search for routes without the helper then finds the gaps. - Add a test. For each route, send a request with no cookie and expect 401 or 404. Run the test in your deployment pipeline.
- Add a rule to your coding tool. Write in the project rules: “Every route handler checks
auth()and usesuserIdin the query.” AI tools follow a clear rule most of the time, but you must still review the code. - Scan the live app after each release. A scan sends requests to your deployed routes with no session, like an outside attacker. It finds a route that your tests do not cover.
For the wider rules on API design, read the API security guide. For limits on how many requests a caller may send, read API abuse protection.
When should I get help?
Get help in these cases:
- The route returned personal data, payment data, or health data. Ask a security engineer to review the app, and check your legal duties.
- Your logs show many requests from unknown IP addresses to the open route.
- You find rows that you did not create, or rows that changed or disappeared.
- The route also called a paid API, and the bill shows a spike.
- You have many routes and you cannot check each one. A scan of the live app gives a list of the routes that answer without a session.
After the fix, run the curl test on every route again. Each route that handles data must return 401 or 404 when you send no cookie.
Common questions
Does clerkMiddleware protect my API routes by default?
Why does auth.protect() return 404 and not 401 for an API call?
Is a signed-in page enough to protect the API behind it?
Do I need to check the user in the middleware and in the route?
Can I trust a userId that the browser sends in the body?
Test your API routes
A VibeEval scan sends requests to the API routes of your deployed app without a session and reports the routes that return data.
14-day free trial · No credit card · Cancel anytime