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/exact-jwt-permission-masks.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Ensure organization permission checks remain accurate when JWT v2 permission masks exceed JavaScript's safe integer range.
122 changes: 116 additions & 6 deletions packages/shared/src/__tests__/jwtPayloadParser.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';

import { splitByScope } from '../authorization';
import { createCheckAuthorization, splitByScope } from '../authorization';
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';

const baseClaims = {
Expand All@@ -14,6 +14,33 @@ const baseClaims = {
__raw: '',
};

const permissionNames = Array.from({ length: 54 }, (_, index) => `permission_${index}`);

const authFromFeaturePermissionMask = (fpm: string) => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:feature',
o: {
id: 'org_id',
rol: 'admin',
per: permissionNames.join(','),
fpm,
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

return { authObject, has };
};

describe('JWTPayloadToAuthObjectProperties', () => {
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
Expand DownExpand Up@@ -75,7 +102,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
);
});

test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
test('features without permissions use zero masks to preserve alignment', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
Expand All@@ -85,7 +112,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,3',
fpm: '1,3,0',
},
});

Expand DownExpand Up@@ -153,7 +180,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '3',
fpm: '3,0',
},
});

Expand DownExpand Up@@ -227,7 +254,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,2,3',
fpm: '1,2,3,0',
},
});

Expand All@@ -246,7 +273,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,create,update,delete,revoke',
fpm: '7,21',
fpm: '7,21,0',
},
});

Expand All@@ -261,6 +288,89 @@ describe('JWTPayloadToAuthObjectProperties', () => {
].sort(),
);
});

test('preserves permissions above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740993');

expect(authObject.orgPermissions).toEqual(['org:feature:permission_0', 'org:feature:permission_53']);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('does not introduce permissions when decoding a mask above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740995');

expect(authObject.orgPermissions).toEqual([
'org:feature:permission_0',
'org:feature:permission_1',
'org:feature:permission_53',
]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_1' })).toBe(true);
expect(has({ permission: 'org:feature:permission_2' })).toBe(false);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('discards mask bits outside the declared permission list', () => {
const { authObject, has } = authFromFeaturePermissionMask('18014398509481984');

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:undefined' })).toBe(false);
});

test('keeps permission masks aligned when a targeted feature follows a feature without permissions', () => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:repositories,o:impersonation,o:billing',
o: {
id: 'org_id',
rol: 'admin',
per: 'manage,read,update',
fpm: '6,0,3',
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

expect(authObject.orgPermissions).toEqual([
'org:repositories:read',
'org:repositories:update',
'org:billing:manage',
'org:billing:read',
]);
expect(has({ permission: 'org:impersonation:manage' })).toBe(false);
expect(has({ permission: 'org:billing:manage' })).toBe(true);
});

test.each([
['1', [0]],
['3', [0, 1]],
['7', [0, 1, 2]],
['21', [0, 2, 4]],
])('preserves permissions for the safe mask %s', (fpm, expectedPermissionIndexes) => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);
const expectedPermissions = expectedPermissionIndexes.map(index => `org:feature:permission_${index}`);

expect(authObject.orgPermissions).toEqual(expectedPermissions);
for (const permission of expectedPermissions) {
expect(has({ permission })).toBe(true);
}
});

test.each(['1invalid', '-1', '1.5'])('fails closed for the malformed mask %s', fpm => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(false);
});
});

describe('splitByScope ', () => {
Expand Down
50 changes: 38 additions & 12 deletions packages/shared/src/jwtPayloadParser.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,26 +6,52 @@ import type {
SharedSignedInAuthObjectProperties,
} from './types';

const decimalToBinaryBits = (decimal: string, minimumLength: number): number[] | undefined => {
if (!/^\d+$/.test(decimal)) {
return undefined;
}

let remaining = decimal.replace(/^0+/, '') || '0';
const bits: number[] = [];

while (remaining !== '0') {
let quotient = '';
let remainder = 0;

for (let i = 0; i < remaining.length; i++) {
const value = remainder * 10 + remaining.charCodeAt(i) - 48;
const quotientDigit = Math.floor(value / 2);

if (quotient || quotientDigit !== 0) {
quotient += quotientDigit;
}
remainder = value % 2;
}

bits.push(remainder);
remaining = quotient || '0';
}

if (bits.length === 0) {
bits.push(0);
}
while (bits.length < minimumLength) {
bits.push(0);
}

return bits;
};

export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => {
if (!per || !fpm) {
return { permissions: [], featurePermissionMap: [] };
}

const permissions = per.split(',').map(p => p.trim());

// TODO: make this more efficient
const featurePermissionMap = fpm
.split(',')
.map(permission => Number.parseInt(permission.trim(), 10))
.map((permission: number) =>
permission
.toString(2)
.padStart(permissions.length, '0')
.split('')
.map(bit => Number.parseInt(bit, 10))
.reverse(),
)
.filter(Boolean);
.map(permission => decimalToBinaryBits(permission.trim(), permissions.length) ?? []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { permissions, featurePermissionMap };
};
Expand DownExpand Up@@ -62,7 +88,7 @@ function buildOrgPermissions({
continue;
}

for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) {
for (let permIndex = 0; permIndex < permissionBits.length && permIndex < permissions.length; permIndex++) {

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.

[MEDIUM] fea/fpm index misalignment attaches a feature's permission mask to the wrong feature when feature targeting is active

buildOrgPermissions assumes featurePermissionMap[i] corresponds to features[i], but the encoder does not emit a mask per feature — pkg/auth/v2.go:223 skips any feature not in featuresInPermissions, so fpm is a compacted list while fea is the full one. The two only stay aligned when the permission-bearing features happen to form a prefix of fea. When params.Plan != nil (v2.go:69-95) featureSet is seeded from plan features and targeted features are appended after, breaking that prefix invariant — a plan feature the member has no permissions on then inherits the next mask in the list, so has({ permission: 'org:<wrong-feature>:manage' }) returns true for a permission the user was never granted.

This predates the diff, but it is in the function this PR rewrites and the PR's stated goal is decoding these masks exactly — the new permIndex < permissions.length clamp bounds the inner loop without fixing the outer index mapping. Suggest having the encoder emit a mask for every entry in fea (zero for features with no permissions), or emitting the feature name alongside each mask so the decoder can key on it rather than on position.

— Comment generated with Claude with @dominic-clerk's supervision

@jeremy-clerkjeremy-clerkAug 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Confirmed — this is a valid pre-existing encoder issue. I reproduced the false grant through the JWT v2 auth-object conversion and has() with a permission-bearing plan feature, a plan feature without permissions, and a permission-bearing targeted feature.

There isn't enough information in the current claims for ClerkJS to repair the mapping safely: once zero masks are omitted, the decoder cannot tell whether the next compacted mask belongs to the next fea entry or a later targeted feature. Failing closed whenever fea.length !== fpm.length would also deny valid permissions for existing plan tokens.

The correct fix is in the backend: emit one fpm entry for every fea entry, using 0 for features without permissions, and cover the plan-plus-targeting case there. I'm keeping this PR scoped to exact decimal mask decoding and undeclared-bit handling rather than adding an ambiguous decoder heuristic.

— Comment generated with Codex with @jeremy-clerk's supervision

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.

👍 I'll follow-up on this with a backend PR

@dominic-clerkdominic-clerkAug 12, 2026

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified that the backend fix is merged. This PR is now updated to match the new positional contract: fixtures include 0 masks for features without permissions, and the regression covers fea = repositories,impersonation,billing with fpm = 6,0,3 through the v2 auth object and has().

— Comment generated with Codex with @jeremy-clerk's supervision

if (permissionBits[permIndex] === 1) {
orgPermissions.push(`org:${feature}:${permissions[permIndex]}`);
}
Expand Down
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/exact-jwt-permission-masks.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Ensure organization permission checks remain accurate when JWT v2 permission masks exceed JavaScript's safe integer range.
122 changes: 116 additions & 6 deletions packages/shared/src/__tests__/jwtPayloadParser.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';

import { splitByScope } from '../authorization';
import { createCheckAuthorization, splitByScope } from '../authorization';
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';

const baseClaims = {
Expand All@@ -14,6 +14,33 @@ const baseClaims = {
__raw: '',
};

const permissionNames = Array.from({ length: 54 }, (_, index) => `permission_${index}`);

const authFromFeaturePermissionMask = (fpm: string) => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:feature',
o: {
id: 'org_id',
rol: 'admin',
per: permissionNames.join(','),
fpm,
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

return { authObject, has };
};

describe('JWTPayloadToAuthObjectProperties', () => {
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
Expand DownExpand Up@@ -75,7 +102,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
);
});

test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
test('features without permissions use zero masks to preserve alignment', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
Expand All@@ -85,7 +112,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,3',
fpm: '1,3,0',
},
});

Expand DownExpand Up@@ -153,7 +180,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '3',
fpm: '3,0',
},
});

