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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { isSignedIn } = useAuth();

if (!isSignedIn.value) {
return navigateTo('/sign-in');
}
});
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Loading
Loading