Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/major-nuxt-drop-nuxt-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/nuxt': major
---

Drop support for Nuxt 3, which reaches end-of-life on July 31, 2026. `@clerk/nuxt` now requires Nuxt 4, and the minimum supported Node.js version is now `^20.19.0 || >=22.12.0` to match Nuxt 4's own requirement. If you are still on Nuxt 3, follow the [Nuxt upgrade guide](https://nuxt.com/docs/getting-started/upgrade) to upgrade your project before updating `@clerk/nuxt`.
69 changes: 69 additions & 0 deletions .changeset/major-nuxt-remove-route-matcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
'@clerk/nuxt': major
---

Remove `createRouteMatcher` from `@clerk/nuxt/server` and the auto-imported client-side `createRouteMatcher`. Middleware-based auth checks rely on path matching, which can diverge from how requests are actually routed and leave protected resources reachable. Move auth checks into the resources themselves.

Protect API routes inside the event handler itself:

```ts
export default defineEventHandler(event => {
const { userId } = event.context.auth();

if (!userId) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}

return { userId };
});
```

Protect pages with a named route middleware and opt pages into it with `definePageMeta()`. Child routes inherit the middleware applied to their parent, so a single declaration can protect a whole section:

```ts
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
```

```vue
<script setup>
definePageMeta({ middleware: 'auth' });
</script>
```

If you want to hand this work to a coding agent, use this migration prompt:

```md
Migrate my Nuxt project away from Clerk's removed `createRouteMatcher` API.

1. Find every matcher created with `createRouteMatcher`, along with the logic
that uses it (throwing 401 errors, calling `navigateTo('/sign-in')`, etc.).
Matchers can appear in Nitro server middleware (imported from
`@clerk/nuxt/server`) or in Nuxt route middleware (auto-imported).
2. For every API route those matchers protected, move the auth check into the
event handler itself:
const { userId } = event.context.auth();
if (!userId) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
Keep any role or permission checks (`event.context.auth().has(...)`) with
the resource as well.
3. For every page those matchers protected, create a named route middleware in
`app/middleware/` that checks `useAuth()` and redirects with `navigateTo()`,
then opt pages into it with `definePageMeta({ middleware: 'auth' })`. Child
routes inherit the middleware applied to their parent.
4. Remove the `createRouteMatcher` imports and calls. Keep `clerkMiddleware()`
itself. Middleware logic unrelated to auth protection (headers, locale
redirects, etc.) may stay, using plain `getRequestURL(event).pathname`
checks. Plain pathname checks do not normalize percent-encoding
(`/api/%61dmin` will not match a check for `/api/admin`), so never use
them for auth or security decisions. Those belong on the resource itself,
as in steps 2 and 3.
5. Make sure every route previously covered by a matcher pattern (including
glob patterns like `/dashboard(.*)`) now has its own check, then verify the
project builds.
```
16 changes: 0 additions & 16 deletions integration/templates/nuxt-node/app/middleware/auth.global.js

This file was deleted.

7 changes: 7 additions & 0 deletions integration/templates/nuxt-node/app/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup>
definePageMeta({ middleware: 'auth' });
const { data: user } = await useFetch('/api/me');
</script>

Expand Down
163 changes: 45 additions & 118 deletions integration/tests/nuxt/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,21 @@ const nuxtConfigFile = () => `export default defineNuxtConfig({
}
});`;

const clerkMiddlewareFile = () => `import { clerkMiddleware, createRouteMatcher } from '@clerk/nuxt/server';
const clerkMiddlewareFile = () => `import { clerkMiddleware } from '@clerk/nuxt/server';