Expand DownExpand Up@@ -227,7 +254,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,2,3',
fpm: '1,2,3,0',
},
});

Expand All@@ -246,7 +273,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,create,update,delete,revoke',
fpm: '7,21',
fpm: '7,21,0',
},
});

Expand All@@ -261,6 +288,89 @@ describe('JWTPayloadToAuthObjectProperties', () => {
].sort(),
);
});

test('preserves permissions above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740993');

expect(authObject.orgPermissions).toEqual(['org:feature:permission_0', 'org:feature:permission_53']);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('does not introduce permissions when decoding a mask above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740995');

expect(authObject.orgPermissions).toEqual([
'org:feature:permission_0',
'org:feature:permission_1',
'org:feature:permission_53',
]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_1' })).toBe(true);
expect(has({ permission: 'org:feature:permission_2' })).toBe(false);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('discards mask bits outside the declared permission list', () => {
const { authObject, has } = authFromFeaturePermissionMask('18014398509481984');

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:undefined' })).toBe(false);
});

test('keeps permission masks aligned when a targeted feature follows a feature without permissions', () => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:repositories,o:impersonation,o:billing',
o: {
id: 'org_id',
rol: 'admin',
per: 'manage,read,update',
fpm: '6,0,3',
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

expect(authObject.orgPermissions).toEqual([
'org:repositories:read',
'org:repositories:update',
'org:billing:manage',
'org:billing:read',
]);
expect(has({ permission: 'org:impersonation:manage' })).toBe(false);
expect(has({ permission: 'org:billing:manage' })).toBe(true);
});

test.each([
['1', [0]],
['3', [0, 1]],
['7', [0, 1, 2]],
['21', [0, 2, 4]],
])('preserves permissions for the safe mask %s', (fpm, expectedPermissionIndexes) => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);
const expectedPermissions = expectedPermissionIndexes.map(index => `org:feature:permission_${index}`);

expect(authObject.orgPermissions).toEqual(expectedPermissions);
for (const permission of expectedPermissions) {
expect(has({ permission })).toBe(true);
}
});

test.each(['1invalid', '-1', '1.5'])('fails closed for the malformed mask %s', fpm => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(false);
});
});

describe('splitByScope ', () => {
Expand Down
50 changes: 38 additions & 12 deletions packages/shared/src/jwtPayloadParser.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,26 +6,52 @@ import type {
SharedSignedInAuthObjectProperties,
} from './types';

const decimalToBinaryBits = (decimal: string, minimumLength: number): number[] | undefined => {
if (!/^\d+$/.test(decimal)) {
return undefined;
}

let remaining = decimal.replace(/^0+/, '') || '0';
const bits: number[] = [];

while (remaining !== '0') {
let quotient = '';
let remainder = 0;

for (let i = 0; i < remaining.length; i++) {
const value = remainder * 10 + remaining.charCodeAt(i) - 48;
const quotientDigit = Math.floor(value / 2);

if (quotient || quotientDigit !== 0) {
quotient += quotientDigit;
}
remainder = value % 2;
}

bits.push(remainder);
remaining = quotient || '0';
}

if (bits.length === 0) {
bits.push(0);
}
while (bits.length < minimumLength) {
bits.push(0);
}

return bits;
};

export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => {
if (!per || !fpm) {
return { permissions: [], featurePermissionMap: [] };
}

const permissions = per.split(',').map(p => p.trim());

// TODO: make this more efficient
const featurePermissionMap = fpm
.split(',')
.map(permission => Number.parseInt(permission.trim(), 10))
.map((permission: number) =>
permission
.toString(2)
.padStart(permissions.length, '0')
.split('')
.map(bit => Number.parseInt(bit, 10))
.reverse(),
)
.filter(Boolean);
.map(permission => decimalToBinaryBits(permission.trim(), permissions.length) ?? []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { permissions, featurePermissionMap };
};
Expand DownExpand Up@@ -62,7 +88,7 @@ function buildOrgPermissions({
continue;
}

for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) {
for (let permIndex = 0; permIndex < permissionBits.length && permIndex < permissions.length; permIndex++) {

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.

[MEDIUM] fea/fpm index misalignment attaches a feature's permission mask to the wrong feature when feature targeting is active

buildOrgPermissions assumes featurePermissionMap[i] corresponds to features[i], but the encoder does not emit a mask per feature — pkg/auth/v2.go:223 skips any feature not in featuresInPermissions, so fpm is a compacted list while fea is the full one. The two only stay aligned when the permission-bearing features happen to form a prefix of fea. When params.Plan != nil (v2.go:69-95) featureSet is seeded from plan features and targeted features are appended after, breaking that prefix invariant — a plan feature the member has no permissions on then inherits the next mask in the list, so has({ permission: 'org:<wrong-feature>:manage' }) returns true for a permission the user was never granted.

This predates the diff, but it is in the function this PR rewrites and the PR's stated goal is decoding these masks exactly — the new permIndex < permissions.length clamp bounds the inner loop without fixing the outer index mapping. Suggest having the encoder emit a mask for every entry in fea (zero for features with no permissions), or emitting the feature name alongside each mask so the decoder can key on it rather than on position.

— Comment generated with Claude with @dominic-clerk's supervision

@jeremy-clerkjeremy-clerkAug 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Confirmed — this is a valid pre-existing encoder issue. I reproduced the false grant through the JWT v2 auth-object conversion and has() with a permission-bearing plan feature, a plan feature without permissions, and a permission-bearing targeted feature.

There isn't enough information in the current claims for ClerkJS to repair the mapping safely: once zero masks are omitted, the decoder cannot tell whether the next compacted mask belongs to the next fea entry or a later targeted feature. Failing closed whenever fea.length !== fpm.length would also deny valid permissions for existing plan tokens.

The correct fix is in the backend: emit one fpm entry for every fea entry, using 0 for features without permissions, and cover the plan-plus-targeting case there. I'm keeping this PR scoped to exact decimal mask decoding and undeclared-bit handling rather than adding an ambiguous decoder heuristic.

— Comment generated with Codex with @jeremy-clerk's supervision

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.

👍 I'll follow-up on this with a backend PR

@dominic-clerkdominic-clerkAug 12, 2026

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified that the backend fix is merged. This PR is now updated to match the new positional contract: fixtures include 0 masks for features without permissions, and the regression covers fea = repositories,impersonation,billing with fpm = 6,0,3 through the v2 auth object and has().

— Comment generated with Codex with @jeremy-clerk's supervision

if (permissionBits[permIndex] === 1) {
orgPermissions.push(`org:${feature}:${permissions[permIndex]}`);
}
Expand Down
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/exact-jwt-permission-masks.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Ensure organization permission checks remain accurate when JWT v2 permission masks exceed JavaScript's safe integer range.
122 changes: 116 additions & 6 deletions packages/shared/src/__tests__/jwtPayloadParser.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';

import { splitByScope } from '../authorization';
import { createCheckAuthorization, splitByScope } from '../authorization';
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';

const baseClaims = {
Expand All@@ -14,6 +14,33 @@ const baseClaims = {
__raw: '',
};

const permissionNames = Array.from({ length: 54 }, (_, index) => `permission_${index}`);

const authFromFeaturePermissionMask = (fpm: string) => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:feature',
o: {
id: 'org_id',
rol: 'admin',
per: permissionNames.join(','),
fpm,
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

return { authObject, has };
};

describe('JWTPayloadToAuthObjectProperties', () => {
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
Expand DownExpand Up@@ -75,7 +102,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
);
});

test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
test('features without permissions use zero masks to preserve alignment', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
Expand All@@ -85,7 +112,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,3',
fpm: '1,3,0',
},
});

Expand DownExpand Up@@ -153,7 +180,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '3',
fpm: '3,0',
},
});

