HomeGuidesClerk route protection in Next.js: clerkMiddleware, tested

Guide

Clerk route protection in Next.js: clerkMiddleware, tested

clerkMiddleware() leaves every Next.js route public, and createRouteMatcher is now deprecated. What we saw in tests, and how to protect pages, APIs and actions.

13 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

You added Clerk to a Next.js app with Cursor, Lovable, Bolt or Claude Code. The sign-in button works, the dashboard shows your name, and the AI tool wrote a middleware.ts (or proxy.ts on Next.js 16) with clerkMiddleware(). It is easy to assume that file is what keeps strangers out. Often it keeps nobody out.

This guide covers what clerkMiddleware() does and does not protect, why Clerk deprecated createRouteMatcher() in July 2026, the gaps we reproduced (sibling paths, unlisted API routes, file extension routes and Server Actions), and the fix: an auth check in every page, Route Handler and Server Action, plus a lint rule that keeps it that way.

Tested on October 29, 2026. We built a small Next.js 16.4.3 App Router app with @clerk/nextjs 7.9.22 (React 19.3.1, Node 22.23.4), ran it with next build and next start, and sent signed-out requests with curl against five versions of proxy.ts that all use the matcher from Clerk's docs. We used a placeholder publishable key and secret key, so nothing reached a real Clerk instance and we tested signed-out behavior only, not signed-in users, roles or organizations. We also ran @clerk/eslint-plugin 0.2.0 with ESLint 9.39.5 on the same app. Every claim below is something we reproduced or a quote from the Clerk and Next.js docs, Clerk's changelog and the GitHub advisories linked at the end. VibeLocked is independent and not affiliated with Clerk or Vercel.

The short version

  1. clerkMiddleware() alone protects nothing. It authenticates the request so auth() works. Every route stays public until you check.
  2. createRouteMatcher() is deprecated. Since @clerk/nextjs 7.5.14 Clerk says to move auth checks into each resource. It will be removed in the next major version.
  3. Path matching has gaps. /dashboard(.*) also matches /dashboardxyz. An API route you forgot to list is public. A route with a file extension can skip the middleware completely.
  4. Server Actions are called by ID, not path. A middleware gate on /dashboard did not stop a dashboard action posted to a public page.
  5. Put await auth.protect() (or an auth() check) at the top of every page, Route Handler and Server Action that reads or changes private data.
  6. Signed in is not the same as allowed. Scope every query by userId or orgId, and check roles with has() on the server.
  7. Update @clerk/nextjs. Two 2026 advisories hit createRouteMatcher and combined has() checks.

What clerkMiddleware() actually does

This is the setup from Clerk's clerkMiddleware() reference. On Next.js 16 the file is proxy.ts; on Next.js 15 or older it is middleware.ts, with the same code. The Next.js docs say the middleware file convention "is deprecated and has been renamed to proxy".

// proxy.ts (middleware.ts on Next.js 15 and older)
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
    // Always run for Clerk-specific frontend API routes
    '/__clerk/(.*)',
  ],
}

It reads the session cookie and attaches the auth state to the request. It does not block anything. With this file and no other checks, our signed-out requests got:

Signed-out request Result
GET /dashboard (page) 200, private page rendered
GET /api/private/data 200, private JSON
GET /api/export 200, export data
GET /reports.csv (Route Handler) 200, CSV rows
Dashboard Server Action, posted to /, /pricing and /dashboard 200, action ran

Clerk's reference now says it directly: "Middleware is not the best place to protect routes. Instead, protect access as close to the resource as possible, in the code that reads or mutates the data."

createRouteMatcher() is deprecated

For years the standard pattern, and the one AI tools still write most often, looked like this:

import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/api/private(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) await auth.protect()
})

The @clerk/nextjs 7.5.14 changelog (July 7, 2026) says: "Deprecate createRouteMatcher() in favor of resource-based auth checks." The reason given: "Middleware-based auth checks rely on path matching, which can diverge from how Next.js routes requests and leave protected resources reachable."

Clerk's migration guide adds that createRouteMatcher() "continues to work and its behavior hasn't changed", logs a one-time warning in development, and "will be removed in the next major version of @clerk/nextjs." We checked the shipped source: in 7.9.22 the warning is skipped when NODE_ENV is production or test, so you will not see it in production logs.