const isProtectedRoute = createRouteMatcher(['/api/me', '/api/admin(.*)']);
export default clerkMiddleware();
`;

export default clerkMiddleware((event) => {
const adminApiRouteFile = () => `export default defineEventHandler((event) => {
const { userId } = event.context.auth();

if (!userId && isProtectedRoute(event)) {
if (!userId) {
throw createError({
statusCode: 401,
statusMessage: 'You are not authorized to access this resource.'
})
}
});
`;

const adminApiRouteFile = () => `export default defineEventHandler((event) => {
return { status: 'ok' };
});`;

Expand All @@ -44,7 +42,37 @@ const mePageFile = () => `<script setup>
<div v-else>Unknown status</div>
</template>`;

test.describe('custom middleware @nuxt', () => {
// Paths using URL encoding tricks that historically diverged between
// middleware path matching and Nitro's routing normalization. With the auth
// check on the event handler itself, the exact router outcome (401 vs 404 vs
// 400) is Nitro's business; what must always hold is that none of these serve
// the protected resource.
const trickPaths = [
// percent-encoded characters resolving to the protected path
'/api/%61dmin/users',
'/api/a%64min/users',
// double-encoded
'/api/%2561dmin/users',
// encoded slash is not a path separator
'/api%2Fadmin/users',
// null byte
'/api/admin%00/users',
// malformed percent-encoding
'/api/%zz/users',
// encoded dot segments and traversal
'/api/%2e/admin/users',
'/api/%2e%2e/admin/users',
'/api/foo/%2e%2e/admin/users',
// fully encoded './' and '../'
'/api%2f%2e%2fadmin/users',
'/api%2f%2e%2e%2fadmin/users',
'/api/foo%2f%2e%2e%2fadmin/users',
// double slashes
'//api/admin/users',
'/api//admin/users',
];

test.describe('resource-based route protection @nuxt', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

Expand All @@ -69,7 +97,7 @@ test.describe('custom middleware @nuxt', () => {
await app.teardown();
});

test('guard API route with custom middleware', async ({ page, context }) => {
test('guard API route with resource-based auth check', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser();
await u.services.users.createBapiUser(fakeUser);
Expand All @@ -78,7 +106,7 @@ test.describe('custom middleware @nuxt', () => {
await u.page.goToAppHome();
await u.po.expect.toBeSignedOut();
await u.page.goToRelative('/me');
await expect(u.page.getByText('401: You are not authorized to access this resource')).toBeVisible();
await expect(u.page.getByText('401: Unauthorized')).toBeVisible();

// Sign in flow
await u.page.goToRelative('/sign-in');
Expand All @@ -93,117 +121,16 @@ test.describe('custom middleware @nuxt', () => {

await fakeUser.deleteIfExists();
});
});

test.describe('percent-encoded URL handling @nuxt', () => {
test.describe.configure({ mode: 'serial' });
let app: Application;

test.beforeAll(async () => {
test.setTimeout(90_000);
app = await appConfigs.nuxt.node
.clone()
.setName('nuxt-custom-middleware')
.addFile('nuxt.config.js', nuxtConfigFile)
.addFile('server/middleware/clerk.js', clerkMiddlewareFile)
.addFile('server/api/admin/[...action].js', adminApiRouteFile)
.commit();

await app.setup();
// pkglab installs with --ignore-scripts, so nuxt prepare must be run manually
execSync('npx nuxt prepare', { cwd: app.appDir, stdio: 'pipe' });
await app.withEnv(appConfigs.envs.withCustomRoles);
await app.dev();
});

test.afterAll(async () => {
await app.teardown();
});

test('handle percent-encoded URL on protected routes', async () => {
const normalRes = await fetch(app.serverUrl + '/api/admin/users');
expect(normalRes.status).toBe(401);

// %61 = 'a': /api/%61dmin/users decodes to /api/admin/users
const encodedRes = await fetch(app.serverUrl + '/api/%61dmin/users');
expect(encodedRes.status).toBe(401);

// %64 = 'd': /api/a%64min/users decodes to /api/admin/users
const encodedRes2 = await fetch(app.serverUrl + '/api/a%64min/users');
expect(encodedRes2.status).toBe(401);
});

test('double-encoded URLs do not match route (Nitro router rejects)', async () => {
// %2561 decodes one layer to %61 — Nitro's file-based router does not
// match %2561dmin to the admin/ directory, returning 404
const res = await fetch(app.serverUrl + '/api/%2561dmin/users');
expect(res.status).toBe(404);
});

test('encoded slash is not decoded into a path separator', async () => {
// %2F is a reserved delimiter — decodeURI preserves it, so the matcher
// sees /api%2Fadmin/users which does not match /api/admin(.*).
// The router also treats %2F as a literal segment char, not a separator.
const res = await fetch(app.serverUrl + '/api%2Fadmin/users');
expect(res.status).not.toBe(200);
});

test('null byte in path is caught by middleware as protected route', async () => {
// %00 decodes to a null char — /api/admin\0/users still matches
// /api/admin(.*) so our middleware correctly blocks it with 401
const res = await fetch(app.serverUrl + '/api/admin%00/users');
test('protected API route returns 401 when signed out', async () => {
const res = await fetch(app.serverUrl + '/api/admin/users');
expect(res.status).toBe(401);
});

test('malformed percent-encoding returns 400 (clerkMiddleware catches MalformedURLError)', async () => {
// %zz is not valid percent-encoding — createPathMatcher throws
// MalformedURLError, which clerkMiddleware catches and returns 400
const res = await fetch(app.serverUrl + '/api/%zz/users');
expect(res.status).toBe(400);
});

test('encoded dot-current segment is caught by middleware', async () => {
// %2e = '.' — /api/%2e/admin/users resolves to /api/./admin/users → /api/admin/users
// Our middleware matches the resolved path as protected
const res = await fetch(app.serverUrl + '/api/%2e/admin/users');
expect(res.status).toBe(401);
});

test('encoded dot-parent segment does not reach protected route', async () => {
// %2e%2e = '..' — /api/%2e%2e/admin/users resolves to /api/../admin/users → /admin/users
// Nitro's router does not match this to any route, returning 404
const res = await fetch(app.serverUrl + '/api/%2e%2e/admin/users');
expect(res.status).toBe(404);
});

test('encoded dot-parent traversal through fake segment is caught by middleware', async () => {
// /api/foo/%2e%2e/admin/users resolves to /api/foo/../admin/users → /api/admin/users
// Our middleware matches the resolved path as protected, returning 401
const res = await fetch(app.serverUrl + '/api/foo/%2e%2e/admin/users');
expect(res.status).toBe(401);
});

test('fully encoded dot segments with encoded slash are rejected (Nitro rejects)', async () => {
// %2e%2f = './', %2e%2e%2f = '../' — when the slash is also encoded,
// Nitro treats the entire sequence as a single path segment and
// doesn't match any route, returning 404
const dotSlashCurrent = await fetch(app.serverUrl + '/api%2f%2e%2fadmin/users');
expect(dotSlashCurrent.status).toBe(404);

const dotSlashParent = await fetch(app.serverUrl + '/api%2f%2e%2e%2fadmin/users');
expect(dotSlashParent.status).toBe(404);

const dotSlashTraversal = await fetch(app.serverUrl + '/api/foo%2f%2e%2e%2fadmin/users');
expect(dotSlashTraversal.status).toBe(404);
});

test('double slashes cannot bypass protected route', async () => {
// Double slashes before the protected segment
const res1 = await fetch(app.serverUrl + '//api/admin/users');
expect(res1.status).not.toBe(200);

// Double slashes in the middle of the path
const res2 = await fetch(app.serverUrl + '/api//admin/users');
expect(res2.status).not.toBe(200);
test('no URL encoding trick can access the protected route', async () => {
for (const path of trickPaths) {
const res = await fetch(app.serverUrl + path);
expect(res.status, `expected non-200 for ${path}`).not.toBe(200);
}
});
});
2 changes: 1 addition & 1 deletion packages/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"vue": "catalog:repo"
},
"engines": {
"node": ">=20.9.0"
"node": "^20.19.0 || >=22.12.0"
},
"publishConfig": {
"access": "public"
Expand Down
6 changes: 1 addition & 5 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default defineNuxtModule<ModuleOptions>({
version: PACKAGE_VERSION,
configKey: 'clerk',
compatibility: {
nuxt: '>=3.0.0',
nuxt: '>=4.0.0',
},
},
setup(options, nuxt) {
Expand Down Expand Up @@ -134,10 +134,6 @@ export default defineNuxtModule<ModuleOptions>({
// Add auto-imports for Clerk components, composables and client utils
addImportsDir(resolver.resolve('./runtime/composables'));
addImports([
{
name: 'createRouteMatcher',
from: resolver.resolve('./runtime/client'),
},
{
name: 'updateClerkOptions',
from: resolver.resolve('./runtime/client'),
Expand Down
26 changes: 0 additions & 26 deletions packages/nuxt/src/runtime/client/__tests__/routeMatcher.test.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/nuxt/src/runtime/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Loading
Loading