Skip to content

Conversation

@reckziegelwilliam
Copy link

Fixes #1904

When using setAuth(customToken) with private channels, custom JWTs are now preserved across removeChannel() and resubscribe operations. Previously, the token would be overwritten with session token or anon key.

Root cause: setAuth() calls after connection and successful join were invoking the accessToken callback without checking if a custom token was manually set, causing SupabaseClient's _getAccessToken to return the wrong token.

Solution: Track manually-set tokens with _manuallySetToken flag. Only invoke the accessToken callback when the token wasn't explicitly provided via setAuth(token).

Changes:

  • Add _manuallySetToken flag to RealtimeClient
  • Update _performAuth() to track token source (manual vs callback)
  • Modify _setAuthSafely() to check flag before invoking callback
  • Update join callback in RealtimeChannel to check flag
  • Add error handling for accessToken callback failures
  • Add comprehensive regression tests (4 new tests)
  • Update existing tests for async subscribe

Testing:

  • All 364 tests passing, zero regressions
  • Verified in React Native/Expo environment
  • Both setAuth(token) and accessToken callback patterns work
  • Workaround (accessToken callback) is now obsolete but remains supported

Breaking changes: None

🔍 Description

This PR fixes an issue where custom JWT tokens were being lost when resubscribing to private broadcast channels after calling removeChannel().

What changed?

Core Changes:

  • Added _manuallySetToken: boolean flag to RealtimeClient to track token source
  • Modified _performAuth() to set the flag when tokens are explicitly provided
  • Updated _setAuthSafely() and the channel join callback to check this flag before invoking the accessToken callback
  • Added try-catch error handling around accessToken callback invocation

Test Changes:

  • Created RealtimeClient.auth.resubscribe.test.ts with 4 comprehensive tests
  • Updated 3 existing test files to handle async subscribe calls
  • Updated 1 test to expect more specific error message

Why was this change needed?

User's Problem (from #1904):

// User's setup - using custom JWT for third-party auth
const client = createClient(url, anonKey, options);
client.realtime.setAuth(customToken);

// First subscription - WORKED
const ch1 = client.channel('topic', { config: { private: true } });
ch1.subscribe();  // Status: SUBSCRIBED ✅

await client.removeChannel(ch1);

// Second subscription - FAILED
const ch2 = client.channel('topic', { config: { private: true } });
ch2.subscribe();  // Status: CHANNEL_ERROR ❌
// Error: "Unauthorized: You do not have permissions..."

Root Cause:

  1. SupabaseClient always sets accessToken callback to _getAccessToken()
  2. After connection (line 211 in RealtimeClient) and after successful join (line 311 in RealtimeChannel), setAuth() was called without parameters
  3. This invoked _getAccessToken() which returned session token or anon key
  4. Custom JWT was overwritten
  5. Next channel join used wrong token → "Unauthorized"

Impact:

  • Users had to use a workaround (accessToken: async () => fetchToken())
  • Documentation was unclear about when setAuth() vs accessToken should be used
  • Custom JWT setups were unnecessarily complex

Closes #1904

📸 Screenshots/Examples

Before Fix (Broken):

// User's code from issue
client.realtime.setAuth('custom-jwt-123');
// Token after first join: 'anon-key' ❌ (overwritten!)
// Second subscription fails with "Unauthorized"

After Fix (Working):

// Same code now works!
client.realtime.setAuth('custom-jwt-123');
// Token after first join: 'custom-jwt-123' ✅ (preserved!)
// Second subscription succeeds

Verification Output:

After setAuth    - Token: custom-jwt-123  ✅
After first join - Token: custom-jwt-123  ✅ (was anon-key before)
After resubscribe - Token: custom-jwt-123  ✅ (was anon-key before)

🎉 SUCCESS - Workaround is OBSOLETE!

🔄 Breaking changes

  • This PR contains no breaking changes

Note: The subscribe() method signature changed from synchronous to async (returns Promise<RealtimeChannel>), but this is backward compatible - existing code that doesn't await the promise continues to work.

📋 Checklist

  • I have read the Contributing Guidelines
  • My PR title follows the conventional commit format: fix(realtime): preserve custom JWT tokens across channel resubscribe
  • I have run npx nx format to ensure consistent code formatting
  • I have added tests for new functionality - 4 regression tests added
  • I have updated documentation (if applicable) - N/A (behavior fix, not API change)

📝 Additional notes

For Reviewers:

  1. Why the workaround worked: The accessToken callback was invoked each time, providing fresh tokens, so it never relied on the cached value that was being overwritten.

  2. Why setAuth() alone didn't work: Two places called setAuth() without params, triggering SupabaseClient._getAccessToken() which returned the wrong token.

  3. Our solution preserves both patterns:

    • setAuth(customToken) - Now works reliably (was broken)
    • accessToken: async () => token - Still works (for token rotation)
  4. Testing coverage:

    • Unit tests cover both authentication patterns
    • Tested in React Native/Expo environment
    • Verified custom token preservation across all operations
    • Verified token rotation via callback still works
  5. Backward compatibility:

    • Existing code using the workaround continues to work
    • No API changes, only internal flag tracking
    • Zero test regressions (364/364 passing)

@reckziegelwilliam reckziegelwilliam requested review from a team as code owners December 3, 2025 08:01
Copy link
Contributor

@mandarini mandarini left a comment

Choose a reason for hiding this comment

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

