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/neat-keys-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Generate publishable keys with unpadded Base64 encoding to match backend output.
6 changes: 3 additions & 3 deletions packages/shared/src/__tests__/keys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import {

describe('buildPublishableKey(frontendApi)', () => {
const cases = [
['fake-clerk-test.clerk.accounts.dev', 'pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ='],
['fake-clerk-test.clerk.accounts.dev', 'pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ'],
['foo-bar-13.clerk.accounts.dev', 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk'],
['clerk.boring.sawfly-91.lcl.dev', 'pk_test_Y2xlcmsuYm9yaW5nLnNhd2ZseS05MS5sY2wuZGV2JA=='],
['clerk.boring.sawfly-91.lcl.dev', 'pk_test_Y2xlcmsuYm9yaW5nLnNhd2ZseS05MS5sY2wuZGV2JA'],
['clerk.boring.sawfly-91.lclclerk.com', 'pk_test_Y2xlcmsuYm9yaW5nLnNhd2ZseS05MS5sY2xjbGVyay5jb20k'],
];

Expand All @@ -37,7 +37,7 @@ describe('parsePublishableKey(key)', () => {
['', null],
['whatever', null],
[
'pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=',
'pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ',
{ instanceType: 'production', frontendApi: 'fake-clerk-test.clerk.accounts.dev' },
],
[
Expand Down
6 changes: 3 additions & 3 deletions packages/shared/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,17 @@ const PUBLISHABLE_KEY_TEST_PREFIX = 'pk_test_';
const PUBLISHABLE_FRONTEND_API_DEV_REGEX = /^(([a-z]+)-){2}([0-9]{1,2})\.clerk\.accounts([a-z.]*)(dev|com)$/i;

/**
* Converts a frontend API URL into a base64-encoded publishable key.
* Converts a frontend API URL into an unpadded base64-encoded publishable key.
*
* @param frontendApi - The frontend API URL (e.g., 'clerk.example.com').
* @returns A base64-encoded publishable key with appropriate prefix (pk_live_ or pk_test_).
* @returns An unpadded base64-encoded publishable key with appropriate prefix (pk_live_ or pk_test_).
*/
export function buildPublishableKey(frontendApi: string): string {
const isDevKey =
PUBLISHABLE_FRONTEND_API_DEV_REGEX.test(frontendApi) ||
(frontendApi.startsWith('clerk.') && LEGACY_DEV_INSTANCE_SUFFIXES.some(s => frontendApi.endsWith(s)));
const keyPrefix = isDevKey ? PUBLISHABLE_KEY_TEST_PREFIX : PUBLISHABLE_KEY_LIVE_PREFIX;
return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`)}`;
return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`).replace(/=+$/, '')}`;
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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether unpadded base64 is normalized before decode.
# Expected for safety: either
# 1) isomorphicAtob adds '=' padding before decoding, or
# 2) callers in keys.ts pad the encoded segment before passing to isomorphicAtob.

set -euo pipefail

echo "Locate decoder and call sites..."
fd -i 'isomorphicAtob.ts' packages/shared/src
fd -i 'keys.ts' packages/shared/src

ISO_FILE="$(fd -i 'isomorphicAtob.ts' packages/shared/src | head -n1)"
KEYS_FILE="$(fd -i 'keys.ts' packages/shared/src | head -n1)"

echo
echo "---- isomorphicAtob implementation ----"
sed -n '1,220p' "$ISO_FILE"

echo
echo "---- build/decode usage in keys.ts ----"
rg -n -C4 'buildPublishableKey|isomorphicAtob\(|split\('\'_\'\)\[2\]|replace\(/=\+\$/, \x27\x27\)' "$KEYS_FILE"

Repository: clerk/javascript

Length of output: 1057


🏁 Script executed:

#!/bin/bash
# Examine keys.ts to see how unpadded Base64 is handled in decode/parse functions

echo "=== Reading full keys.ts file ==="
cat -n packages/shared/src/keys.ts | head -100

Repository: clerk/javascript

Length of output: 4722


🏁 Script executed:

cat -n packages/shared/src/keys.ts | tail -n +100

Repository: clerk/javascript

Length of output: 8694


🏁 Script executed:

#!/bin/bash
# Search for tests of buildPublishableKey and parsePublishableKey round-trip

find . -type f \( -name '*.test.ts' -o -name '*.test.tsx' -o -name '*.spec.ts' -o -name '*.spec.tsx' \) | xargs rg -l 'buildPublishableKey|parsePublishableKey' 2>/dev/null | head -5

Repository: clerk/javascript

Length of output: 325


🏁 Script executed:

cat packages/shared/src/__tests__/keys.spec.ts

Repository: clerk/javascript

Length of output: 11820


🏁 Script executed:

python3 << 'EOF'
# Check if buildPublishableKey test output strings are valid for browser atob()
# Base64 strings must be multiple of 4 chars when using atob()

test_cases = [
    'ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ',  # fake-clerk-test...dev$
    'Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk',        # foo-bar-13...dev$
    'Y2xlcmsuYm9yaW5nLnNhd2ZseS05MS5sY2wuZGV2JA',     # clerk.boring...dev$
    'Y2xlcmsuYm9yaW5nLnNhd2ZseS05MS5sY2xjbGVyay5jb20k', # clerk.boring...com$
]

print("Checking buildPublishableKey test outputs for Base64 validity in atob():\n")
for b64_str in test_cases:
    length = len(b64_str)
    remainder = length % 4
    is_valid_for_atob = remainder == 0
    status = "✓ Valid for atob()" if is_valid_for_atob else f"✗ INVALID ({remainder} chars short of multiple of 4)"
    print(f"Length: {length:2d} ({remainder} % 4 = {remainder}) - {status}")
    print(f"  String: {b64_str}\n")
EOF

Repository: clerk/javascript

Length of output: 583


🏁 Script executed:

python3 << 'EOF'
# Verify what proper Base64 padding should be for these strings
test_cases = [
    ('ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ', 'fake-clerk-test.clerk.accounts.dev$'),
    ('Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', 'foo-bar-13.clerk.accounts.dev$'),
    ('Y2xlcmsuYm9yaW5nLnNhd2ZseS05MS5sY2wuZGV2JA', 'clerk.boring.sawfly-91.lcl.dev$'),
    ('Y2xlcmsuYm9yaW5nLnNhd2ZseS05MS5sY2xjbGVyay5jb20k', 'clerk.boring.sawfly-91.lclclerk.com$'),
]

import base64

print("Verifying Base64 encoding with proper padding:\n")
for unpadded_b64, expected_decoded in test_cases:
    length = len(unpadded_b64)
    remainder = length % 4
    
    # Add proper padding
    padding_needed = (4 - remainder) % 4
    padded_b64 = unpadded_b64 + ('=' * padding_needed)
    
    # Try to decode
    try:
        decoded = base64.b64decode(padded_b64).decode('utf-8')
        match = "✓" if decoded == expected_decoded else "✗"
        print(f"{match} Unpadded: {unpadded_b64}")
        print(f"   Padded:   {padded_b64}")
        print(f"   Decoded:  {decoded}")
        print()
    except Exception as e:
        print(f"✗ Failed to decode: {e}")
        print()
EOF

Repository: clerk/javascript

Length of output: 765


Critical: Unpadded Base64 keys will fail in browser environments.

Line 43 strips all Base64 padding via .replace(/=+$/, ''), but isomorphicAtob passes the unpadded string directly to browser atob(), which requires length to be a multiple of 4. Some keys (e.g., those with decoded length not divisible by 4) will throw in browsers—parsePublishableKey/isPublishableKey will fail for valid keys generated by buildPublishableKey.

Tests pass in Node.js because Buffer.from() accepts unpadded Base64, but browser atob() is stricter. Either restore padding before decode or do not strip padding on encode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/keys.ts` at line 43, The Base64 padding is being stripped
in buildPublishableKey (the line using isomorphicBtoa(...).replace(/=+$/, ''))
which breaks browser atob() used by isomorphicAtob and thus
parsePublishableKey/isPublishableKey; either stop removing the '=' padding when
encoding in buildPublishableKey or, if you must strip it, update
isomorphicAtob/parsePublishableKey to restore padding to a multiple of 4 before
calling atob()/Buffer. Modify the code paths referencing
isomorphicBtoa/isomorphicAtob, buildPublishableKey, parsePublishableKey and
isPublishableKey so decoding always receives properly padded Base64 (restore
padding via '=' until length % 4 === 0) or retain padding on encode.

}

/**
Expand Down
Loading