The guide lists three motivations: Server Actions are called by ID, regular expressions mapped to URLs and folders are easy to get wrong, and Clerk and Next.js can parse the same path differently. That last one was a real bug, below.

The gaps we reproduced

All tests below are signed out, against next start, with the same matcher as above.

1. (.*) matches sibling paths

createRouteMatcher(['/dashboard(.*)']) redirected /dashboard to sign-in, and also /dashboardxyz, a separate public page. Over-matching fails safe here, but it shows the pattern is a regular expression, not a folder. Clerk's 7.5.13 changelog says /dashboard/:path* "matches only /dashboard and its path-segment subtree". With /dashboard/:path*, our /dashboardxyz page was public again, as expected.

2. An API route you did not list is public

With the list ['/dashboard(.*)', '/api/private(.*)'], GET /api/export returned 200 with the export data. Nothing was wrong with the matcher. The route just was not in the list, which is the usual way this breaks when an AI tool adds a new endpoint later.

The inverted pattern, which protects everything except a public list, did block /api/export:

const isPublicRoute = createRouteMatcher(['/', '/pricing', '/sign-in(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) await auth.protect()
})

3. Routes the matcher skips never reach the middleware

Clerk's default matcher skips paths that end in a static file extension, including .csv, .docx, .xlsx and .zip, unless they are under /api or /trpc. Our Route Handler at app/reports.csv/route.ts returned 200 with the CSV rows under every middleware variant, including the inverted one, because proxy.ts never ran for it.

If the handler itself calls auth.protect(), the request fails closed instead. Ours returned a 500 with "Clerk: auth() was called but Clerk can't detect usage of clerkMiddleware()". So keep downloads under /api/, or widen the matcher, and still check inside the handler.

4. Server Actions ignore your path list

This is the one that matters most. Our deleteProject Server Action lived in app/dashboard/actions.ts and was used by a button on the dashboard. The same button component was also rendered on the public /pricing page, which is common for shared components. With the matcher protecting /dashboard(.*):

  • Posting the action to /dashboard got a 307 to sign-in.
  • Posting the same action ID to /pricing got a 200, and the action ran.

The inverted "protect everything except public" matcher behaved the same, because /pricing was on the public list. Clerk's migration guide describes the same thing: if a Server Function "is called while visiting /public/, Middleware sees /public/ and lets the request through, even if /protected/:path* is protected." The Next.js authentication guide says to treat Server Actions "with the same security considerations as public-facing API endpoints". Our Server Actions security guide covers the rest of that checklist.

5. The bypass that was a real CVE

GHSA-vqx2-fgx2-5wq9 (CVE-2026-41248, rated critical, published April 15, 2026) says createRouteMatcher "can be bypassed by certain crafted requests, allowing them to skip middleware gating and reach downstream handlers." The vulnerable pattern was the isProtectedRoute list above. The advisory says the inverted isPublicRoute pattern "correctly blocks the bypass at the middleware layer", and that auth checks "inside your route handlers, server components, or server actions continue to work correctly and are not affected."

Fixed versions of @clerk/nextjs: 7.2.1 for v7, 6.39.2 for v6 and 5.7.6 for v5. We did not reproduce this bypass; the version we tested is far past the fix. It is the same lesson as the Next.js CVE-2025-29927 middleware bypass: when the only lock is in middleware, one bug in path handling opens everything.

The fix: check auth in every resource

Clerk's protect content guide says "every resource that reads or mutates protected data needs its own check": Server Components (layouts and pages), Route Handlers, Server Actions and any external API your app calls.

Keep proxy.ts with plain clerkMiddleware() and the matcher. Then add the check at the top of each resource.

Page:

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

export default async function Page() {
  const { userId } = await auth.protect()
  // load only this user's data with userId
  return <h1>Dashboard</h1>
}

New Next.js apps enable Cache Components, and the Clerk docs say to add a loading.tsx next to a page that reads auth so it is wrapped in a <Suspense> boundary.

Route Handler, with your own 401:

// 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 })
  }
  const projects = await db.project.findMany({ where: { ownerId: userId } })
  return Response.json(projects)
}

Server Action:

'use server'
import { auth } from '@clerk/nextjs/server'

