Skip to content

feat(intercom): Closing intercom session on org change#112549

Merged
sentaur-athena merged 2 commits intomasterfrom
athena/intercom-logout
Apr 9, 2026
Merged

feat(intercom): Closing intercom session on org change#112549
sentaur-athena merged 2 commits intomasterfrom
athena/intercom-logout

Conversation

@sentaur-athena
Copy link
Copy Markdown
Member

When user changes or org changes we need to call intercom authentication API again. Each user<>org pair represents an intercom user and we don't want the same user in org A has access to their chats on org B.

@sentaur-athena sentaur-athena requested a review from a team as a code owner April 8, 2026 22:33
@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Apr 8, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Shutdown ignores in-flight boot causing cross-org session leak
    • I confirmed the race was real and fixed it by tracking the booting org, invalidating in-flight boots during shutdown/org changes, and preventing stale boot promises from showing Intercom for the wrong org.

Create PR

Or push these changes by commenting:

@cursor push 66d79886a2
Preview (66d79886a2)
diff --git a/static/app/utils/intercom.tsx b/static/app/utils/intercom.tsx
--- a/static/app/utils/intercom.tsx
+++ b/static/app/utils/intercom.tsx
@@ -24,6 +24,7 @@
 
 let hasBooted = false;
 let bootPromise: Promise<void> | null = null;
+let bootingOrgSlug: string | null = null;
 let bootedOrgSlug: string | null = null;
 
 /**
@@ -31,16 +32,21 @@
  * Only fetches JWT and boots on first call.
  */
 async function initIntercom(orgSlug: string): Promise<void> {
-  if (hasBooted) {
+  if (hasBooted && bootedOrgSlug === orgSlug) {
     return;
   }
 
-  // Prevent concurrent initialization
-  if (bootPromise) {
+  // Prevent concurrent initialization for the same organization
+  if (bootPromise && bootingOrgSlug === orgSlug) {
     return bootPromise;
   }
 
-  bootPromise = (async () => {
+  // If a different org is currently booting, invalidate it first.
+  if (bootPromise && bootingOrgSlug !== orgSlug) {
+    await shutdownIntercom();
+  }
+
+  const currentBootPromise = (async () => {
     try {
       const intercomAppId = ConfigStore.get('intercomAppId');
       if (!intercomAppId) {
@@ -55,6 +61,12 @@
 
       // Boot Intercom with user data
       const {default: Intercom} = await import('@intercom/messenger-js-sdk');
+
+      // Boot may have been invalidated while async work was in-flight.
+      if (bootPromise !== currentBootPromise || bootingOrgSlug !== orgSlug) {
+        return;
+      }
+
       Intercom({
         app_id: intercomAppId,
         user_id: jwtData.userData.userId,
@@ -71,14 +83,25 @@
 
       hasBooted = true;
       bootedOrgSlug = orgSlug;
+
+      if (bootPromise === currentBootPromise) {
+        bootPromise = null;
+        bootingOrgSlug = null;
+      }
     } catch (error) {
-      // Reset so user can retry on next click
-      bootPromise = null;
-      throw error;
+      // Reset so user can retry on next click.
+      if (bootPromise === currentBootPromise) {
+        bootPromise = null;
+        bootingOrgSlug = null;
+        throw error;
+      }
     }
   })();
 
-  return bootPromise;
+  bootPromise = currentBootPromise;
+  bootingOrgSlug = orgSlug;
+
+  return currentBootPromise;
 }
 
 /**
@@ -86,6 +109,10 @@
  * Call this when user logs out or switches organizations.
  */
 export async function shutdownIntercom(): Promise<void> {
+  // Invalidate any in-flight boot immediately.
+  bootPromise = null;
+  bootingOrgSlug = null;
+
   if (!hasBooted) {
     return;
   }
@@ -105,11 +132,18 @@
  */
 export async function showIntercom(orgSlug: string): Promise<void> {
   // If booted for a different org, shutdown first to re-initialize with new org context
-  if (hasBooted && bootedOrgSlug !== orgSlug) {
+  if (
+    (hasBooted && bootedOrgSlug !== orgSlug) ||
+    (bootPromise && bootingOrgSlug !== orgSlug)
+  ) {
     await shutdownIntercom();
   }
 
   await initIntercom(orgSlug);
+  if (!hasBooted || bootedOrgSlug !== orgSlug) {
+    return;
+  }
+
   const {show} = await import('@intercom/messenger-js-sdk');
   show();
 }

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 27a607c. Configure here.

hasBooted = false;
bootPromise = null;
bootedOrgSlug = null;
}
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.

Shutdown ignores in-flight boot causing cross-org session leak

High Severity

shutdownIntercom only checks hasBooted but ignores a pending bootPromise. If a user switches orgs while Intercom is still booting (e.g., JWT fetch in progress), hasBooted is false, so shutdown is a no-op. The boot then completes for the old org, creating the exact cross-org session leak this PR intends to prevent. The same gap exists in showIntercom: the guard hasBooted && bootedOrgSlug !== orgSlug misses the in-progress case, and initIntercom would reuse the stale bootPromise for the wrong org.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 27a607c. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@scttcper is this actually possible? I would think that hasBooted is always true if we got a bootPromise.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i guess. Start the request -> switch orgs -> jwtData comes back for the previous org or something

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

doesn't really seem like it would be that bad, i'm not sure switching orgs and contacting support is the most common thing

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@sentaur-athena actually thinking about this more, there's no way for this to happen for us because we make them switch subdomains and its a full page reload

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for checking. I can address this but it would make the code extremely confusing and dirty. Will merge as is.

@sentaur-athena sentaur-athena merged commit 65bf4f0 into master Apr 9, 2026
64 checks passed
@sentaur-athena sentaur-athena deleted the athena/intercom-logout branch April 9, 2026 21:12
george-sentry pushed a commit that referenced this pull request Apr 9, 2026
When user changes or org changes we need to call intercom authentication
API again. Each user<>org pair represents an intercom user and we don't
want the same user in org A has access to their chats on org B.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants