Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Clerk API route not protected: how to fix it

FIX / CLERK API AUTH / 9 MIN READ

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.

  1. List the API routes of your app. In Next.js, look for route.ts files under app/api and for files under pages/api.
  2. Run curl against 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"}'
  1. 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.

  1. 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 })
}
  1. Use the userId from auth() in every database query. Do not read a user ID from the body, the query string, or a header. See the next section.
  2. Choose between auth() and auth.protect(). The Clerk docs for auth() state what auth.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.
  3. 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.
  4. Deploy and run the curl test 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.

  1. 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.
  2. 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.
  3. Look for requests with no session. If your logs show the Authorization header 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.
  4. 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.
  5. 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.
  6. 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.

  1. 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.
  2. 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.
  3. Write one helper for the check. A function such as requireUser() returns the userId or a 401 response. Each route calls it on the first line. A search for routes without the helper then finds the gaps.
  4. Add a test. For each route, send a request with no cookie and expect 401 or 404. Run the test in your deployment pipeline.
  5. Add a rule to your coding tool. Write in the project rules: “Every route handler checks auth() and uses userId in the query.” AI tools follow a clear rule most of the time, but you must still review the code.
  6. 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?
No. The basic setup, clerkMiddleware() with no arguments, passes no check to Clerk. Add an auth check in the middleware or in each route handler.
Why does auth.protect() return 404 and not 401 for an API call?
For an unauthenticated request with a session token, Clerk returns 404. Check userId yourself with auth() if you want a 401 status code.
Is a signed-in page enough to protect the API behind it?
No. A person can call the API URL directly. Every route handler must check the user on its own.
Do I need to check the user in the middleware and in the route?
Use the route check as the main control. The middleware check is a second layer that catches a route where you forget the check.
Can I trust a userId that the browser sends in the body?
No. Read the user ID from auth() on the server. Ignore any user ID in the body, the query string, or the headers.

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

SCAN YOUR APP →