export async function deleteProject(projectId: string) {
  const { userId } = await auth.protect()
  // the owner filter makes a wrong id delete nothing
  await db.project.deleteMany({ where: { id: projectId, ownerId: userId } })
}

db is a placeholder for your database client. With these checks and a bare clerkMiddleware(), our signed-out requests to /dashboard, /api/private/data and /api/export got a 307 to /sign-in, and the dashboard action posted to /, /pricing and /dashboard did not run. Clerk's auth() reference says auth.protect() redirects document requests, returns a 401 for Server Actions and a 404 for other non-document requests. Our curl calls got the redirect or the rendered sign-in page instead of those status codes, so if you write tests, assert that the private data is missing, not a specific status.

Layouts are not enough. Both Clerk and the Next.js docs warn that layouts do not re-render on client-side navigation, so the session is not checked on every route change. Check in each page, and treat a layout check as an extra.

Client checks are UX only. Clerk's guide says a client-side useAuth() check "is a UX affordance, not a security boundary." The same goes for <Show>, which Clerk says only "visually hides" its children. More on this in client-side auth is not security.

You can keep an early redirect in proxy.ts for signed-out users if you want a faster bounce. Clerk's migration guide shows one and labels it "not an auth guarantee, only a performance optimization."

Signed in is not the same as allowed

auth.protect() with no arguments only proves someone is signed in. Any free account passes it. Two more checks belong in the same place:

  • Ownership. Filter every query by userId, or by orgId for team data. A route that loads where id = params.id without the owner lets any signed-in user read anyone's row. That is IDOR; see the IDOR fix for Next.js.
  • Roles and permissions. Use has() on the server. Clerk's authorization checks guide recommends permission checks over role checks, notes that server-side has() only checks custom permissions, and that role and permission checks depend on the user having an active organization.
const { isAuthenticated, has, orgId } = await auth()
if (!isAuthenticated) return Response.json({ error: 'Unauthorized' }, { status: 401 })
if (!orgId || !has({ permission: 'org:projects:delete' })) {
  return Response.json({ error: 'Forbidden' }, { status: 403 })
}

If you check several conditions at once, update first. GHSA-w24r-5266-9c3c (CVE-2026-42349, high, April 22, 2026) says a single has() or auth.protect() call that combined reverification with a role, permission, feature or plan, or a billing check with a role or permission, could return true when it should be false. @clerk/nextjs 7.2.4 and 6.39.3 fix it. The advisory's workaround is to split combined checks into sequential single-condition checks.

We did not test signed-in, role or organization behavior in this run.

Keep it that way with Clerk's lint rule

Clerk ships @clerk/eslint-plugin with an experimental rule, @clerk/next/require-auth-protection, that flags App Router resources with no auth check. Clerk's plugin reference says to pin the version and recommends protecting everything by default:

// eslint.config.mjs
import clerkNext from '@clerk/eslint-plugin/next'

export default [
  {
    plugins: { '@clerk/next': clerkNext },
    rules: {
      '@clerk/next/require-auth-protection': [
        'error',
        {
          protected: ['**'],
          public: ['src/app/sign-in/**', 'src/app/sign-up/**'],
        },
      ],
    },
  },
]

On our unprotected test app, version 0.2.0 reported 6 errors: the dashboard page, the dashboard Server Action, three Route Handlers (including reports.csv) and the root page. After we moved await auth.protect() inline into the dashboard page and action, those two passed. Three things we noticed:

  • Wrappers are not recognized. When the check lived in a shared guard() helper that called auth.protect(), the rule still flagged every file. Clerk's docs say the rule can't verify protection done by a wrapper or in another file, and suggest a disable comment with a reason.
  • Patterns are folders, not files. Our public entry for app/page.tsx did not exempt the root page. Clerk's docs say the patterns are folder globs.
  • App Router only. Clerk says Pages Router routes must be checked by hand with getAuth().

Clerk also ships a Bulk Fixer (npx --package @clerk/eslint-plugin clerk-next-fix-auth-protection --dry-run) that inserts await auth.protect(). Clerk warns that it changes runtime behavior. We did not run it. Webhook routes must stay public and verify signatures instead (how to verify Clerk webhooks).