Expand DownExpand Up@@ -227,7 +254,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,2,3',
fpm: '1,2,3,0',
},
});

Expand All@@ -246,7 +273,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,create,update,delete,revoke',
fpm: '7,21',
fpm: '7,21,0',
},
});

Expand All@@ -261,6 +288,89 @@ describe('JWTPayloadToAuthObjectProperties', () => {
].sort(),
);
});

test('preserves permissions above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740993');

expect(authObject.orgPermissions).toEqual(['org:feature:permission_0', 'org:feature:permission_53']);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('does not introduce permissions when decoding a mask above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740995');

expect(authObject.orgPermissions).toEqual([
'org:feature:permission_0',
'org:feature:permission_1',
'org:feature:permission_53',
]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_1' })).toBe(true);
expect(has({ permission: 'org:feature:permission_2' })).toBe(false);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('discards mask bits outside the declared permission list', () => {
const { authObject, has } = authFromFeaturePermissionMask('18014398509481984');

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:undefined' })).toBe(false);
});

test('keeps permission masks aligned when a targeted feature follows a feature without permissions', () => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:repositories,o:impersonation,o:billing',
o: {
id: 'org_id',
rol: 'admin',
per: 'manage,read,update',
fpm: '6,0,3',
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

expect(authObject.orgPermissions).toEqual([
'org:repositories:read',
'org:repositories:update',
'org:billing:manage',
'org:billing:read',
]);
expect(has({ permission: 'org:impersonation:manage' })).toBe(false);
expect(has({ permission: 'org:billing:manage' })).toBe(true);
});

test.each([
['1', [0]],
['3', [0, 1]],
['7', [0, 1, 2]],
['21', [0, 2, 4]],
])('preserves permissions for the safe mask %s', (fpm, expectedPermissionIndexes) => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);
const expectedPermissions = expectedPermissionIndexes.map(index => `org:feature:permission_${index}`);

expect(authObject.orgPermissions).toEqual(expectedPermissions);
for (const permission of expectedPermissions) {
expect(has({ permission })).toBe(true);
}
});

test.each(['1invalid', '-1', '1.5'])('fails closed for the malformed mask %s', fpm => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(false);
});
});

describe('splitByScope ', () => {
Expand Down
50 changes: 38 additions & 12 deletions packages/shared/src/jwtPayloadParser.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,26 +6,52 @@ import type {
SharedSignedInAuthObjectProperties,
} from './types';

const decimalToBinaryBits = (decimal: string, minimumLength: number): number[] | undefined => {
if (!/^\d+$/.test(decimal)) {
return undefined;
}

let remaining = decimal.replace(/^0+/, '') || '0';
const bits: number[] = [];

while (remaining !== '0') {
let quotient = '';
let remainder = 0;

for (let i = 0; i < remaining.length; i++) {
const value = remainder * 10 + remaining.charCodeAt(i) - 48;
const quotientDigit = Math.floor(value / 2);

if (quotient || quotientDigit !== 0) {
quotient += quotientDigit;
}
remainder = value % 2;
}

bits.push(remainder);
remaining = quotient || '0';
}

if (bits.length === 0) {
bits.push(0);
}
while (bits.length < minimumLength) {
bits.push(0);
}

return bits;
};

export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => {
if (!per || !fpm) {
return { permissions: [], featurePermissionMap: [] };
}

const permissions = per.split(',').map(p => p.trim());

// TODO: make this more efficient
const featurePermissionMap = fpm
.split(',')
.map(permission => Number.parseInt(permission.trim(), 10))
.map((permission: number) =>
permission
.toString(2)
.padStart(permissions.length, '0')
.split('')
.map(bit => Number.parseInt(bit, 10))
.reverse(),
)
.filter(Boolean);
.map(permission => decimalToBinaryBits(permission.trim(), permissions.length) ?? []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { permissions, featurePermissionMap };
};
Expand DownExpand Up@@ -62,7 +88,7 @@ function buildOrgPermissions({
continue;
}

for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) {
for (let permIndex = 0; permIndex < permissionBits.length && permIndex < permissions.length; permIndex++) {

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.

[MEDIUM] fea/fpm index misalignment attaches a feature's permission mask to the wrong feature when feature targeting is active

buildOrgPermissions assumes featurePermissionMap[i] corresponds to features[i], but the encoder does not emit a mask per feature — pkg/auth/v2.go:223 skips any feature not in featuresInPermissions, so fpm is a compacted list while fea is the full one. The two only stay aligned when the permission-bearing features happen to form a prefix of fea. When params.Plan != nil (v2.go:69-95) featureSet is seeded from plan features and targeted features are appended after, breaking that prefix invariant — a plan feature the member has no permissions on then inherits the next mask in the list, so has({ permission: 'org:<wrong-feature>:manage' }) returns true for a permission the user was never granted.

This predates the diff, but it is in the function this PR rewrites and the PR's stated goal is decoding these masks exactly — the new permIndex < permissions.length clamp bounds the inner loop without fixing the outer index mapping. Suggest having the encoder emit a mask for every entry in fea (zero for features with no permissions), or emitting the feature name alongside each mask so the decoder can key on it rather than on position.

— Comment generated with Claude with @dominic-clerk's supervision

@jeremy-clerkjeremy-clerkAug 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Confirmed — this is a valid pre-existing encoder issue. I reproduced the false grant through the JWT v2 auth-object conversion and has() with a permission-bearing plan feature, a plan feature without permissions, and a permission-bearing targeted feature.

There isn't enough information in the current claims for ClerkJS to repair the mapping safely: once zero masks are omitted, the decoder cannot tell whether the next compacted mask belongs to the next fea entry or a later targeted feature. Failing closed whenever fea.length !== fpm.length would also deny valid permissions for existing plan tokens.

The correct fix is in the backend: emit one fpm entry for every fea entry, using 0 for features without permissions, and cover the plan-plus-targeting case there. I'm keeping this PR scoped to exact decimal mask decoding and undeclared-bit handling rather than adding an ambiguous decoder heuristic.

— Comment generated with Codex with @jeremy-clerk's supervision

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.

👍 I'll follow-up on this with a backend PR

@dominic-clerkdominic-clerkAug 12, 2026

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified that the backend fix is merged. This PR is now updated to match the new positional contract: fixtures include 0 masks for features without permissions, and the regression covers fea = repositories,impersonation,billing with fpm = 6,0,3 through the v2 auth object and has().

— Comment generated with Codex with @jeremy-clerk's supervision

if (permissionBits[permIndex] === 1) {
orgPermissions.push(`org:${feature}:${permissions[permIndex]}`);
}
Expand Down
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/exact-jwt-permission-masks.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Ensure organization permission checks remain accurate when JWT v2 permission masks exceed JavaScript's safe integer range.
122 changes: 116 additions & 6 deletions packages/shared/src/__tests__/jwtPayloadParser.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';

import { splitByScope } from '../authorization';
import { createCheckAuthorization, splitByScope } from '../authorization';
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';

const baseClaims = {
Expand All@@ -14,6 +14,33 @@ const baseClaims = {
__raw: '',
};

const permissionNames = Array.from({ length: 54 }, (_, index) => `permission_${index}`);

const authFromFeaturePermissionMask = (fpm: string) => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:feature',
o: {
id: 'org_id',
rol: 'admin',
per: permissionNames.join(','),
fpm,
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

return { authObject, has };
};

describe('JWTPayloadToAuthObjectProperties', () => {
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
Expand DownExpand Up@@ -75,7 +102,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
);
});

test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
test('features without permissions use zero masks to preserve alignment', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
Expand All@@ -85,7 +112,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,3',
fpm: '1,3,0',
},
});

Expand DownExpand Up@@ -153,7 +180,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '3',
fpm: '3,0',
},
});

