From 50b061ff977e5d8ae7121683a5c5051eafcaef2a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 12 Oct 2026 10:30:25 -0700 Subject: [PATCH 1/6] test(nextjs): cover auth.protect() authorization params with machine tokens --- integration/tests/next-machine.test.ts | 97 ++++++++++++++++++- .../server/__tests__/clerkMiddleware.test.ts | 95 +++++++++++++++++- 2 files changed, 189 insertions(+), 3 deletions(-) diff --git a/integration/tests/next-machine.test.ts b/integration/tests/next-machine.test.ts index 0a753cce476..80b9cbc878d 100644 --- a/integration/tests/next-machine.test.ts +++ b/integration/tests/next-machine.test.ts @@ -1,8 +1,13 @@ -import { test } from '@playwright/test'; +import type { User } from '@clerk/backend'; +import { TokenType } from '@clerk/backend/internal'; +import { expect, test } from '@playwright/test'; +import type { Application } from '../models/application'; import { appConfigs } from '../presets'; +import { createTestUtils } from '../testUtils'; import type { MachineAuthTestAdapter } from '../testUtils/machineAuthHelpers'; import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../testUtils/machineAuthHelpers'; +import type { FakeAPIKey, FakeUser } from '../testUtils/usersService'; const adapter: MachineAuthTestAdapter = { baseConfig: appConfigs.next.appRouter, @@ -94,4 +99,94 @@ test.describe('Next.js machine authentication @machine', () => { registerApiKeyAuthTests(adapter); registerM2MAuthTests(adapter); registerOAuthAuthTests(adapter); + + test.describe('auth.protect() authorization with API keys', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + let fakeBapiUser: User; + let fakeAPIKey: FakeAPIKey; + + test.beforeAll(async () => { + test.setTimeout(120_000); + + app = await appConfigs.next.appRouter + .clone() + .addFile( + 'src/app/api/protect/route.ts', + () => ` + import { auth } from '@clerk/nextjs/server'; + + export async function GET() { + const { userId, tokenType } = await auth.protect({ token: 'api_key' }); + + return Response.json({ userId, tokenType }); + } + + export async function POST() { + const { userId } = await auth.protect({ role: 'org:admin' }, { token: ['session_token', 'api_key'] }); + + return Response.json({ userId }); + } + + export async function PUT() { + await auth.protect(() => false, { token: 'api_key' }); + + return Response.json({ protected: true }); + } + + export async function PATCH() { + const { userId, tokenType } = await auth.protect(() => true, { token: 'api_key' }); + + return Response.json({ userId, tokenType }); + } + `, + ) + .commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withAPIKeys.clone()); + await app.dev(); + + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(test); + fakeBapiUser = await u.services.users.createBapiUser(fakeUser); + fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id); + }); + + test.afterAll(async () => { + await fakeAPIKey?.revoke(); + await fakeUser?.deleteIfExists(); + await app?.teardown(); + }); + + test('should return 200 if API key is valid and no authorization params are passed', async ({ request }) => { + const res = await request.get(new URL('/api/protect', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ userId: fakeBapiUser.id, tokenType: TokenType.ApiKey }); + }); + + test('should return 401 if API key is valid but a role is required', async ({ request }) => { + const res = await request.post(new URL('/api/protect', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + expect(res.status()).toBe(401); + }); + + test('should return 401 if API key is valid but the predicate returns false', async ({ request }) => { + const res = await request.put(new URL('/api/protect', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + expect(res.status()).toBe(401); + }); + + test('should return 200 if API key is valid and the predicate returns true', async ({ request }) => { + const res = await request.patch(new URL('/api/protect', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ userId: fakeBapiUser.id, tokenType: TokenType.ApiKey }); + }); + }); }); diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 31757419d0f..99828e3e32b 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1,14 +1,15 @@ // There is no need to execute the complete authenticateRequest to test clerkMiddleware // This mock SHOULD exist before the import of authenticateRequest -import { AuthStatus, constants, TokenType } from '@clerk/backend/internal'; +import { authenticatedMachineObject, AuthStatus, constants, TokenType } from '@clerk/backend/internal'; import { MalformedURLError } from '@clerk/shared/pathMatcher'; // used to assert the mock import assert from 'assert'; import type { NextFetchEvent } from 'next/server'; import { NextRequest, NextResponse } from 'next/server'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, expectTypeOf, it, type Mock, vi } from 'vitest'; import { clerkClient } from '../clerkClient'; +import type { ClerkMiddlewareAuth } from '../clerkMiddleware'; import { clerkMiddleware } from '../clerkMiddleware'; import { createRouteMatcher } from '../routeMatcher'; import { decryptClerkRequestData } from '../utils'; @@ -117,6 +118,23 @@ describe('ClerkMiddleware type tests', () => { }); }); + it('infers the protect return type from the token option when a predicate is passed', () => { + clerkMiddlewareMock(auth => { + const predicate = () => true; + + expectTypeOf(auth.protect(predicate)).toEqualTypeOf(auth.protect()); + expectTypeOf(auth.protect(predicate, { unauthorizedUrl: '/denied' })).toEqualTypeOf(auth.protect()); + expectTypeOf(auth.protect(predicate, { token: TokenType.SessionToken })).toEqualTypeOf(auth.protect()); + expectTypeOf(auth.protect(predicate, { token: TokenType.ApiKey })).toEqualTypeOf( + auth.protect({ token: TokenType.ApiKey }), + ); + expectTypeOf(auth.protect(predicate, { token: [TokenType.SessionToken, TokenType.M2MToken] })).toEqualTypeOf( + auth.protect({ token: [TokenType.SessionToken, TokenType.M2MToken] }), + ); + expectTypeOf(auth.protect(predicate, { token: 'any' })).toEqualTypeOf(auth.protect({ token: 'any' })); + }); + }); + it('can be used with just an optional options object', () => { clerkMiddlewareMock({ secretKey: '', publishableKey: '' }); clerkMiddlewareMock(); @@ -881,6 +899,79 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(401); expect((await clerkClient()).authenticateRequest).toBeCalled(); }); + + describe.each([ + [TokenType.ApiKey, 'ak_123', 'user_123'], + [TokenType.M2MToken, 'mt_123', 'mch_123'], + [TokenType.OAuthToken, 'oat_123', 'user_123'], + ] as const)('when the request is authenticated with a valid %s', (tokenType, token, subject) => { + const protectWith = async (protect: (auth: ClerkMiddlewareAuth) => Promise) => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ [constants.Headers.Authorization]: `Bearer ${token}` }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => authenticatedMachineObject(tokenType, token, { id: token, subject, scopes: [] } as any), + }); + + return clerkMiddleware(async auth => { + await protect(auth); + })(req, {} as NextFetchEvent); + }; + + it('returns 401 when protect is called with a role', async () => { + const resp = await protectWith(auth => auth.protect({ role: 'org:admin' }, { token: 'any' })); + + expect(resp?.status).toEqual(401); + }); + + it('returns 401 when protect is called with a permission and an array of token types', async () => { + const resp = await protectWith(auth => + auth.protect({ permission: 'org:settings:manage' }, { token: [TokenType.SessionToken, tokenType] }), + ); + + expect(resp?.status).toEqual(401); + }); + + it('returns 401 when protect is called with authorization params and token in a single argument', async () => { + const resp = await protectWith(auth => { + const opts = { feature: 'premium', token: tokenType } as any; + return auth.protect(opts); + }); + + expect(resp?.status).toEqual(401); + }); + + it('returns 401 when protect is called with a predicate that returns false', async () => { + const predicate = vi.fn().mockReturnValue(false); + const resp = await protectWith(auth => auth.protect(predicate, { token: tokenType })); + + expect(predicate).toHaveBeenCalledOnce(); + expect(resp?.status).toEqual(401); + }); + + it('returns 401 when protect is called with a predicate that relies on has()', async () => { + const resp = await protectWith(auth => auth.protect(has => has({ role: 'org:admin' }), { token: tokenType })); + + expect(resp?.status).toEqual(401); + }); + + it('does not throw when protect is called with a predicate that returns true', async () => { + const resp = await protectWith(auth => auth.protect(() => true, { token: tokenType })); + + expect(resp?.status).toEqual(200); + }); + + it('does not throw when protect is called without authorization params', async () => { + const resp = await protectWith(auth => auth.protect({ token: tokenType })); + + expect(resp?.status).toEqual(200); + }); + }); }); describe('auth().redirectToSignIn()', () => { From 32ece339bf5e4995c00e40cbfe72f30efff478ad Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 12 Oct 2026 10:30:27 -0700 Subject: [PATCH 2/6] fix(nextjs): evaluate auth.protect() authorization params for machine tokens --- .changeset/quiet-keys-protect.md | 5 +++ packages/nextjs/src/server/protect.ts | 50 ++++++++++++++++----------- 2 files changed, 34 insertions(+), 21 deletions(-) create mode 100644 .changeset/quiet-keys-protect.md diff --git a/.changeset/quiet-keys-protect.md b/.changeset/quiet-keys-protect.md new file mode 100644 index 00000000000..9f571a7db0e --- /dev/null +++ b/.changeset/quiet-keys-protect.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Fix `auth.protect()` skipping authorization checks for machine tokens. When a route accepts API keys, OAuth tokens, or M2M tokens through the `token` option, `auth.protect()` now evaluates the `role`, `permission`, `feature`, `plan`, and `reverification` params and the predicate callback for those requests instead of letting every authenticated machine token through. Machine tokens carry no organization or billing context, so param-based checks respond with a 401 for them. The predicate overload now also infers its return type from the `token` option. diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 1e72f7128fd..47ffa731609 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -5,6 +5,7 @@ import type { InferAuthObjectFromToken, InferAuthObjectFromTokenArray, RedirectFun, + SessionTokenType, SignedInAuthObject, } from '@clerk/backend/internal'; import { constants, isTokenTypeAccepted, TokenType } from '@clerk/backend/internal'; @@ -34,6 +35,14 @@ type AuthProtectOptions = { unauthenticatedUrl?: string; }; +type InferProtectedAuthObject = T extends TokenType[] + ? InferAuthObjectFromTokenArray + : T extends TokenType + ? InferAuthObjectFromToken + : T extends 'any' + ? SignedInAuthObject | AuthenticatedMachineObject + : SignedInAuthObject; + /** * Throws a Nextjs notFound error if user is not authenticated or authorized. */ @@ -52,10 +61,10 @@ export interface AuthProtect { * @example * auth.protect(has => has({ permission: 'org:admin:example1' })); */ - ( + ( params?: (has: CheckAuthorizationFromSessionClaims) => boolean, - options?: AuthProtectOptions, - ): Promise; + options?: AuthProtectOptions & { token?: T }, + ): Promise>; /** * @example @@ -154,30 +163,28 @@ export function createProtect(opts: { } if (authObject.tokenType !== TokenType.SessionToken) { - // For machine tokens, we only check if they're authenticated - // They don't have session status or organization permissions + // Machine tokens don't have session status if (!authObject.isAuthenticated) { return handleUnauthorized(); } - return authObject; - } - - /** - * Redirects the user back to the tasks URL if their session status is pending - */ - if (authObject.sessionStatus === 'pending') { - return handleUnauthenticated(); - } + } else { + /** + * Redirects the user back to the tasks URL if their session status is pending + */ + if (authObject.sessionStatus === 'pending') { + return handleUnauthenticated(); + } - /** - * User is not authenticated - */ - if (!authObject.userId) { - return handleUnauthenticated(); + /** + * User is not authenticated + */ + if (!authObject.userId) { + return handleUnauthenticated(); + } } /** - * User is authenticated + * User or machine is authenticated */ if (!paramsOrFunction) { return authObject; @@ -194,7 +201,8 @@ export function createProtect(opts: { } /** - * Checking if user is authorized when permission or role is passed + * Checking if user is authorized when permission or role is passed. + * Machine tokens don't have organization permissions, so their has() always returns false */ if (authObject.has(paramsOrFunction)) { return authObject; From 7a8335ab09a8e9ae53919bbf6620bc14a49f4d49 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 12 Oct 2026 11:16:16 -0700 Subject: [PATCH 3/6] chore(nextjs): trim protect comment and test cast --- .changeset/quiet-keys-protect.md | 2 +- packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts | 5 +---- packages/nextjs/src/server/protect.ts | 3 +-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.changeset/quiet-keys-protect.md b/.changeset/quiet-keys-protect.md index 9f571a7db0e..c525d113ce4 100644 --- a/.changeset/quiet-keys-protect.md +++ b/.changeset/quiet-keys-protect.md @@ -2,4 +2,4 @@ '@clerk/nextjs': patch --- -Fix `auth.protect()` skipping authorization checks for machine tokens. When a route accepts API keys, OAuth tokens, or M2M tokens through the `token` option, `auth.protect()` now evaluates the `role`, `permission`, `feature`, `plan`, and `reverification` params and the predicate callback for those requests instead of letting every authenticated machine token through. Machine tokens carry no organization or billing context, so param-based checks respond with a 401 for them. The predicate overload now also infers its return type from the `token` option. +Fix `auth.protect()` skipping authorization checks for machine tokens. When a route accepts API keys, OAuth tokens, or M2M tokens through the `token` option, the `role`, `permission`, `feature`, `plan`, and `reverification` params and the predicate callback are now evaluated for those requests. Machine tokens carry no organization or billing context, so param-based checks respond with a 401 for them. diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 99828e3e32b..f9035351ae6 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -938,10 +938,7 @@ describe('clerkMiddleware(params)', () => { }); it('returns 401 when protect is called with authorization params and token in a single argument', async () => { - const resp = await protectWith(auth => { - const opts = { feature: 'premium', token: tokenType } as any; - return auth.protect(opts); - }); + const resp = await protectWith(auth => auth.protect({ feature: 'premium', token: tokenType } as any)); expect(resp?.status).toEqual(401); }); diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 47ffa731609..2eb011f6d87 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -201,8 +201,7 @@ export function createProtect(opts: { } /** - * Checking if user is authorized when permission or role is passed. - * Machine tokens don't have organization permissions, so their has() always returns false + * Checking if user is authorized when permission or role is passed */ if (authObject.has(paramsOrFunction)) { return authObject; From 062853679d8817a797eed0c03aeeb019057b0444 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 12 Oct 2026 12:19:16 -0700 Subject: [PATCH 4/6] test(nextjs): cover every authorization param with machine tokens --- .../src/server/__tests__/clerkMiddleware.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index f9035351ae6..544caf5550d 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -923,8 +923,14 @@ describe('clerkMiddleware(params)', () => { })(req, {} as NextFetchEvent); }; - it('returns 401 when protect is called with a role', async () => { - const resp = await protectWith(auth => auth.protect({ role: 'org:admin' }, { token: 'any' })); + it.each([ + { role: 'org:admin' }, + { permission: 'org:settings:manage' }, + { feature: 'premium' }, + { plan: 'pro' }, + { reverification: 'strict' }, + ] as const)('returns 401 when protect is called with %o', async params => { + const resp = await protectWith(auth => auth.protect(params, { token: 'any' })); expect(resp?.status).toEqual(401); }); From 9e09fe581251499c0f9b9fbb31e24e4d51e5959a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 13 Oct 2026 09:37:35 -0700 Subject: [PATCH 5/6] chore(nextjs): leave AuthProtect signatures unchanged The predicate overload return type moves to a follow-up change so this one only alters runtime behavior. --- .../server/__tests__/clerkMiddleware.test.ts | 19 +------------------ packages/nextjs/src/server/protect.ts | 15 +++------------ 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 544caf5550d..4b3e6754dfd 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -6,7 +6,7 @@ import { MalformedURLError } from '@clerk/shared/pathMatcher'; import assert from 'assert'; import type { NextFetchEvent } from 'next/server'; import { NextRequest, NextResponse } from 'next/server'; -import { afterAll, beforeAll, beforeEach, describe, expect, expectTypeOf, it, type Mock, vi } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { clerkClient } from '../clerkClient'; import type { ClerkMiddlewareAuth } from '../clerkMiddleware'; @@ -118,23 +118,6 @@ describe('ClerkMiddleware type tests', () => { }); }); - it('infers the protect return type from the token option when a predicate is passed', () => { - clerkMiddlewareMock(auth => { - const predicate = () => true; - - expectTypeOf(auth.protect(predicate)).toEqualTypeOf(auth.protect()); - expectTypeOf(auth.protect(predicate, { unauthorizedUrl: '/denied' })).toEqualTypeOf(auth.protect()); - expectTypeOf(auth.protect(predicate, { token: TokenType.SessionToken })).toEqualTypeOf(auth.protect()); - expectTypeOf(auth.protect(predicate, { token: TokenType.ApiKey })).toEqualTypeOf( - auth.protect({ token: TokenType.ApiKey }), - ); - expectTypeOf(auth.protect(predicate, { token: [TokenType.SessionToken, TokenType.M2MToken] })).toEqualTypeOf( - auth.protect({ token: [TokenType.SessionToken, TokenType.M2MToken] }), - ); - expectTypeOf(auth.protect(predicate, { token: 'any' })).toEqualTypeOf(auth.protect({ token: 'any' })); - }); - }); - it('can be used with just an optional options object', () => { clerkMiddlewareMock({ secretKey: '', publishableKey: '' }); clerkMiddlewareMock(); diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 2eb011f6d87..73b4cf74f9d 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -5,7 +5,6 @@ import type { InferAuthObjectFromToken, InferAuthObjectFromTokenArray, RedirectFun, - SessionTokenType, SignedInAuthObject, } from '@clerk/backend/internal'; import { constants, isTokenTypeAccepted, TokenType } from '@clerk/backend/internal'; @@ -35,14 +34,6 @@ type AuthProtectOptions = { unauthenticatedUrl?: string; }; -type InferProtectedAuthObject = T extends TokenType[] - ? InferAuthObjectFromTokenArray - : T extends TokenType - ? InferAuthObjectFromToken - : T extends 'any' - ? SignedInAuthObject | AuthenticatedMachineObject - : SignedInAuthObject; - /** * Throws a Nextjs notFound error if user is not authenticated or authorized. */ @@ -61,10 +52,10 @@ export interface AuthProtect { * @example * auth.protect(has => has({ permission: 'org:admin:example1' })); */ - ( + ( params?: (has: CheckAuthorizationFromSessionClaims) => boolean, - options?: AuthProtectOptions & { token?: T }, - ): Promise>; + options?: AuthProtectOptions, + ): Promise; /** * @example From b4610b5dd3c0f706116e58181f3509355e75ee40 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 13 Oct 2026 09:39:59 -0700 Subject: [PATCH 6/6] chore(nextjs): trim auth.protect() changeset --- .changeset/quiet-keys-protect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quiet-keys-protect.md b/.changeset/quiet-keys-protect.md index c525d113ce4..0dd77df6eed 100644 --- a/.changeset/quiet-keys-protect.md +++ b/.changeset/quiet-keys-protect.md @@ -2,4 +2,4 @@ '@clerk/nextjs': patch --- -Fix `auth.protect()` skipping authorization checks for machine tokens. When a route accepts API keys, OAuth tokens, or M2M tokens through the `token` option, the `role`, `permission`, `feature`, `plan`, and `reverification` params and the predicate callback are now evaluated for those requests. Machine tokens carry no organization or billing context, so param-based checks respond with a 401 for them. +Fix `auth.protect()` skipping authorization checks for machine tokens. The `role`, `permission`, `feature`, `plan`, and `reverification` params and the predicate callback are now evaluated for API key, OAuth token, and M2M token requests. Param-based checks respond with a 401 for those requests.