Checklist

  • @clerk/nextjs is at least 7.2.4 on v7 or 6.39.3 on v6 (covers both 2026 advisories)
  • proxy.ts (or middleware.ts) still exports clerkMiddleware() with Clerk's matcher
  • Every page under a private area calls auth.protect() or checks auth()
  • Every Route Handler with private data checks auth() and returns 401 when signed out
  • Every Server Action calls auth.protect() first, wherever its file lives
  • Queries are filtered by userId or orgId, never by a client-sent id alone
  • Role and permission checks use has() on the server, one condition per call on old versions
  • No private Route Handler path ends in a file extension the matcher skips, unless it is under /api/
  • Layout checks and client useAuth() or <Show> checks are extras, not the only lock
  • @clerk/next/require-auth-protection runs in CI with protected: ['**']
  • The Clerk secret key is server only (publishable key vs secret key)

Copy-paste prompt for your AI coding tool

Audit Clerk auth in this Next.js app. Do not print or ask for any secret values. Find proxy.ts or middleware.ts and list every createRouteMatcher pattern and the config.matcher. Then list every page, layout, Route Handler (route.ts) and Server Action ('use server') in the app. For each one, show the exact line where it calls auth.protect() or checks auth().isAuthenticated, or say MISSING. Flag: resources only protected by middleware; Server Actions with no check inside the function; Route Handlers whose path ends in .csv, .zip, .docx, .xlsx or another extension the matcher skips; database queries that use an id from the request without filtering by userId or orgId; role checks done only on the client or with ; has() or auth.protect() calls that combine reverification with role or permission. Check the installed @clerk/nextjs version against 7.2.4. Propose a diff that adds await auth.protect() at the top of each missing resource and owner filters to queries. Keep clerkMiddleware() and the matcher. Do not make any route public to fix an error.

More prompts: security review prompt for AI coding tools.

Check what your app exposes with LeakCheck

LeakCheck scans a live app URL or a public GitHub repo, read only. It looks for exposed keys such as sk_live_ and sk_test_ secret keys (Clerk and Stripe share that prefix, so the finding is labeled Stripe or Clerk), Supabase service role keys and webhook secrets. It does not sign in, call your Server Actions or test your route protection, so the per-route review above is still yours to do.

Sources

Questions

Does clerkMiddleware() protect my routes by default?

No. A bare export default clerkMiddleware() only reads the session and makes auth() work. In our test with @clerk/nextjs 7.9.22 and Next.js 16.4.3, a signed-out request got the dashboard page, a private API route, a CSV export and a Server Action result, all with status 200. You have to add checks yourself.

Is createRouteMatcher deprecated?

Yes. @clerk/nextjs 7.5.14, released July 7, 2026, deprecated createRouteMatcher() in favor of auth checks inside each page, Route Handler and Server Function. Clerk says it still works the same way and will be removed in the next major version. The warning is only printed in development, so a production build shows nothing.

Can a Server Action skip my Clerk middleware check?

Yes. Clerk's migration guide explains that Server Actions are called by ID, not by path, so middleware sees the page the request was posted to. In our test, an action that lived in app/dashboard ran for a signed-out caller when the request was posted to the public /pricing page, even with a protect everything except public routes matcher. Putting await auth.protect() inside the action stopped it.

What does auth.protect() return in an API route when the user is signed out?

Clerk's docs say a 404 for Route Handlers, a 401 for Server Actions and a redirect to sign-in for pages. In our signed-out curl tests the Route Handlers answered with a 307 redirect to /sign-in instead, and the action call came back as the rendered sign-in page. In every case the protected code did not run, which is the part that matters.

Am I affected by CVE-2026-41248, the Clerk createRouteMatcher bypass?

If you protect routes with createRouteMatcher and are on @clerk/nextjs 7.0.0 to 7.2.0, 6.39.1 or older on v6, or 5.7.5 or older on v5, yes. The advisory lists 7.2.1, 6.39.2 and 5.7.6 as fixed. Auth checks inside your Route Handlers, Server Components and Server Actions were not affected, which is why Clerk now recommends them.

Do I still need proxy.ts or middleware.ts if I check auth in every route?

Yes. auth() needs clerkMiddleware() to have run for that request. In our test, a Route Handler at /reports.csv was skipped by Clerk's default matcher because of the .csv extension, and its auth.protect() call failed with a 500 error saying Clerk can't detect clerkMiddleware(). That fails closed, but keep the matcher in sync with your routes.