Expand DownExpand Up@@ -227,7 +254,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,2,3',
fpm: '1,2,3,0',
},
});

Expand All@@ -246,7 +273,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,create,update,delete,revoke',
fpm: '7,21',
fpm: '7,21,0',
},
});

Expand All@@ -261,6 +288,89 @@ describe('JWTPayloadToAuthObjectProperties', () => {
].sort(),
);
});

test('preserves permissions above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740993');

expect(authObject.orgPermissions).toEqual(['org:feature:permission_0', 'org:feature:permission_53']);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('does not introduce permissions when decoding a mask above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740995');

expect(authObject.orgPermissions).toEqual([
'org:feature:permission_0',
'org:feature:permission_1',
'org:feature:permission_53',
]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_1' })).toBe(true);
expect(has({ permission: 'org:feature:permission_2' })).toBe(false);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('discards mask bits outside the declared permission list', () => {
const { authObject, has } = authFromFeaturePermissionMask('18014398509481984');

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:undefined' })).toBe(false);
});

test('keeps permission masks aligned when a targeted feature follows a feature without permissions', () => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:repositories,o:impersonation,o:billing',
o: {
id: 'org_id',
rol: 'admin',
per: 'manage,read,update',
fpm: '6,0,3',
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

expect(authObject.orgPermissions).toEqual([
'org:repositories:read',
'org:repositories:update',
'org:billing:manage',
'org:billing:read',
]);
expect(has({ permission: 'org:impersonation:manage' })).toBe(false);
expect(has({ permission: 'org:billing:manage' })).toBe(true);
});

test.each([
['1', [0]],
['3', [0, 1]],
['7', [0, 1, 2]],
['21', [0, 2, 4]],
])('preserves permissions for the safe mask %s', (fpm, expectedPermissionIndexes) => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);
const expectedPermissions = expectedPermissionIndexes.map(index => `org:feature:permission_${index}`);

expect(authObject.orgPermissions).toEqual(expectedPermissions);
for (const permission of expectedPermissions) {
expect(has({ permission })).toBe(true);
}
});

test.each(['1invalid', '-1', '1.5'])('fails closed for the malformed mask %s', fpm => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(false);
});
});

describe('splitByScope ', () => {
Expand Down
50 changes: 38 additions & 12 deletions packages/shared/src/jwtPayloadParser.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,26 +6,52 @@ import type {
SharedSignedInAuthObjectProperties,
} from './types';

const decimalToBinaryBits = (decimal: string, minimumLength: number): number[] | undefined => {
if (!/^\d+$/.test(decimal)) {
return undefined;
}

let remaining = decimal.replace(/^0+/, '') || '0';
const bits: number[] = [];

while (remaining !== '0') {
let quotient = '';
let remainder = 0;

for (let i = 0; i < remaining.length; i++) {
const value = remainder * 10 + remaining.charCodeAt(i) - 48;
const quotientDigit = Math.floor(value / 2);

if (quotient || quotientDigit !== 0) {
quotient += quotientDigit;
}
remainder = value % 2;
}

bits.push(remainder);
remaining = quotient || '0';
}

if (bits.length === 0) {
bits.push(0);
}
while (bits.length < minimumLength) {
bits.push(0);
}

return bits;
};

export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => {
if (!per || !fpm) {
return { permissions: [], featurePermissionMap: [] };
}

const permissions = per.split(',').map(p => p.trim());

// TODO: make this more efficient
const featurePermissionMap = fpm
.split(',')
.map(permission => Number.parseInt(permission.trim(), 10))
.map((permission: number) =>
permission
.toString(2)
.padStart(permissions.length, '0')
.split('')
.map(bit => Number.parseInt(bit, 10))
.reverse(),
)
.filter(Boolean);
.map(permission => decimalToBinaryBits(permission.trim(), permissions.length) ?? []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { permissions, featurePermissionMap };
};
Expand DownExpand Up@@ -62,7 +88,7 @@ function buildOrgPermissions({
continue;
}

for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) {
for (let permIndex = 0; permIndex < permissionBits.length && permIndex < permissions.length; permIndex++) {

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.

[MEDIUM] fea/fpm index misalignment attaches a feature's permission mask to the wrong feature when feature targeting is active

buildOrgPermissions assumes featurePermissionMap[i] corresponds to features[i], but the encoder does not emit a mask per feature — pkg/auth/v2.go:223 skips any feature not in featuresInPermissions, so fpm is a compacted list while fea is the full one. The two only stay aligned when the permission-bearing features happen to form a prefix of fea. When params.Plan != nil (v2.go:69-95) featureSet is seeded from plan features and targeted features are appended after, breaking that prefix invariant — a plan feature the member has no permissions on then inherits the next mask in the list, so has({ permission: 'org:<wrong-feature>:manage' }) returns true for a permission the user was never granted.

This predates the diff, but it is in the function this PR rewrites and the PR's stated goal is decoding these masks exactly — the new permIndex < permissions.length clamp bounds the inner loop without fixing the outer index mapping. Suggest having the encoder emit a mask for every entry in fea (zero for features with no permissions), or emitting the feature name alongside each mask so the decoder can key on it rather than on position.

— Comment generated with Claude with @dominic-clerk's supervision

@jeremy-clerkjeremy-clerkAug 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Confirmed — this is a valid pre-existing encoder issue. I reproduced the false grant through the JWT v2 auth-object conversion and has() with a permission-bearing plan feature, a plan feature without permissions, and a permission-bearing targeted feature.

There isn't enough information in the current claims for ClerkJS to repair the mapping safely: once zero masks are omitted, the decoder cannot tell whether the next compacted mask belongs to the next fea entry or a later targeted feature. Failing closed whenever fea.length !== fpm.length would also deny valid permissions for existing plan tokens.

The correct fix is in the backend: emit one fpm entry for every fea entry, using 0 for features without permissions, and cover the plan-plus-targeting case there. I'm keeping this PR scoped to exact decimal mask decoding and undeclared-bit handling rather than adding an ambiguous decoder heuristic.

— Comment generated with Codex with @jeremy-clerk's supervision

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.

👍 I'll follow-up on this with a backend PR

@dominic-clerkdominic-clerkAug 12, 2026

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified that the backend fix is merged. This PR is now updated to match the new positional contract: fixtures include 0 masks for features without permissions, and the regression covers fea = repositories,impersonation,billing with fpm = 6,0,3 through the v2 auth object and has().

— Comment generated with Codex with @jeremy-clerk's supervision

if (permissionBits[permIndex] === 1) {
orgPermissions.push(`org:${feature}:${permissions[permIndex]}`);
}
Expand Down
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/exact-jwt-permission-masks.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Ensure organization permission checks remain accurate when JWT v2 permission masks exceed JavaScript's safe integer range.
122 changes: 116 additions & 6 deletions packages/shared/src/__tests__/jwtPayloadParser.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';

import { splitByScope } from '../authorization';
import { createCheckAuthorization, splitByScope } from '../authorization';
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';

const baseClaims = {
Expand All@@ -14,6 +14,33 @@ const baseClaims = {
__raw: '',
};

const permissionNames = Array.from({ length: 54 }, (_, index) => `permission_${index}`);

const authFromFeaturePermissionMask = (fpm: string) => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:feature',
o: {
id: 'org_id',
rol: 'admin',
per: permissionNames.join(','),
fpm,
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

return { authObject, has };
};

describe('JWTPayloadToAuthObjectProperties', () => {
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
Expand DownExpand Up@@ -75,7 +102,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
);
});

test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
test('features without permissions use zero masks to preserve alignment', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
Expand All@@ -85,7 +112,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,3',
fpm: '1,3,0',
},
});

Expand DownExpand Up@@ -153,7 +180,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '3',
fpm: '3,0',
},
});

