NextAuth middleware not protecting routes: how to fix it
NextAuth middleware protects only the paths in its matcher, so add every protected path to the matcher, check the session in each route handler, and upgrade Next.js. If a page or an API route opens for a visitor who is not signed in, one of four things is wrong. The matcher misses the path, the file is not in the right place, the callback allows the request, or the middleware never runs for the request.
This guide shows how to find which one applies. It then gives the fix for NextAuth v4 and for Auth.js v5, and the steps to check the damage.
What does “NextAuth middleware not protecting routes” mean?
It means that a visitor with no session can open a path that you expect to be private. The visitor does not get a redirect to the sign-in page. The route returns the page or the data.
The middleware is a function that runs before a request reaches a page or a route. NextAuth uses it to check the session. It does not protect your app as a whole. It protects the paths that it is set to run on.
Two versions of the library exist, and the setup differs:
| Version | Package | Setup |
|---|---|---|
| NextAuth v4 | next-auth |
export { default } from "next-auth/middleware" |
| Auth.js v5 | next-auth@5 |
export { auth as proxy } from "@/auth" in Next.js 16 and later |
Check your package.json first. The steps are different for each version, and a mix of both setups is a common cause of the problem.
How do I confirm that the route is open?
Send a request with no cookie. The browser sends your session cookie, so do not test in the browser where you are signed in. Use curl, or use a private window.
curl -i https://your-app.example/dashboard
curl -i https://your-app.example/api/projects
Read the result for each path that must be private.
| Response | Meaning |
|---|---|
307 or 302 to the sign-in page |
The middleware protects this page |
401 |
The route or the middleware rejects the request |
200 with the page or the data |
The path is open |
The middleware redirects a page request. It does not return a 401 by default, so a 307 for an API route is normal. If an API route returns 200 with data, the route is open.
Check all the paths, not only the page that you looked at. List the pages under app and the routes under app/api. Test each one that shows or changes data.
Warning: Do not run a POST or DELETE test against your production data. Test a copy of the app, or read the code of the route.
Why does the middleware not run on my route?
Check these causes in order. Most apps have one of the first three causes.
1. The matcher does not include the path
The matcher is the list of paths where the middleware runs. The NextAuth v4 docs show this setup:
export { default } from "next-auth/middleware"
export const config = { matcher: ["/dashboard"] }
The docs state the result: you can still visit every page, but only /dashboard requires authentication. Every path that you do not list is public.
The Next.js matcher is anchored to the start of the path. According to the Next.js proxy docs, /about matches /about and /about/team. Use :path* for clarity, as in /dashboard/:path*.
2. The matcher excludes the API routes
The Auth.js docs show this matcher, which skips the API routes, the static files, and the favicon:
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
The pattern is correct for pages. It means that the middleware never runs for /api. If you copy this pattern, each API route must check the session on its own. See the section on route handlers below.
3. The file is in the wrong place or has the wrong name
The file must be in the project root, or in src if you use that folder. It must be at the same level as app or pages. A file in another folder does not run. No error appears.
In Next.js 16 and later, the file convention middleware is deprecated and renamed to proxy. The Next.js docs give a codemod:
npx @next/codemod@canary middleware-to-proxy .
The codemod renames the file and the function. Check the Next.js version in your package.json, and use the file name that matches the version.
4. The authorized callback allows the request
In Auth.js v5, the authorized callback decides if the request may continue. This is the example from the Auth.js docs:
import NextAuth from "next-auth"
export const { auth, handlers } = NextAuth({
callbacks: {
authorized: async ({ auth }) => {
return !!auth
},
},
})
If the callback returns true for every request, the middleware allows all requests. Read your callback. AI tools sometimes write return true to stop a redirect loop, and this removes the protection.
The wrapper form has a similar trap. In the wrapper, your function must return a response to block the request:
import { auth } from "@/auth"
export const proxy = auth((req) => {
if (!req.auth && req.nextUrl.pathname !== "/login") {
return Response.redirect(new URL("/login", req.nextUrl.origin))
}
})
If the function returns nothing, the request continues. A condition that is too narrow lets other paths through.
5. The session strategy is not supported
The NextAuth v4 docs list a limit: the middleware supports only the "jwt" session strategy. If you use the database strategy, the middleware cannot read the session. Change the strategy to jwt in your authOptions. Or leave the middleware as a first layer and check the session in each page and route with getServerSession.
6. The data call does not pass through the middleware
A Server Action runs as a POST request to the route where it is used. The Next.js docs state that a matcher which excludes a path also skips the Server Function calls on that path. A refactor can also move an action to a path that the matcher does not cover. The action then runs with no middleware check.
Read each Server Action and look for a session check on the first line. If the check is missing, add it.
How do I fix it now?
Do the steps in this order. Steps 1 to 3 close the open routes. The other steps remove the cause.
- Make a list of the private paths. Include the pages, the API routes, and the Server Actions.
- Add a session check to each API route and Server Action. The route check works even when the matcher is wrong. See the example below.
- Fix the matcher. Add each private path, or switch to a deny-by-default matcher with a short list of public paths.
- Fix the callback. Return
!!authinauthorized, nottrue. - Upgrade Next.js. See the next section.
- Deploy.
- Run the
curltest again. Test each path with no cookie.
A route handler check in Auth.js v5 looks like this:
import { auth } from "@/auth"
export async function GET() {
const session = await auth()
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
return Response.json({ id: session.user.id })
}
In NextAuth v4, use getServerSession(authOptions) in the same way. The value session.user.id exists only if you add it in the session callback. Use this ID in each database query, not an ID from the request body.
Caution: The Auth.js docs say: “You should not rely on the proxy exclusively for authorization.” They add that you must verify the session as close to your data fetching as possible. The Next.js Data Security guide gives the same advice.
Can a request skip the middleware?
Yes, in older versions of Next.js. The vulnerability is known as CVE-2025-29927. Next.js uses an internal header named x-middleware-subrequest to prevent loops. The header was not validated, so a request that carried it could skip the middleware.
The postmortem from Vercel lists the facts:
| Item | Detail |
|---|---|
| Patched versions | 12.3.5, 13.5.9, 14.2.25, and 15.2.3 |
| Affected | Self-hosted apps that use next start or output: 'standalone' |
| Not affected | Apps on Vercel and Netlify, and static exports |
| Workaround | Remove the header before the request reaches the Next.js server |
If you self-host an old version, upgrade now. The post also advises that you do not use the middleware as the only route protection. A session check inside the route is not affected by this bug.
To test your own app, read the version of Next.js in your package.json. Compare it with the patched versions in the table. If your version is older than the patched version of your major line, upgrade before you do anything else. The upgrade is a small change in most apps, and it closes a bug that you cannot see from the outside.
If the host is Vercel or Netlify, the postmortem says that this bug did not apply. The other causes in this guide still apply to your app, because they come from your own matcher and your own code.
How do I check the damage?
Assume that any person who knew the URL could read the open path. Check the logs and the data for the period in which the path was open.
- Find when the path became open. Read the git history of the middleware file and the matcher. Look for the commit that removed a path or added
apito the exclusions. - Read the request logs of your host. Filter by the open path. Look for requests from IP addresses that you do not know and for high volume.
- Look for requests with no session cookie. Many hosts do not log cookies, so you may not be able to prove that nobody used the path.
- Check the data. Look for new rows, changed rows, and deleted rows that you did not cause. Look at the columns that store the user ID.
- Check paid APIs and email. If the open route calls an AI model or sends email, read the usage page of the vendor.
- Check the account list. Look for users that you do not know in your user table.
If the open path showed personal data, you may have a duty to tell the users. The rules depend on your country, so ask a lawyer or a security engineer.
How do I stop this from happening again?
Use a design where a new path is closed until you open it.
- Use a deny-by-default matcher. Match all paths and list the public ones. A new page is then protected from the first request.
- Check the session in each route handler and Server Action. Write one helper, such as
requireUser(), and call it on the first line. - Write a test. For each private path, send a request with no cookie and expect a redirect or a 401. Run it after each deploy.
- Keep Next.js and next-auth up to date. Read the release notes for a security fix.
- Review changes to the matcher. A matcher change removes protection without an error. Ask your coding tool to explain each change, and read the diff.
- Scan the live app. A scan sends requests with no session to your deployed paths. It finds the paths that your tests do not cover.
For the full rules on how to design the checks, read authentication implementation and authorization patterns. If your routes take an ID in the URL, also read BOLA in AI-generated CRUD.
When should I get help?
Get help in these cases:
- The open path returned personal data, payment data, or health data. Ask a security engineer to review the app.
- Your logs show many requests with no session to the open path.
- You self-host an old Next.js version and you cannot upgrade.
- You find rows that you did not create, or rows that changed.
- You have many routes and you cannot test each one by hand. A scan of the live app gives a list of the paths that answer with no session.
After the fix, run the curl test on every private path again. Each path must redirect or return 401 when you send no cookie.
Common questions
Why does my NextAuth middleware not run on some routes?
Does NextAuth middleware work with database sessions?
Is the middleware enough to protect my app?
Was there a Next.js bug that let a request skip the middleware?
Is middleware.ts the same as proxy.ts?
Test routes without a session
A VibeEval scan requests the pages and API routes of your deployed app without a session and reports the paths that answer with data.
14-day free trial · No credit card · Cancel anytime