Skip to main content

Migrate away from Middleware-based auth checks

Previously, authentication checks were often performed in clerkMiddleware() using createRouteMatcher(). Clerk is deprecating this approach and now recommends protecting each server-side resource (pages, Route Handlers, and Server Functions, including Server Actions) individually, rather than relying on Middleware-based auth checks. This guide explains how to migrate to the new resource-based protection model. To learn more about why createRouteMatcher() is being deprecated, see Motivations for deprecating createRouteMatcher().

createRouteMatcher() continues to work and its behavior hasn't changed. Calling it logs a one-time deprecation warning to the console in development, and the function will be removed in the next major version of @clerk/nextjs. You can plan the migration on your own schedule, but complete it before upgrading to the next major version.

Before you start

Before you migrate, it's important to understand how resource protection works and how removing Middleware-based authentication checks can affect your signed-out user experience.

This migration is about both protecting server-side resources and preserving the signed-out user experience. Server-rendered pages, Route Handlers, Server Functions, external API endpoints, and any other code that can access privileged data must include their own authentication checks.

Client Components can't reach privileged data directly, but this migration still applies to routes that render Client Components. Client-only routes might need a signed-out state even if there is no server-side resource to protect.

Use the following guides to learn how to protect different types of resources:

Tip

Before you start the migration, verify that all relevant Server Functions are protected. Server Functions can't be reliably protected by Middleware patterns — they're called by ID, not by path (see Motivations for deprecating createRouteMatcher()). Verifying their protection first gives you a safer baseline before removing Middleware checks elsewhere.

The lint rule can help with this step, or you can search your codebase for 'use server' to find your Server Functions.

Migration guide

Don't remove clerkMiddleware() entirely during this migration. It's still required for Clerk to work correctly and can still be used for any non-auth logic, like redirects, headers, and other request handling. If you use createRouteMatcher() for non-auth path logic, replace it with the framework's native matching (config.matcher and req.nextUrl.pathname). For an example, see Geo blocking.

The line to hold: if deleting a piece of clerkMiddleware() logic would leave a resource unprotected, it was a gate and belongs at the resource. A cross-cutting redirect for incomplete session tasks or onboarding can stay in clerkMiddleware() on that basis: the redirect only sends the user somewhere useful first, while the resource still runs the auth check.

To help with the migration, Clerk has launched @clerk/eslint-pluginNext.js Icon, which includes @clerk/next/require-auth-protection, a lint rule that warns you when a resource is missing required protection. The package also includes the Bulk Fixer CLI, which can automatically add protection to your resources. You can also migrate manually if you prefer. Use the following tabs to choose your migration method.

Warning

The lint rule and the Bulk Fixer CLI only work with App Router resources, so Pages Router routes must be audited manually. Protect pages with getAuth()Next.js Icon in getServerSideProps, and protect pages/api routes by calling getAuth(req) in each handler and returning a 401 for signed-out requests. See the Pages Router examples for both patterns.

Install and configure the lint rule

Install @clerk/eslint-plugin. The plugin requires ESLint >=9 with the flat config format (Oxlint is also supportedNext.js Icon); if you can't use flat config, follow the Migrate manually tab instead. The require-auth-protection rule is experimental, so pin the package version. See the @clerk/eslint-plugin referenceNext.js Icon for all options and advanced configuration, including how to set it up for monorepos. The reference also describes the exact patterns the lint rule recognizes, like how protection must always be at the top of the function.

npm install --save-dev @clerk/eslint-plugin
pnpm add --save-dev @clerk/eslint-plugin
yarn add --dev @clerk/eslint-plugin
bun add --dev @clerk/eslint-plugin

In your eslint.config.mjs file, register the Next.js plugin and the @clerk/next/require-auth-protection rule. The rule accepts a protected option which is an array of folder globs whose resources must perform an authentication check. You can base the patterns on your current createRouteMatcher() patterns, but you need to convert them from URL-based patterns to folder-based patterns.

Derive the folder globs from your actual directory tree, not from the URL patterns, because folder paths and URLs don't always match:

Your routeURL patternFolder glob
src/app/dashboard//dashboard(.*)src/app/dashboard/**
src/app/(app)/dashboard/ (route group)/dashboard(.*)src/app/(app)/dashboard/**
src/app/[locale]/dashboard/ (i18n)/:locale/dashboard(.*)src/app/[locale]/dashboard/**

Route groups and dynamic segments like [locale] appear in folder globs but not in URLs. A glob that matches no folders reports no lint errors, which can look like a fully protected slice, so double-check each glob against your directory structure. These examples assume a src directory; see Path matchingNext.js Icon for glob semantics and other project layouts.

It's recommended to incrementally migrate your application, starting with a small slice of your application and gradually adding more resources to the protected set. You can also temporarily disable resource groups that you aren't ready to migrate yet.

Scope protected to the slice you're migrating. The following example scopes the rule to a /dashboard folder and temporarily skips Server Component entrypoints so you can focus on Route Handlers and Server Functions first:

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

export default [
  {
    plugins: { '@clerk/next': clerkNext },
    rules: {
      '@clerk/next/require-auth-protection': [
        'error',
        {
          protected: ['src/app/dashboard/**', 'src/actions/dashboard/**'],
          public: ['src/app/sign-in/**', 'src/app/sign-up/**'],
          resources: {
            routeHandlers: true,
            serverFunctions: true,
            serverComponentEntrypoints: false, // Skip for now.
          },
        },
      ],
    },
  },
]

When you're ready to include pages, layouts, templates, and default files in the migration, set serverComponentEntrypoints to true or remove the resources option altogether.

If you're migrating the whole application at once, you can set protected to include all route groups:

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/**'],
        },
      ],
    },
  },
]

Run the Bulk Fixer CLI

The lint rule doesn't use ESLint autofixes, because adding protection changes runtime behavior. Instead, you can run the Bulk Fixer CLINext.js Icon to automatically add protection to your resources.

The Bulk Fixer CLI runs ESLint with your existing config, respects your protected, public, and resources options, and applies the rule's await auth.protect() suggestion anywhere it can do so safely.

Warning

Applying these fixes changes your application's runtime behavior. It enforces authentication where there potentially was none, and it might override custom authentication checks that were already in place. Review the changes and test your application after running the Bulk Fixer CLI.

Run the fixer with npx:

# Fix everything under the current directory.
npx clerk-next-fix-auth-protection

# Preview without writing files.
npx clerk-next-fix-auth-protection --dry-run

# Scope fixes to a specific path or glob.
npx clerk-next-fix-auth-protection "src/**"
# Fix everything under the current directory.
pnpm dlx clerk-next-fix-auth-protection

# Preview without writing files.
pnpm dlx clerk-next-fix-auth-protection --dry-run

# Scope fixes to a specific path or glob.
pnpm dlx clerk-next-fix-auth-protection "src/**"
# Fix everything under the current directory.
yarn dlx clerk-next-fix-auth-protection

# Preview without writing files.
yarn dlx clerk-next-fix-auth-protection --dry-run

# Scope fixes to a specific path or glob.
yarn dlx clerk-next-fix-auth-protection "src/**"
# Fix everything under the current directory.
bun x clerk-next-fix-auth-protection

# Preview without writing files.
bun x clerk-next-fix-auth-protection --dry-run

# Scope fixes to a specific path or glob.
bun x clerk-next-fix-auth-protection "src/**"

The command exits with a non-zero status when:

  • --dry-run reports changes that would be made.
  • Some resources need manual attention.

The following examples demonstrate the resource-based checks that the Bulk Fixer CLI inserts.

When a user isn't authenticated, auth.protect()Next.js Icon redirects them to the sign-in page for document requests (such as pages), returns a 401 for Server Actions, and returns a 404 for other non-document requests (such as Route Handlers).

If you want more control over the response, or if you want to perform instead, adjust resources manually.

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

export default async function Page() {
  await auth.protect()

  return <h1>Dashboard</h1>
}
app/api/dashboard/route.ts
import { auth } from '@clerk/nextjs/server'

export async function GET() {
  await auth.protect()

  return Response.json({ ok: true })
}
app/dashboard/actions.ts
'use server'

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

export async function updateDashboard() {
  await auth.protect()

  // Add your Server Function logic
}

The Bulk Fixer CLI will warn about resources that require manual attention, such as imported or wrapped exports, or mixed-scope layouts that require explicit acknowledgement. Add protection manually to those resources when they still need it.

If the resource is already protected by a wrapper or by code in another file, the rule can't currently verify that protection. In that case, add an ESLint disable comment with a reason:

// eslint-disable-next-line @clerk/next/require-auth-protection -- Protected by withAuth().
export const POST = withAuth(handler)

Important

The lint rule can't help you with this step. You need to verify this manually.

If a route only uses Client Components, the protected resource is often an API endpoint that the client calls. If the Middleware-based authentication check previously redirected signed-out users before that call happened, removing it without adding a signed-out state can make the UX worse. For example, a page that previously redirected might now show a generic error page instead.

Use client-side controls such as <Show when="signed-out"> to handle a signed-out user's experience.

One way to preserve the previous UX is to render signed-out handling from the shared layout for the affected routes. Keep this logic in a Client Component so it can respond to client-side auth state changes.

app/dashboard/SignedOutRedirect.tsx
'use client'

import { RedirectToSignIn, Show } from '@clerk/nextjs'

export default function SignedOutRedirect({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Show when="signed-in">{children}</Show>
      <Show when="signed-out">
        <RedirectToSignIn />
      </Show>
    </>
  )
}

Use this pattern in the layout for the affected route or route group, rather than in a root layout that also renders public pages. This preserves the signed-out experience, but it doesn't replace auth checks in the API endpoints the client calls.

app/dashboard/layout.tsx
import SignedOutRedirect from './SignedOutRedirect'

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return <SignedOutRedirect>{children}</SignedOutRedirect>
}

Remove the Middleware authentication checks

After the server resources and signed-out states are handled, remove the Middleware authentication checks for the routes you've migrated. If you're migrating incrementally, keep the checks for the routes you haven't migrated yet. It's safe for a route to be protected by both the Middleware check and the resource check while the migration is in progress.

Important

If you're using Next.js ≤15, name your file middleware.ts instead of proxy.ts. The code itself remains the same; only the filename changes.

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

// The `/dashboard` patterns were removed after that slice was migrated.
// Keep the remaining patterns until their routes are migrated.
const isProtected = createRouteMatcher(['/settings(.*)', '/admin(.*)'])

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

Once every slice has been migrated, remove the authentication checks entirely:

proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

// Keep clerkMiddleware() and your existing `config.matcher` export;
// they're still required. Only remove the authentication checks.
export default clerkMiddleware()

Test and repeat

Test the migrated slice while signed out. Verify that protected resources reject unauthenticated requests and that pages still show the expected signed-out experience.

If you have more slices to migrate, start from the top of the guide and add more patterns to the lint rule. Repeat until you are no longer using createRouteMatcher().

Keep the lint rule enabled for ongoing protection

Once the migration is complete, broaden protected to cover your whole application, opting out only the public routes.

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/**'],
        },
      ],
    },
  },
]

If you prefer to migrate manually, it's still recommended to install the lint rule to help ensure your resources stay protected over time.

Identify a slice of your application to migrate

Choose a route, route group, or feature area to migrate first. Within that slice, identify every resource that needs protection. You can use your current createRouteMatcher() patterns as a starting point.

Important

If your Middleware uses the inverted pattern (an isPublicRoute matcher that protects everything except the listed routes), don't derive the protected set from the matcher array. Every route that's not in the public list is currently protected, so each one needs a resource-level check before you remove the Middleware check. Forgetting one silently makes it public. For this pattern, consider using the lint rule instead: setting protected: ['**'] and listing your public routes in public mirrors your existing model.

For routes that require signed-in users, check the following files:

  • Protect all layout, template, default, and page files.
    • Protecting only layout files isn't enough, because they don't always re-render when pages do.
  • Protect loading and error files only if they access sensitive resources.
    • These files are usually mostly static and often don't need protection, but there are exceptions.
  • Protect exports from route files.
    • All functions named as HTTP verbs turn into Route Handlers, so protect them.

Server Functions need protection regardless of where they are located, so audit them separately.

Add auth checks to server resources

The following example shows how to migrate a Middleware-based auth check to a resource-based auth check. This example performs an authentication check on the resource, but the same principle applies to any authentication or that you want to move to the resource.

Important

If you're using Next.js ≤15, name your file middleware.ts instead of proxy.ts. The code itself remains the same; only the filename changes.

If clerkMiddleware() protects a route:

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

const isProtected = createRouteMatcher(['/dashboard/:path*'])

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

Move the auth check into each protected resource. The following examples use auth.protect().

When a user isn't authenticated, auth.protect()Next.js Icon redirects them to the sign-in page for document requests (such as pages), returns a 401 for Server Actions, and returns a 404 for other non-document requests (such as Route Handlers).

If you want more control over the response, or if you want to perform instead, see the relevant guides in the Before you start section.

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

export default async function Page() {
  await auth.protect()

  return <h1>Dashboard</h1>
}
app/api/dashboard/route.ts
import { auth } from '@clerk/nextjs/server'

export async function GET() {
  const { userId } = await auth.protect()

  return Response.json({ userId })
}
app/dashboard/actions.ts
'use server'

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

export async function updateDashboard() {
  const { userId } = await auth.protect()

  // Add your Server Function logic using the `userId`
}

Important

The lint rule can't help you with this step. You need to verify this manually.

If a route only uses Client Components, the protected resource is often an API endpoint that the client calls. If the Middleware-based authentication check previously redirected signed-out users before that call happened, removing it without adding a signed-out state can make the UX worse. For example, a page that previously redirected might now show a generic error page instead.

Use client-side controls such as <Show when="signed-out"> to handle a signed-out user's experience.

One way to preserve the previous UX is to render signed-out handling from the shared layout for the affected routes. Keep this logic in a Client Component so it can respond to client-side auth state changes.

app/dashboard/SignedOutRedirect.tsx
'use client'

import { RedirectToSignIn, Show } from '@clerk/nextjs'

export default function SignedOutRedirect({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Show when="signed-in">{children}</Show>
      <Show when="signed-out">
        <RedirectToSignIn />
      </Show>
    </>
  )
}

Use this pattern in the layout for the affected route or route group, rather than in a root layout that also renders public pages. This preserves the signed-out experience, but it doesn't replace auth checks in the API endpoints the client calls.

app/dashboard/layout.tsx
import SignedOutRedirect from './SignedOutRedirect'

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return <SignedOutRedirect>{children}</SignedOutRedirect>
}

Remove the Middleware authentication checks

After the server resources and signed-out states are handled, remove the Middleware authentication checks for the routes you've migrated. If you're migrating incrementally, keep the checks for the routes you haven't migrated yet. It's safe for a route to be protected by both the Middleware check and the resource check while the migration is in progress.

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

// The `/dashboard` patterns were removed after that slice was migrated.
// Keep the remaining patterns until their routes are migrated.
const isProtected = createRouteMatcher(['/settings(.*)', '/admin(.*)'])

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

Once every slice has been migrated, remove the authentication checks entirely:

proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

// Keep clerkMiddleware() and your existing `config.matcher` export;
// they're still required. Only remove the authentication checks.
export default clerkMiddleware()

Test and repeat

Test the migrated slice while signed out. Verify that protected resources reject unauthenticated requests and that pages still show the expected signed-out experience.

If you have more slices to migrate, start from the top of the guide and repeat the process until you are no longer using createRouteMatcher().

What about early redirects for signed-out users?

Moving signed-out UX handling out of Middleware can raise a natural performance question: "Will signed-out users now have to wait longer for the redirect to happen, especially if it happens in a Client Component?"

This is a fair concern. If this is something you want to optimize for, first follow this migration guide to ensure your application behaves correctly, even without the Middleware gate. After that, you can add back an early redirect to the Middleware gate as a pure performance optimization.

proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware(async (auth, req) => {
  // Important: This is not an auth guarantee, only
  // a performance optimization for signed-out users.
  const pathname = req.nextUrl.pathname
  if (pathname === '/dashboard' || pathname.startsWith('/dashboard/')) {
    const { isAuthenticated, redirectToSignIn } = await auth()

    if (!isAuthenticated) return redirectToSignIn()
  }
})

Keep the resource-level auth checks in place. Because the early redirect is a pure performance optimization, you can remove it at any time without exposing protected data or changing access control. The only impact of removing it is on signed-out users' perceived load time.

Warning

Adding the redirect back to the Middleware can make it harder to determine if the base resources are correctly protected.

If you add the redirect behavior back to the Middleware, you should have a strategy for verifying the base resources are also protected. For example, by using the require-auth-protection lint rule or by setting up auth tests that skip the Middleware redirect.

Note

This section provides the technical rationale for the updated recommendations. Reading this section is optional.

The reasons for deprecating createRouteMatcher() are a mix of existing concerns and new ones.

One main concern has always been that Middleware-level auth gating can lead to a false sense of security. It can be tempting to think, "I'm already guarding my entire application in Middleware, so I'm safe by default." In reality, Middleware checks can be bypassed in several ways, leaving resources unprotected.

Server Functions are one example. It can be tempting to think these are protected by Middleware checks, but they're called by ID, not by path. If a Server Function that lives in /protected/server-functions.ts is called while visiting /public/, Middleware sees /public/ and lets the request through, even if /protected/:path* is protected.

Another example is the mapping of regular expressions first to URLs and then to folders. Regular expressions have subtle caveats, and a mistake can leave a resource unprotected.

Differences between how createRouteMatcher() parses a path and how the framework resolves it can also lead to dangerous gaps. If Clerk normalizes a path one way and treats it as public, but the framework normalizes it differently and renders a protected path, that creates a bypass. This is what happened in the GHSA-vqx2-fgx2-5wq9 vulnerability. Future framework changes to path interpretation can introduce new vulnerabilities, and patching them inside createRouteMatcher() isn't sustainable without first-class framework support.

On top of these concerns, in 2025, several framework-level Middleware bypasses were publicly disclosed. In these cases, the request never goes through createRouteMatcher() at all, and all authentication checks at the Middleware level are bypassed.

Together, these concerns led Clerk to deprecate createRouteMatcher() and recommend moving away from Middleware-level auth gating.

Clerk strongly believes in being secure by default, and this change helps provide those guarantees without creating a false sense of security.

Feedback

What did you think of this content?

Last updated on