Expand DownExpand Up@@ -227,7 +254,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,2,3',
fpm: '1,2,3,0',
},
});

Expand All@@ -246,7 +273,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,create,update,delete,revoke',
fpm: '7,21',
fpm: '7,21,0',
},
});

Expand All@@ -261,6 +288,89 @@ describe('JWTPayloadToAuthObjectProperties', () => {
].sort(),
);
});

test('preserves permissions above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740993');

expect(authObject.orgPermissions).toEqual(['org:feature:permission_0', 'org:feature:permission_53']);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('does not introduce permissions when decoding a mask above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740995');

expect(authObject.orgPermissions).toEqual([
'org:feature:permission_0',
'org:feature:permission_1',
'org:feature:permission_53',
]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_1' })).toBe(true);
expect(has({ permission: 'org:feature:permission_2' })).toBe(false);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('discards mask bits outside the declared permission list', () => {
const { authObject, has } = authFromFeaturePermissionMask('18014398509481984');

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:undefined' })).toBe(false);
});

test('keeps permission masks aligned when a targeted feature follows a feature without permissions', () => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:repositories,o:impersonation,o:billing',
o: {
id: 'org_id',
rol: 'admin',
per: 'manage,read,update',
fpm: '6,0,3',
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

expect(authObject.orgPermissions).toEqual([
'org:repositories:read',
'org:repositories:update',
'org:billing:manage',
'org:billing:read',
]);
expect(has({ permission: 'org:impersonation:manage' })).toBe(false);
expect(has({ permission: 'org:billing:manage' })).toBe(true);
});

test.each([
['1', [0]],
['3', [0, 1]],
['7', [0, 1, 2]],
['21', [0, 2, 4]],
])('preserves permissions for the safe mask %s', (fpm, expectedPermissionIndexes) => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);
const expectedPermissions = expectedPermissionIndexes.map(index => `org:feature:permission_${index}`);

expect(authObject.orgPermissions).toEqual(expectedPermissions);
for (const permission of expectedPermissions) {
expect(has({ permission })).toBe(true);
}
});

test.each(['1invalid', '-1', '1.5'])('fails closed for the malformed mask %s', fpm => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(false);
});
});

describe('splitByScope ', () => {
Expand Down
50 changes: 38 additions & 12 deletions packages/shared/src/jwtPayloadParser.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,26 +6,52 @@ import type {
SharedSignedInAuthObjectProperties,
} from './types';

const decimalToBinaryBits = (decimal: string, minimumLength: number): number[] | undefined => {
if (!/^\d+$/.test(decimal)) {
return undefined;
}

let remaining = decimal.replace(/^0+/, '') || '0';
const bits: number[] = [];

while (remaining !== '0') {
let quotient = '';
let remainder = 0;

for (let i = 0; i < remaining.length; i++) {
const value = remainder * 10 + remaining.charCodeAt(i) - 48;
const quotientDigit = Math.floor(value / 2);

if (quotient || quotientDigit !== 0) {
quotient += quotientDigit;
}
remainder = value % 2;
}

bits.push(remainder);
remaining = quotient || '0';
}

if (bits.length === 0) {
bits.push(0);
}
while (bits.length < minimumLength) {
bits.push(0);
}

return bits;
};

export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => {
if (!per || !fpm) {
return { permissions: [], featurePermissionMap: [] };
}

const permissions = per.split(',').map(p => p.trim());

// TODO: make this more efficient
const featurePermissionMap = fpm
.split(',')
.map(permission => Number.parseInt(permission.trim(), 10))
.map((permission: number) =>
permission
.toString(2)
.padStart(permissions.length, '0')
.split('')
.map(bit => Number.parseInt(bit, 10))
.reverse(),
)
.filter(Boolean);
.map(permission => decimalToBinaryBits(permission.trim(), permissions.length) ?? []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { permissions, featurePermissionMap };
};
Expand DownExpand Up@@ -62,7 +88,7 @@ function buildOrgPermissions({
continue;
}

for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) {
for (let permIndex = 0; permIndex < permissionBits.length && permIndex < permissions.length; permIndex++) {

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.

[MEDIUM] fea/fpm index misalignment attaches a feature's permission mask to the wrong feature when feature targeting is active

buildOrgPermissions assumes featurePermissionMap[i] corresponds to features[i], but the encoder does not emit a mask per feature — pkg/auth/v2.go:223 skips any feature not in featuresInPermissions, so fpm is a compacted list while fea is the full one. The two only stay aligned when the permission-bearing features happen to form a prefix of fea. When params.Plan != nil (v2.go:69-95) featureSet is seeded from plan features and targeted features are appended after, breaking that prefix invariant — a plan feature the member has no permissions on then inherits the next mask in the list, so has({ permission: 'org:<wrong-feature>:manage' }) returns true for a permission the user was never granted.

This predates the diff, but it is in the function this PR rewrites and the PR's stated goal is decoding these masks exactly — the new permIndex < permissions.length clamp bounds the inner loop without fixing the outer index mapping. Suggest having the encoder emit a mask for every entry in fea (zero for features with no permissions), or emitting the feature name alongside each mask so the decoder can key on it rather than on position.

— Comment generated with Claude with @dominic-clerk's supervision

@jeremy-clerkjeremy-clerkAug 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Confirmed — this is a valid pre-existing encoder issue. I reproduced the false grant through the JWT v2 auth-object conversion and has() with a permission-bearing plan feature, a plan feature without permissions, and a permission-bearing targeted feature.

There isn't enough information in the current claims for ClerkJS to repair the mapping safely: once zero masks are omitted, the decoder cannot tell whether the next compacted mask belongs to the next fea entry or a later targeted feature. Failing closed whenever fea.length !== fpm.length would also deny valid permissions for existing plan tokens.

The correct fix is in the backend: emit one fpm entry for every fea entry, using 0 for features without permissions, and cover the plan-plus-targeting case there. I'm keeping this PR scoped to exact decimal mask decoding and undeclared-bit handling rather than adding an ambiguous decoder heuristic.

— Comment generated with Codex with @jeremy-clerk's supervision

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.

👍 I'll follow-up on this with a backend PR

@dominic-clerkdominic-clerkAug 12, 2026

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified that the backend fix is merged. This PR is now updated to match the new positional contract: fixtures include 0 masks for features without permissions, and the regression covers fea = repositories,impersonation,billing with fpm = 6,0,3 through the v2 auth object and has().

— Comment generated with Codex with @jeremy-clerk's supervision

if (permissionBits[permIndex] === 1) {
orgPermissions.push(`org:${feature}:${permissions[permIndex]}`);
}
Expand Down
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/exact-jwt-permission-masks.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Ensure organization permission checks remain accurate when JWT v2 permission masks exceed JavaScript's safe integer range.
122 changes: 116 additions & 6 deletions packages/shared/src/__tests__/jwtPayloadParser.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';

import { splitByScope } from '../authorization';
import { createCheckAuthorization, splitByScope } from '../authorization';
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';

const baseClaims = {
Expand All@@ -14,6 +14,33 @@ const baseClaims = {
__raw: '',
};

const permissionNames = Array.from({ length: 54 }, (_, index) => `permission_${index}`);

const authFromFeaturePermissionMask = (fpm: string) => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:feature',
o: {
id: 'org_id',
rol: 'admin',
per: permissionNames.join(','),
fpm,
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

return { authObject, has };
};

describe('JWTPayloadToAuthObjectProperties', () => {
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
Expand DownExpand Up@@ -75,7 +102,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
);
});

test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
test('features without permissions use zero masks to preserve alignment', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
Expand All@@ -85,7 +112,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,3',
fpm: '1,3,0',
},
});

Expand DownExpand Up@@ -153,7 +180,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '3',
fpm: '3,0',
},
});