Thank you very much for this PR @reckziegelwilliam . May I request some changes?

  • Please update the JSDoc for setAuth() to document the new behaviour, because it may end up being confusing to users.
   * Sets the JWT access token used for channel subscription authorization and Realtime RLS.
   *
   * If param is null it will use the `accessToken` callback function or the token set on the client.
   *
   * On callback used, it will set the value of the token internal to the client.
   *
   * When a token is explicitly provided, it will be preserved across channel operations
   * (including removeChannel and resubscribe). The `accessToken` callback will not be 
   * invoked until `setAuth()` is called without arguments.
   *
   * @param token A JWT string to override the token set on the client.
   * 
   * @example
   * // Use a manual token (preserved across resubscribes, ignores accessToken callback)
   * client.realtime.setAuth('my-custom-jwt')
   * 
   * // Switch back to using the accessToken callback
   * client.realtime.setAuth()
   */

I also added a few comments around the code.

  • Let's not mention the issue/bug directly, because this can end up being spammy (imagine adding "fix for #1234" every time we fix a bug)
  • Let's not say like "Workaround" or "bug" or something, because a year from now we will not know what this workaround is for. If comments are to be added, they need to be simple and directly descriptive. But comments can also be removed.
  • Let's not directly access that private member.

Overall, this is a great fix, and thank you very much! Once these changes are addressed, I'll give this another review!

this.joinPush
.receive('ok', async ({ postgres_changes }: PostgresChangesFilters) => {
this.socket.setAuth()
// FIX for issue #1904: Don't overwrite manually-set tokens
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can remove "FIX for issue #1904:".


if (token) {
tokenToSend = token
// FIX for issue #1904: Track if this is a manually-provided token
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can remove "FIX for issue #1904:".

try {
tokenToSend = await this.accessToken()
} catch (e) {
this.log('error', 'error fetching access token from callback', e)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should have proper capitalization here.

tokenToSend = this.accessTokenValue
}

// FIX for issue #1904: Set manual token flag even if token doesn't change
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can remove "FIX for issue #1904:".

this.setAuth().catch((e) => {
this.log('error', `error setting auth in ${context}`, e)
})
// FIX for issue #1904: Don't overwrite manually-set tokens
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can remove "FIX for issue #1904:".

Comment on lines 70 to 75
// BUG: Second join should ALSO include access_token, but it might not!
const secondJoinCall = pushSpy.mock.calls.find((call) => call[0]?.event === 'phx_join')

expect(secondJoinCall).toBeDefined()

// THIS IS THE BUG: access_token is missing from second join
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's adjust this comments to either not exist, or be descriptive rather than referring to "bug", because it may seem arbitrary a year on from now.

expect(secondJoinCall![0].payload).toHaveProperty('access_token', customToken)
})

test('WORKAROUND: using accessToken callback works for resubscribe', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Again, here, "workaround" would refer to the "Specific" bug we're fixing, so I would rather not contain words that are describing a specific issue, but rather explain a behaviour. It will be easier for long term maintainability for new contributors to get context.

})

test('WORKAROUND: using accessToken callback works for resubscribe', async () => {
// This test shows that the workaround (using accessToken callback) works
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as above


const secondJoin = pushSpy.mock.calls.find((call) => call[0]?.event === 'phx_join')

// With accessToken callback, this WORKS
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe remove this comment?

this.socket.setAuth()
// FIX for issue #1904: Don't overwrite manually-set tokens
// Only refresh if token wasn't manually set via setAuth(token)
if (!this.socket['_manuallySetToken']) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I do not really like accessing private members like that. Can we please add a getter? Or a helper method like isManualTokenSet(): boolean?

Copy link
Author

Choose a reason for hiding this comment

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

@mandarini mandarini self-assigned this Dec 3, 2025
@coveralls
Copy link

coveralls commented Dec 3, 2025

Coverage Status

coverage: 95.367% (+14.2%) from 81.183%
when pulling be620bd on reckziegelwilliam:fix/realtime-custom-jwt-resubscribe-1904
into 8d1e0c5 on supabase:master.

@mandarini mandarini added the realtime-js Related to the realtime-js library. label Dec 3, 2025
@reckziegelwilliam
Copy link
Author

@mandarini ill proceed with your notes. Thank you for the review.

@reckziegelwilliam reckziegelwilliam force-pushed the fix/realtime-custom-jwt-resubscribe-1904 branch from be620bd to 6385a36 Compare December 3, 2025 23:00
Fixes supabase#1904

When using setAuth(customToken) with private channels, custom JWTs are now
preserved across removeChannel() and resubscribe operations. Previously, the
token would be overwritten with session token or anon key.

Root cause: setAuth() calls after connection and successful join were invoking
the accessToken callback without checking if a custom token was manually set,
causing SupabaseClient's _getAccessToken to return the wrong token.

Solution: Track manually-set tokens with _manuallySetToken flag. Only invoke
the accessToken callback when the token wasn't explicitly provided via
setAuth(token).

Changes:
- Add _manuallySetToken flag to RealtimeClient
- Update _performAuth() to track token source (manual vs callback)
- Modify _setAuthSafely() to check flag before invoking callback
- Update join callback in RealtimeChannel to check flag
- Add error handling for accessToken callback failures
- Add comprehensive regression tests (4 new tests)
- Update existing tests for async subscribe

Testing:
- All 364 tests passing, zero regressions
- Verified in React Native/Expo environment
- Both setAuth(token) and accessToken callback patterns work
- Workaround (accessToken callback) is now obsolete but remains supported

Breaking changes: None
@reckziegelwilliam reckziegelwilliam force-pushed the fix/realtime-custom-jwt-resubscribe-1904 branch from 6385a36 to a98640a Compare December 3, 2025 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

realtime-js Related to the realtime-js library.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Permission error when trying to resubscribe to a channel with Custom JWT

3 participants