Skip to content

fix(shared,clerk-js,nextjs): authorization bypass in combined-condition has() checks - #8372

Merged
nikosdouvlis merged 11 commits into
mainfrom
fix/sdk-67-authorization-bypass
Apr 22, 2026
Merged

fix(shared,clerk-js,nextjs): authorization bypass in combined-condition has() checks#8372
nikosdouvlis merged 11 commits into
mainfrom
fix/sdk-67-authorization-bypass

Conversation

@nikosdouvlis

@nikosdouvlis nikosdouvlis commented Apr 22, 2026

Copy link
Copy Markdown
Member

Why

has(), auth.protect(), and related authorization predicates can return true for certain calls that combined more than one condition (role, permission, feature, plan, reverification) when the result should have been false. When a requested dimension could not be satisfied because the underlying session data was missing or indeterminate, the combining logic ignored that dimension instead of denying. If any remaining requested dimension passed, the overall result was true.

Separately, auth.protect() in @clerk/nextjs silently discarded authorization params (role, permission, feature, plan, reverification) whenever the same argument object also contained unauthenticatedUrl, unauthorizedUrl, or token. TypeScript's excess-property check caught this only for inline object literals; variable-assigned or spread arguments and any JavaScript caller let every authenticated user through.

What changed

packages/shared/src/authorization.ts

  • Internal helper return type is now a 'pass' | 'fail' | 'skip' tri-state. The combiner treats any asked dimension returning 'fail' as a denial and requires at least one 'pass'. Missing, malformed, or invalid session data now denies instead of being treated as indeterminate.
  • Removed the billingAuthorization || orgAuthorization OR-coercion; each asked dimension is evaluated independently.
  • role / permission / feature / plan values that are not strings (e.g. null cast through as any) now fail closed instead of throwing.
  • factorVerificationAge tuple shape validation (length, numeric, -1 | >= 0) before comparisons.
  • Graceful downgrade preserved for strict / strict_mfa per the existing docs: when no second factor is enrolled, these levels still evaluate against the first factor. multi_factor still requires both factors when both are enrolled.

packages/clerk-js/src/core/resources/Session.ts

  • session.checkAuthorization() now uses the active organization's id (activeMembership.organization.id) when building authorization options, not the membership row id.

packages/clerk-js/src/test/core-fixtures.ts

  • Test fixtures now give memberships and organizations distinct ids so downstream regressions on the above are caught.

packages/nextjs/src/server/protect.ts

  • getAuthorizationParams now picks the known authorization keys from the argument via an explicit allowlist. Mixed shapes like { role, unauthorizedUrl } or { permission, token } enforce the authorization check; options-only objects take the fast path; unknown extra keys are ignored.

packages/react/src/hooks/__tests__/useAuth.test.tsx

  • Removed the global vi.mock of createCheckAuthorization so the React hook tests exercise the real predicate.