Expand DownExpand Up@@ -227,7 +254,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,2,3',
fpm: '1,2,3,0',
},
});

Expand All@@ -246,7 +273,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,create,update,delete,revoke',
fpm: '7,21',
fpm: '7,21,0',
},
});

Expand All@@ -261,6 +288,89 @@ describe('JWTPayloadToAuthObjectProperties', () => {
].sort(),
);
});

test('preserves permissions above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740993');

expect(authObject.orgPermissions).toEqual(['org:feature:permission_0', 'org:feature:permission_53']);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('does not introduce permissions when decoding a mask above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740995');

expect(authObject.orgPermissions).toEqual([
'org:feature:permission_0',
'org:feature:permission_1',
'org:feature:permission_53',
]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_1' })).toBe(true);
expect(has({ permission: 'org:feature:permission_2' })).toBe(false);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('discards mask bits outside the declared permission list', () => {
const { authObject, has } = authFromFeaturePermissionMask('18014398509481984');

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:undefined' })).toBe(false);
});

test('keeps permission masks aligned when a targeted feature follows a feature without permissions', () => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:repositories,o:impersonation,o:billing',
o: {
id: 'org_id',
rol: 'admin',
per: 'manage,read,update',
fpm: '6,0,3',
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

expect(authObject.orgPermissions).toEqual([
'org:repositories:read',
'org:repositories:update',
'org:billing:manage',
'org:billing:read',
]);
expect(has({ permission: 'org:impersonation:manage' })).toBe(false);
expect(has({ permission: 'org:billing:manage' })).toBe(true);
});

test.each([
['1', [0]],
['3', [0, 1]],
['7', [0, 1, 2]],
['21', [0, 2, 4]],
])('preserves permissions for the safe mask %s', (fpm, expectedPermissionIndexes) => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);
const expectedPermissions = expectedPermissionIndexes.map(index => `org:feature:permission_${index}`);

expect(authObject.orgPermissions).toEqual(expectedPermissions);
for (const permission of expectedPermissions) {
expect(has({ permission })).toBe(true);
}
});

test.each(['1invalid', '-1', '1.5'])('fails closed for the malformed mask %s', fpm => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(false);
});
});

describe('splitByScope ', () => {
Expand Down
50 changes: 38 additions & 12 deletions packages/shared/src/jwtPayloadParser.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,26 +6,52 @@ import type {
SharedSignedInAuthObjectProperties,
} from './types';

const decimalToBinaryBits = (decimal: string, minimumLength: number): number[] | undefined => {
if (!/^\d+$/.test(decimal)) {
return undefined;
}

let remaining = decimal.replace(/^0+/, '') || '0';
const bits: number[] = [];

while (remaining !== '0') {
let quotient = '';
let remainder = 0;

for (let i = 0; i < remaining.length; i++) {
const value = remainder * 10 + remaining.charCodeAt(i) - 48;
const quotientDigit = Math.floor(value / 2);

if (quotient || quotientDigit !== 0) {
quotient += quotientDigit;
}
remainder = value % 2;
}

bits.push(remainder);
remaining = quotient || '0';
}

if (bits.length === 0) {
bits.push(0);
}
while (bits.length < minimumLength) {
bits.push(0);
}

return bits;
};

export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => {
if (!per || !fpm) {
return { permissions: [], featurePermissionMap: [] };
}

const permissions = per.split(',').map(p => p.trim());

// TODO: make this more efficient
const featurePermissionMap = fpm
.split(',')
.map(permission => Number.parseInt(permission.trim(), 10))
.map((permission: number) =>
permission
.toString(2)
.padStart(permissions.length, '0')
.split('')
.map(bit => Number.parseInt(bit, 10))
.reverse(),
)
.filter(Boolean);
.map(permission => decimalToBinaryBits(permission.trim(), permissions.length) ?? []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { permissions, featurePermissionMap };
};
Expand DownExpand Up@@ -62,7 +88,7 @@ function buildOrgPermissions({
continue;
}

for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) {
for (let permIndex = 0; permIndex < permissionBits.length && permIndex < permissions.length; permIndex++) {

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.

[MEDIUM] fea/fpm index misalignment attaches a feature's permission mask to the wrong feature when feature targeting is active

buildOrgPermissions assumes featurePermissionMap[i] corresponds to features[i], but the encoder does not emit a mask per feature — pkg/auth/v2.go:223 skips any feature not in featuresInPermissions, so fpm is a compacted list while fea is the full one. The two only stay aligned when the permission-bearing features happen to form a prefix of fea. When params.Plan != nil (v2.go:69-95) featureSet is seeded from plan features and targeted features are appended after, breaking that prefix invariant — a plan feature the member has no permissions on then inherits the next mask in the list, so has({ permission: 'org:<wrong-feature>:manage' }) returns true for a permission the user was never granted.

This predates the diff, but it is in the function this PR rewrites and the PR's stated goal is decoding these masks exactly — the new permIndex < permissions.length clamp bounds the inner loop without fixing the outer index mapping. Suggest having the encoder emit a mask for every entry in fea (zero for features with no permissions), or emitting the feature name alongside each mask so the decoder can key on it rather than on position.

— Comment generated with Claude with @dominic-clerk's supervision

@jeremy-clerkjeremy-clerkAug 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Confirmed — this is a valid pre-existing encoder issue. I reproduced the false grant through the JWT v2 auth-object conversion and has() with a permission-bearing plan feature, a plan feature without permissions, and a permission-bearing targeted feature.

There isn't enough information in the current claims for ClerkJS to repair the mapping safely: once zero masks are omitted, the decoder cannot tell whether the next compacted mask belongs to the next fea entry or a later targeted feature. Failing closed whenever fea.length !== fpm.length would also deny valid permissions for existing plan tokens.

The correct fix is in the backend: emit one fpm entry for every fea entry, using 0 for features without permissions, and cover the plan-plus-targeting case there. I'm keeping this PR scoped to exact decimal mask decoding and undeclared-bit handling rather than adding an ambiguous decoder heuristic.

— Comment generated with Codex with @jeremy-clerk's supervision

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.

👍 I'll follow-up on this with a backend PR

@dominic-clerkdominic-clerkAug 12, 2026

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified that the backend fix is merged. This PR is now updated to match the new positional contract: fixtures include 0 masks for features without permissions, and the regression covers fea = repositories,impersonation,billing with fpm = 6,0,3 through the v2 auth object and has().

— Comment generated with Codex with @jeremy-clerk's supervision

if (permissionBits[permIndex] === 1) {
orgPermissions.push(`org:${feature}:${permissions[permIndex]}`);
}
Expand Down
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/exact-jwt-permission-masks.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Ensure organization permission checks remain accurate when JWT v2 permission masks exceed JavaScript's safe integer range.
122 changes: 116 additions & 6 deletions packages/shared/src/__tests__/jwtPayloadParser.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';

import { splitByScope } from '../authorization';
import { createCheckAuthorization, splitByScope } from '../authorization';
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';

const baseClaims = {
Expand All@@ -14,6 +14,33 @@ const baseClaims = {
__raw: '',
};

const permissionNames = Array.from({ length: 54 }, (_, index) => `permission_${index}`);

const authFromFeaturePermissionMask = (fpm: string) => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:feature',
o: {
id: 'org_id',
rol: 'admin',
per: permissionNames.join(','),
fpm,
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

return { authObject, has };
};

describe('JWTPayloadToAuthObjectProperties', () => {
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
Expand DownExpand Up@@ -75,7 +102,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
);
});

test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
test('features without permissions use zero masks to preserve alignment', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
Expand All@@ -85,7 +112,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,3',
fpm: '1,3,0',
},
});

Expand DownExpand Up@@ -153,7 +180,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '3',
fpm: '3,0',
},
});

Expand DownExpand Up@@ -227,7 +254,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,2,3',
fpm: '1,2,3,0',
},
});

Expand All@@ -246,7 +273,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,create,update,delete,revoke',
fpm: '7,21',
fpm: '7,21,0',
},
});

Expand All@@ -261,6 +288,89 @@ describe('JWTPayloadToAuthObjectProperties', () => {
].sort(),
);
});

test('preserves permissions above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740993');

expect(authObject.orgPermissions).toEqual(['org:feature:permission_0', 'org:feature:permission_53']);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('does not introduce permissions when decoding a mask above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740995');

expect(authObject.orgPermissions).toEqual([
'org:feature:permission_0',
'org:feature:permission_1',
'org:feature:permission_53',
]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_1' })).toBe(true);
expect(has({ permission: 'org:feature:permission_2' })).toBe(false);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('discards mask bits outside the declared permission list', () => {
const { authObject, has } = authFromFeaturePermissionMask('18014398509481984');

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:undefined' })).toBe(false);
});

test('keeps permission masks aligned when a targeted feature follows a feature without permissions', () => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:repositories,o:impersonation,o:billing',
o: {
id: 'org_id',
rol: 'admin',
per: 'manage,read,update',
fpm: '6,0,3',
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

expect(authObject.orgPermissions).toEqual([
'org:repositories:read',
'org:repositories:update',
'org:billing:manage',
'org:billing:read',
]);
expect(has({ permission: 'org:impersonation:manage' })).toBe(false);
expect(has({ permission: 'org:billing:manage' })).toBe(true);
});

test.each([
['1', [0]],
['3', [0, 1]],
['7', [0, 1, 2]],
['21', [0, 2, 4]],
])('preserves permissions for the safe mask %s', (fpm, expectedPermissionIndexes) => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);
const expectedPermissions = expectedPermissionIndexes.map(index => `org:feature:permission_${index}`);

expect(authObject.orgPermissions).toEqual(expectedPermissions);
for (const permission of expectedPermissions) {
expect(has({ permission })).toBe(true);
}
});

test.each(['1invalid', '-1', '1.5'])('fails closed for the malformed mask %s', fpm => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(false);
});
});

describe('splitByScope ', () => {
Expand Down
50 changes: 38 additions & 12 deletions packages/shared/src/jwtPayloadParser.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,26 +6,52 @@ import type {
SharedSignedInAuthObjectProperties,
} from './types';

const decimalToBinaryBits = (decimal: string, minimumLength: number): number[] | undefined => {
if (!/^\d+$/.test(decimal)) {
return undefined;
}

let remaining = decimal.replace(/^0+/, '') || '0';
const bits: number[] = [];

while (remaining !== '0') {
let quotient = '';
let remainder = 0;

for (let i = 0; i < remaining.length; i++) {
const value = remainder * 10 + remaining.charCodeAt(i) - 48;
const quotientDigit = Math.floor(value / 2);

if (quotient || quotientDigit !== 0) {
quotient += quotientDigit;
}
remainder = value % 2;
}

bits.push(remainder);
remaining = quotient || '0';
}

if (bits.length === 0) {
bits.push(0);
}
while (bits.length < minimumLength) {
bits.push(0);
}

return bits;
};

export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => {
if (!per || !fpm) {
return { permissions: [], featurePermissionMap: [] };
}

const permissions = per.split(',').map(p => p.trim());

// TODO: make this more efficient
const featurePermissionMap = fpm
.split(',')
.map(permission => Number.parseInt(permission.trim(), 10))
.map((permission: number) =>
permission
.toString(2)
.padStart(permissions.length, '0')
.split('')
.map(bit => Number.parseInt(bit, 10))
.reverse(),
)
.filter(Boolean);
.map(permission => decimalToBinaryBits(permission.trim(), permissions.length) ?? []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { permissions, featurePermissionMap };
};
Expand DownExpand Up@@ -62,7 +88,7 @@ function buildOrgPermissions({
continue;
}

for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) {
for (let permIndex = 0; permIndex < permissionBits.length && permIndex < permissions.length; permIndex++) {

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.

[MEDIUM] fea/fpm index misalignment attaches a feature's permission mask to the wrong feature when feature targeting is active

buildOrgPermissions assumes featurePermissionMap[i] corresponds to features[i], but the encoder does not emit a mask per feature — pkg/auth/v2.go:223 skips any feature not in featuresInPermissions, so fpm is a compacted list while fea is the full one. The two only stay aligned when the permission-bearing features happen to form a prefix of fea. When params.Plan != nil (v2.go:69-95) featureSet is seeded from plan features and targeted features are appended after, breaking that prefix invariant — a plan feature the member has no permissions on then inherits the next mask in the list, so has({ permission: 'org:<wrong-feature>:manage' }) returns true for a permission the user was never granted.

This predates the diff, but it is in the function this PR rewrites and the PR's stated goal is decoding these masks exactly — the new permIndex < permissions.length clamp bounds the inner loop without fixing the outer index mapping. Suggest having the encoder emit a mask for every entry in fea (zero for features with no permissions), or emitting the feature name alongside each mask so the decoder can key on it rather than on position.

— Comment generated with Claude with @dominic-clerk's supervision

@jeremy-clerkjeremy-clerkAug 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Confirmed — this is a valid pre-existing encoder issue. I reproduced the false grant through the JWT v2 auth-object conversion and has() with a permission-bearing plan feature, a plan feature without permissions, and a permission-bearing targeted feature.

There isn't enough information in the current claims for ClerkJS to repair the mapping safely: once zero masks are omitted, the decoder cannot tell whether the next compacted mask belongs to the next fea entry or a later targeted feature. Failing closed whenever fea.length !== fpm.length would also deny valid permissions for existing plan tokens.

The correct fix is in the backend: emit one fpm entry for every fea entry, using 0 for features without permissions, and cover the plan-plus-targeting case there. I'm keeping this PR scoped to exact decimal mask decoding and undeclared-bit handling rather than adding an ambiguous decoder heuristic.

— Comment generated with Codex with @jeremy-clerk's supervision

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.

👍 I'll follow-up on this with a backend PR

@dominic-clerkdominic-clerkAug 12, 2026

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified that the backend fix is merged. This PR is now updated to match the new positional contract: fixtures include 0 masks for features without permissions, and the regression covers fea = repositories,impersonation,billing with fpm = 6,0,3 through the v2 auth object and has().

— Comment generated with Codex with @jeremy-clerk's supervision

if (permissionBits[permIndex] === 1) {
orgPermissions.push(`org:${feature}:${permissions[permIndex]}`);
}
Expand Down
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/exact-jwt-permission-masks.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Ensure organization permission checks remain accurate when JWT v2 permission masks exceed JavaScript's safe integer range.
122 changes: 116 additions & 6 deletions packages/shared/src/__tests__/jwtPayloadParser.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';

import { splitByScope } from '../authorization';
import { createCheckAuthorization, splitByScope } from '../authorization';
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';

const baseClaims = {
Expand All@@ -14,6 +14,33 @@ const baseClaims = {
__raw: '',
};

const permissionNames = Array.from({ length: 54 }, (_, index) => `permission_${index}`);

const authFromFeaturePermissionMask = (fpm: string) => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:feature',
o: {
id: 'org_id',
rol: 'admin',
per: permissionNames.join(','),
fpm,
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

return { authObject, has };
};

describe('JWTPayloadToAuthObjectProperties', () => {
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
Expand DownExpand Up@@ -75,7 +102,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
);
});

test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
test('features without permissions use zero masks to preserve alignment', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
Expand All@@ -85,7 +112,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,3',
fpm: '1,3,0',
},
});

Expand DownExpand Up@@ -153,7 +180,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '3',
fpm: '3,0',
},
});

Expand DownExpand Up@@ -227,7 +254,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '1,2,3',
fpm: '1,2,3,0',
},
});

Expand All@@ -246,7 +273,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
rol: 'admin',
slg: 'org_slug',
per: 'read,create,update,delete,revoke',
fpm: '7,21',
fpm: '7,21,0',
},
});

Expand All@@ -261,6 +288,89 @@ describe('JWTPayloadToAuthObjectProperties', () => {
].sort(),
);
});

test('preserves permissions above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740993');

expect(authObject.orgPermissions).toEqual(['org:feature:permission_0', 'org:feature:permission_53']);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('does not introduce permissions when decoding a mask above the safe integer boundary', () => {
const { authObject, has } = authFromFeaturePermissionMask('9007199254740995');

expect(authObject.orgPermissions).toEqual([
'org:feature:permission_0',
'org:feature:permission_1',
'org:feature:permission_53',
]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
expect(has({ permission: 'org:feature:permission_1' })).toBe(true);
expect(has({ permission: 'org:feature:permission_2' })).toBe(false);
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
});

test('discards mask bits outside the declared permission list', () => {
const { authObject, has } = authFromFeaturePermissionMask('18014398509481984');

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:undefined' })).toBe(false);
});

test('keeps permission masks aligned when a targeted feature follows a feature without permissions', () => {
const authObject = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:repositories,o:impersonation,o:billing',
o: {
id: 'org_id',
rol: 'admin',
per: 'manage,read,update',
fpm: '6,0,3',
},
});
const has = createCheckAuthorization({
userId: authObject.userId,
orgId: authObject.orgId,
orgRole: authObject.orgRole,
orgPermissions: authObject.orgPermissions,
factorVerificationAge: authObject.factorVerificationAge,
features: null,
plans: null,
});

expect(authObject.orgPermissions).toEqual([
'org:repositories:read',
'org:repositories:update',
'org:billing:manage',
'org:billing:read',
]);
expect(has({ permission: 'org:impersonation:manage' })).toBe(false);
expect(has({ permission: 'org:billing:manage' })).toBe(true);
});

test.each([
['1', [0]],
['3', [0, 1]],
['7', [0, 1, 2]],
['21', [0, 2, 4]],
])('preserves permissions for the safe mask %s', (fpm, expectedPermissionIndexes) => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);
const expectedPermissions = expectedPermissionIndexes.map(index => `org:feature:permission_${index}`);

expect(authObject.orgPermissions).toEqual(expectedPermissions);
for (const permission of expectedPermissions) {
expect(has({ permission })).toBe(true);
}
});

test.each(['1invalid', '-1', '1.5'])('fails closed for the malformed mask %s', fpm => {
const { authObject, has } = authFromFeaturePermissionMask(fpm);

expect(authObject.orgPermissions).toEqual([]);
expect(has({ permission: 'org:feature:permission_0' })).toBe(false);
});
});

describe('splitByScope ', () => {
Expand Down
50 changes: 38 additions & 12 deletions packages/shared/src/jwtPayloadParser.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,26 +6,52 @@ import type {
SharedSignedInAuthObjectProperties,
} from './types';

const decimalToBinaryBits = (decimal: string, minimumLength: number): number[] | undefined => {
if (!/^\d+$/.test(decimal)) {
return undefined;
}

let remaining = decimal.replace(/^0+/, '') || '0';
const bits: number[] = [];

while (remaining !== '0') {
let quotient = '';
let remainder = 0;

for (let i = 0; i < remaining.length; i++) {
const value = remainder * 10 + remaining.charCodeAt(i) - 48;
const quotientDigit = Math.floor(value / 2);

if (quotient || quotientDigit !== 0) {
quotient += quotientDigit;
}
remainder = value % 2;
}

bits.push(remainder);
remaining = quotient || '0';
}

if (bits.length === 0) {
bits.push(0);
}
while (bits.length < minimumLength) {
bits.push(0);
}

return bits;
};

export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => {
if (!per || !fpm) {
return { permissions: [], featurePermissionMap: [] };
}

const permissions = per.split(',').map(p => p.trim());

// TODO: make this more efficient
const featurePermissionMap = fpm
.split(',')
.map(permission => Number.parseInt(permission.trim(), 10))
.map((permission: number) =>
permission
.toString(2)
.padStart(permissions.length, '0')
.split('')
.map(bit => Number.parseInt(bit, 10))
.reverse(),
)
.filter(Boolean);
.map(permission => decimalToBinaryBits(permission.trim(), permissions.length) ?? []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { permissions, featurePermissionMap };
};
Expand DownExpand Up@@ -62,7 +88,7 @@ function buildOrgPermissions({
continue;
}

for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) {
for (let permIndex = 0; permIndex < permissionBits.length && permIndex < permissions.length; permIndex++) {

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.

[MEDIUM] fea/fpm index misalignment attaches a feature's permission mask to the wrong feature when feature targeting is active

buildOrgPermissions assumes featurePermissionMap[i] corresponds to features[i], but the encoder does not emit a mask per feature — pkg/auth/v2.go:223 skips any feature not in featuresInPermissions, so fpm is a compacted list while fea is the full one. The two only stay aligned when the permission-bearing features happen to form a prefix of fea. When params.Plan != nil (v2.go:69-95) featureSet is seeded from plan features and targeted features are appended after, breaking that prefix invariant — a plan feature the member has no permissions on then inherits the next mask in the list, so has({ permission: 'org:<wrong-feature>:manage' }) returns true for a permission the user was never granted.

This predates the diff, but it is in the function this PR rewrites and the PR's stated goal is decoding these masks exactly — the new permIndex < permissions.length clamp bounds the inner loop without fixing the outer index mapping. Suggest having the encoder emit a mask for every entry in fea (zero for features with no permissions), or emitting the feature name alongside each mask so the decoder can key on it rather than on position.

— Comment generated with Claude with @dominic-clerk's supervision

@jeremy-clerkjeremy-clerkAug 12, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Confirmed — this is a valid pre-existing encoder issue. I reproduced the false grant through the JWT v2 auth-object conversion and has() with a permission-bearing plan feature, a plan feature without permissions, and a permission-bearing targeted feature.

There isn't enough information in the current claims for ClerkJS to repair the mapping safely: once zero masks are omitted, the decoder cannot tell whether the next compacted mask belongs to the next fea entry or a later targeted feature. Failing closed whenever fea.length !== fpm.length would also deny valid permissions for existing plan tokens.

The correct fix is in the backend: emit one fpm entry for every fea entry, using 0 for features without permissions, and cover the plan-plus-targeting case there. I'm keeping this PR scoped to exact decimal mask decoding and undeclared-bit handling rather than adding an ambiguous decoder heuristic.

— Comment generated with Codex with @jeremy-clerk's supervision

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.

👍 I'll follow-up on this with a backend PR

@dominic-clerkdominic-clerkAug 12, 2026

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified that the backend fix is merged. This PR is now updated to match the new positional contract: fixtures include 0 masks for features without permissions, and the regression covers fea = repositories,impersonation,billing with fpm = 6,0,3 through the v2 auth object and has().

— Comment generated with Codex with @jeremy-clerk's supervision

if (permissionBits[permIndex] === 1) {
orgPermissions.push(`org:${feature}:${permissions[permIndex]}`);
}
Expand Down
Loading