Tests

  • New regression matrix in packages/shared/src/__tests__/authorization.spec.ts covering every "asked but unsatisfiable" cell, AND semantics within and across dimensions, malformed payloads, null / non-string params, invalid reverification configs, and positive multi-requirement cases.
  • SDK-68 regression tests in packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts.
  • New Playwright coverage in integration/tests/protect.test.ts for three new /settings/* routes in the existing next-app-router template: auth-protect-mixed-args, auth-protect-mixed-token, auth-protect-role-and-permission. Admin / signed-out / viewer assertions added.

Changeset

  • @clerk/shared, @clerk/clerk-js, @clerk/nextjs at patch. Other packages pick up the fix transitively via the shared patch.

Test plan

  • pnpm --filter @clerk/shared test - 25/25 on the new matrix
  • pnpm --filter @clerk/clerk-js test - Session.test.ts green
  • pnpm --filter @clerk/clerk-react test - useAuth.test.tsx green after removing the mock
  • pnpm --filter @clerk/backend test - green
  • pnpm --filter @clerk/nextjs test - middleware tests green (pre-existing type-only failures unrelated to this PR)
  • pnpm --filter @clerk/vue test / pnpm --filter @clerk/astro test - green
  • integration/tests/protect.test.ts - new admin / viewer / signed-out assertions run in CI against the withCustomRoles + withBillingJwtV2 instances.

Backport

A Core 2 backport is prepared on the advisory's private fork and will be merged once this lands.

…n has() checks

`has()`, `auth.protect()`, and related predicates could return true for certain
calls that combined conditions from more than one dimension (role, permission,
feature, plan, reverification) when the result should have been false. The
combining logic in `createCheckAuthorization` treated any helper returning null
as "skip this dimension", so a hard-negative in one dimension could collapse
into a hard-positive whenever another dimension returned true.

The helpers now return an internal `pass | fail | skip` verdict and the combiner
enforces that every requested dimension must individually pass:

- Dimensions the caller did not ask about do not contribute to the result.
- Asked dimensions with missing, malformed, or invalid session data fail closed
  (covers missing org claims, empty billing claims, `factorVerificationAge` being
  null or malformed, invalid reverification configs, and `factor1Age === -1`).
- `has({})` continues to return false.
- Multi-key calls (e.g. `{ permission, feature }`) now require every requested
  sub-check to pass (AND). The previous `billingAuthorization || orgAuthorization`
  OR-coercion is removed.
- Non-string `role` / `permission` / `feature` / `plan` values fail closed
  instead of throwing.

`session.checkAuthorization()` now uses the active organization's id rather than
the membership row id when constructing the authorization options. The
corresponding test fixture now gives memberships and organizations distinct
ids so regressions around this are caught. The global mock of
`createCheckAuthorization` in the React `useAuth` test suite has been removed so
the predicate is exercised against real session state.
Previously, `has({ reverification: 'strict_mfa' })` (and any `multi_factor`
level reverification) silently downgraded to a first-factor check when the
user had not enrolled a second factor (`factor2Age === -1`). This answered a
different question than the caller asked: strict_mfa means "prove a fresh
second factor", and if the user has no second factor the requirement cannot
be satisfied.

Fail closed in that case and direct apps to enroll a second factor before
gating behind strict_mfa. `second_factor` (strict/moderate/lax) is unchanged
and still falls back to a fresh first factor when no second factor is
enrolled - that is a separate product decision.

The existing Session.test assertions that pinned the downgrade behavior are
flipped to assert the fail-closed behavior and a corresponding regression
test is added to authorization.spec.ts.
…tect()

Previously `auth.protect()` silently skipped the authorization check whenever
the argument object contained any of `unauthenticatedUrl`, `unauthorizedUrl`,
or `token` alongside `role` / `permission` / `feature` / `plan` /
`reverification`. TypeScript's excess-property check caught this only when
the argument was an inline object literal; variable-assigned or spread
arguments (and JS callers) bypassed the check entirely, letting every
authenticated user through. Example:

  const opts = { role: 'org:admin', unauthorizedUrl: '/denied' };
  await auth.protect(opts); // used to authorize everyone

`getAuthorizationParams` now picks the known authorization keys from the
argument with an explicit allowlist (`role`, `permission`, `feature`,
`plan`, `reverification`). Mixed-shape calls enforce the authorization
check against the extracted params; options-only calls take the existing
fast path. Unknown extra keys are ignored rather than forwarded to `has()`,
so objects with stray keys from spread or variable construction do not
accidentally deny legitimate requests.

Regression tests in `clerkMiddleware.test.ts` cover:
- `{ role, unauthorizedUrl }` unauthorized user redirects to unauthorizedUrl
- `{ permission, token }` unauthorized user hits the rewrite path
- `{ role, unauthorizedUrl }` authorized user passes through
- options-only object with an extra unknown key still takes the fast path
…dback

- Collapse combine() to a readable some/every expression; drop the local
  boolean flag.
- Rename permAsked -> permissionAsked in checkOrgAuthorization for
  consistency with the other dimensions.
- Trim redundant @returns JSDoc blocks (pass/fail/skip semantics are
  already documented on the CheckResult type).
- Drop the 'separate product decision' hedge on the second_factor branch;
  the comment belonged in a tracker, not in code.
- Shorten the AUTH_PARAM_KEYS comment in protect.ts to one line.
- Delete three test-body comments in Session.test.ts that restated what
  the describe/it names already said.

No behavior changes.
- Harden combine() to an allowlist of 'pass' | 'skip'. If any helper ever
  returns an off-type or unexpected value, treat it as a denial rather than
  silently authorizing. Defense in depth around the one remaining foot-gun
  flagged by review.
- Add @clerk/backend to the changeset. authObject.has is built on
  createCheckAuthorization at request time, so backend's CHANGELOG should
  carry the fix explicitly even though the package patch-bumps transitively.
- Clarify the single-condition claim in the changeset so it does not contradict
  the strict_mfa behavior change.
- Delete narrative 'Regression: previously...' comments from the new tests;
  the ticket story belongs in commit messages, not in test bodies.
…, add e2e tests

Fredrik pointed out that strict_mfa / multi_factor graceful downgrade is
explicitly documented policy at clerk.com/docs/guides/secure/reverification,
not a bug. Reverting the F2 behavior change and the factor1Age===-1 early
return so every documented downgrade case continues to pass.

The reverification helper still gets its refactor: it returns crisp
'pass' | 'fail' | 'skip' instead of null so the combiner cannot silently
bypass via another true dimension. Every pre-existing Session.test assertion
remains true - the factor state x level matrix was walked against the
existing cases.

Tests:
- authorization.spec.ts: dropped the two F2 / factor1=-1 regression tests,
  added three positive multi-requirement tests (permission+reverification,
  role+feature, the all-three triple) per Fredrik's suggestion.
- Session.test.ts: reverted three test flips that pinned the non-graceful
  variant.
- integration/tests/protect.test.ts: added e2e coverage for SDK-67 (role
  AND permission) and SDK-68 (mixed auth params with unauthorizedUrl, mixed
  permission with token). Three new template pages plus a /settings/denied
  redirect target in the existing next-app-router template.

Changeset:
- Shrunk to @clerk/shared + @clerk/clerk-js + @clerk/nextjs (packages with
  direct code changes). Others patch-bump transitively via shared.
- Reworded the 'combined checks' bullet to describe the indeterminate
  collapse rather than a universal OR break.
- Dropped the has({}) and strict_mfa bullets per Fredrik.
- Simplified the Session.ts membership-id note.
Covers two combiner scenarios flagged in review:

- permission + strict_mfa with [0, -1] -> pass via graceful downgrade
- permission + reverification with [-1, -1] -> fail (no factors enrolled)

Also qualifies the reverification-only claim in the changeset: single-condition
reverification calls with malformed factorVerificationAge payloads now deny
(previously returned indeterminate).
@vercel

vercel Bot commented Apr 22, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

You must set up Two-Factor Authentication before accessing this team.

View Documentation: https://vercel.com/docs/two-factor-authentication

@pkg-pr-new

pkg-pr-new Bot commented Apr 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@8372

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@8372

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@8372

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@8372

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@8372

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@8372

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@8372

@clerk/express

npm i https://pkg.pr.new/@clerk/express@8372

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@8372

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@8372

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@8372

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@8372

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@8372

@clerk/react

npm i https://pkg.pr.new/@clerk/react@8372

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@8372

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@8372

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@8372

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@8372

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@8372

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@8372

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@8372

commit: 9750ecb

@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 24d93a3f-85cc-481f-9357-07bcb4e5d65d

📥 Commits

Reviewing files that changed from the base of the PR and between a6b9a21 and 9750ecb.

📒 Files selected for processing (2)
  • packages/shared/src/__tests__/authorization.spec.ts
  • packages/shared/src/authorization.ts

📝 Walkthrough

Walkthrough

Implements fail-closed authorization by changing internal checks to explicit verdicts ('pass' | 'fail' | 'skip') for organization, billing, and reverification dimensions and combining them so at least one dimension must pass and any non-skipped fail causes denial. Fixes Session.checkAuthorization to use the organization id from the membership object. Updates Next.js protect() to extract only known authorization keys and to enforce authorization when those keys are present even if the same options object also contains unauthenticatedUrl, unauthorizedUrl, or token. Adds tests and Next.js example pages exercising mixed-shape protect calls.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: fixing an authorization bypass in combined-condition has() checks across shared, clerk-js, and nextjs packages.
Description check ✅ Passed The description is comprehensive and directly related to the changeset. It explains the authorization bypass issue, lists all affected files and changes, provides detailed test coverage information, and references the backport plan.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/shared/src/authorization.ts`:
- Around line 112-126: The code currently assumes session claims (orgRole,
orgPermissions, features, plans) have the expected shapes and directly calls
prefixWithOrg(...).replace/.includes/.split which throws on
malformed/truthy-but-invalid values; update the guard logic in the
functions/blocks that use roleAsked, permissionAsked, feature/plan checks (the
branch using prefixWithOrg(params.role),
orgPermissions.includes(prefixWithOrg(params.permission)), and the similar block
at the later 178-192 range) to first validate the claim shape (e.g., ensure
orgRole is a string before calling prefixWithOrg, ensure orgPermissions is an
Array or string that can be split/iterated, ensure features/plans are
arrays/strings) and swallow parsing errors by returning 'fail' on validation
failure or inside a try/catch around parsing; this ensures any malformed claim
yields a safe 'fail' result instead of throwing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: cfeb5eb2-6d66-4091-81bf-fd56d7838ab2

📥 Commits

Reviewing files that changed from the base of the PR and between abaa339 and bacaa96.

📒 Files selected for processing (13)
  • .changeset/authorization-bypass-combined-conditions.md
  • integration/templates/next-app-router/src/app/settings/auth-protect-mixed-args/page.tsx
  • integration/templates/next-app-router/src/app/settings/auth-protect-mixed-token/page.tsx
  • integration/templates/next-app-router/src/app/settings/auth-protect-role-and-permission/page.tsx
  • integration/templates/next-app-router/src/app/settings/denied/page.tsx
  • integration/tests/protect.test.ts
  • packages/clerk-js/src/core/resources/Session.ts
  • packages/clerk-js/src/test/core-fixtures.ts
  • packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
  • packages/nextjs/src/server/protect.ts
  • packages/react/src/hooks/__tests__/useAuth.test.tsx
  • packages/shared/src/__tests__/authorization.spec.ts
  • packages/shared/src/authorization.ts

Comment thread packages/shared/src/authorization.ts
- Wrap single-line if bodies in braces to satisfy the curly eslint rule
  in packages/shared/src/authorization.ts.
- Cast mixed-shape auth.protect literals as any in two new regression
  tests and two new integration template pages so TypeScript does not
  block the build on excess-property checks. The fixture shape is
  intentionally off-type because the regression guards target runtime
  behavior for variable-assigned / JS callers.
@changeset-bot

changeset-bot Bot commented Apr 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9750ecb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@clerk/shared Patch
@clerk/clerk-js Patch
@clerk/nextjs Patch
@clerk/astro Patch
@clerk/backend Patch
@clerk/chrome-extension Patch
@clerk/expo-passkeys Patch
@clerk/expo Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/hono Patch
@clerk/localizations Patch
@clerk/msw Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/react Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch
@clerk/ui Patch
@clerk/vue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

The withCustomRoles integration instance provisions custom permissions
like org:posts:manage (used by existing /settings/auth-has). Our two new
routes referenced org:sys_profile:delete / org:sys_memberships:read which
the admin user does not hold on that instance, so the admin-path
assertions failed.

Swap both routes to org:posts:manage so admin passes and viewer still
fails (viewer lacks the permission per existing /settings/auth-has
coverage).
@vercel

vercel Bot commented Apr 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Apr 22, 2026 3:03pm

Request Review

@Ephem Ephem left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the Rabbit had a good NIT to be extra defensive, but other than that I think this is gtg.

@nikosdouvlis
nikosdouvlis merged commit d52b311 into main Apr 22, 2026
42 checks passed
@nikosdouvlis
nikosdouvlis deleted the fix/sdk-67-authorization-bypass branch April 22, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants