Skip to content

Commit 7efa558

Browse files
authored
fix(browser): prevent silent identity switch during bootstrap and auto-identify anonymous users (#3247)
* fix(browser): prevent silent identity switch during bootstrap and auto-identify anonymous users * add changeset * address review comments: remove redundant mockRestore, parameterize tests, fix comments * fix: use isUndefined() instead of direct undefined check
1 parent f4e5889 commit 7efa558

File tree

3 files changed

+186
-7
lines changed

3 files changed

+186
-7
lines changed

.changeset/empty-ants-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': minor
3+
---
4+
5+
prevent silent identity switch during bootstrap and auto-identify anonymous users

packages/browser/src/__tests__/posthog-core-also.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mockLogger } from './helpers/mock-logger'
33
import * as globals from '../utils/globals'
44
import { document, window } from '../utils/globals'
55
import { uuidv7 } from '../uuidv7'
6+
import { isUndefined } from '@posthog/core'
67
import { ENABLE_PERSON_PROCESSING, USER_STATE } from '../constants'
78
import { createPosthogInstance, defaultPostHog } from './helpers/posthog-instance'
89
import { PostHogConfig, RemoteConfig } from '../types'
@@ -794,6 +795,7 @@ describe('posthog core', () => {
794795
it('sets the right distinctID', () => {
795796
const posthog = posthogWith(
796797
{
798+
token: 'bootstrap-distinctid-' + uuidv7(),
797799
bootstrap: {
798800
distinctID: 'abcd',
799801
},
@@ -820,6 +822,7 @@ describe('posthog core', () => {
820822
it('treats identified distinctIDs appropriately', () => {
821823
const posthog = posthogWith(
822824
{
825+
token: 'bootstrap-identified-' + uuidv7(),
823826
bootstrap: {
824827
distinctID: 'abcd',
825828
isIdentifiedID: true,
@@ -942,6 +945,141 @@ describe('posthog core', () => {
942945
posthog.featureFlags.onFeatureFlags(() => (called = true))
943946
expect(called).toEqual(false)
944947
})
948+
949+
describe('auto-identify on bootstrap', () => {
950+
afterEach(() => {
951+
jest.restoreAllMocks()
952+
})
953+
954+
it('calls identify when bootstrap has identified distinctID that differs from persisted anonymous ID', () => {
955+
const token = 'auto-identify-test-' + uuidv7()
956+
957+
// First instance creates an anonymous user in persistence
958+
const first = posthogWith({ token })
959+
expect(first.get_distinct_id()).toBeTruthy()
960+
expect(first.persistence.get_property(USER_STATE)).toBe('anonymous')
961+
962+
const identifySpy = jest.spyOn(PostHog.prototype, 'identify')
963+
const captureSpy = jest.spyOn(PostHog.prototype, 'capture')
964+
965+
// Second instance bootstraps with an identified user
966+
const second = posthogWith({
967+
token,
968+
bootstrap: {
969+
distinctID: 'user-123',
970+
isIdentifiedID: true,
971+
},
972+
})
973+
974+
expect(identifySpy).toHaveBeenCalledWith('user-123')
975+
expect(second.get_distinct_id()).toBe('user-123')
976+
expect(second.persistence.get_property(USER_STATE)).toBe('identified')
977+
978+
// Verify the $identify event includes the anonymous-to-identified mapping
979+
const captureCall = captureSpy.mock.calls.find((call) => call[0] === '$identify')
980+
expect(captureCall).toBeDefined()
981+
expect(captureCall![1]).toMatchObject({
982+
distinct_id: 'user-123',
983+
$anon_distinct_id: expect.any(String),
984+
})
985+
986+
// Subsequent identify with the same ID should be a no-op
987+
identifySpy.mockClear()
988+
second.identify('user-123')
989+
// identify is called but since distinct_id matches, no $identify event fires
990+
expect(second.get_distinct_id()).toBe('user-123')
991+
})
992+
993+
it('does not call identify when bootstrap distinctID matches persisted ID', () => {
994+
const token = 'auto-identify-same-' + uuidv7()
995+
996+
// First instance creates an anonymous user
997+
const first = posthogWith({ token })
998+
const anonId = first.get_distinct_id()
999+
1000+
const identifySpy = jest.spyOn(PostHog.prototype, 'identify')
1001+
1002+
// Second instance bootstraps with the same anonymous ID
1003+
posthogWith({
1004+
token,
1005+
bootstrap: {
1006+
distinctID: anonId,
1007+
isIdentifiedID: true,
1008+
},
1009+
})
1010+
1011+
expect(identifySpy).not.toHaveBeenCalled()
1012+
})
1013+
1014+
it.each([
1015+
{ isIdentifiedID: false, description: 'false' },
1016+
{ isIdentifiedID: undefined, description: 'omitted' },
1017+
])('does not call identify when isIdentifiedID is $description', ({ isIdentifiedID }) => {
1018+
const token = 'auto-identify-non-true-' + uuidv7()
1019+
1020+
// First instance creates an anonymous user
1021+
posthogWith({ token })
1022+
1023+
const identifySpy = jest.spyOn(PostHog.prototype, 'identify')
1024+
1025+
// Second instance bootstraps with isIdentifiedID that is not true
1026+
posthogWith({
1027+
token,
1028+
bootstrap: {
1029+
distinctID: 'user-456',
1030+
...(!isUndefined(isIdentifiedID) && { isIdentifiedID }),
1031+
},
1032+
})
1033+
1034+
expect(identifySpy).not.toHaveBeenCalled()
1035+
})
1036+
1037+
it('does not call identify when there is no existing persisted ID (first visit)', () => {
1038+
const token = 'auto-identify-first-visit-' + uuidv7()
1039+
1040+
const identifySpy = jest.spyOn(PostHog.prototype, 'identify')
1041+
1042+
// First visit with bootstrap - no prior persistence
1043+
const posthog = posthogWith({
1044+
token,
1045+
bootstrap: {
1046+
distinctID: 'user-789',
1047+
isIdentifiedID: true,
1048+
},
1049+
})
1050+
1051+
expect(identifySpy).not.toHaveBeenCalled()
1052+
expect(posthog.get_distinct_id()).toBe('user-789')
1053+
expect(posthog.persistence.get_property(USER_STATE)).toBe('identified')
1054+
})
1055+
1056+
it('does not call identify when existing user is already identified', () => {
1057+
const token = 'auto-identify-already-id-' + uuidv7()
1058+
1059+
// First instance: create and identify a user
1060+
const first = posthogWith({ token }, { capture: jest.fn() })
1061+
first.identify('existing-user')
1062+
expect(first.persistence.get_property(USER_STATE)).toBe('identified')
1063+
1064+
const identifySpy = jest.spyOn(PostHog.prototype, 'identify')
1065+
1066+
// Second instance bootstraps with a different identified user
1067+
const second = posthogWith({
1068+
token,
1069+
bootstrap: {
1070+
distinctID: 'new-user',
1071+
isIdentifiedID: true,
1072+
},
1073+
})
1074+
1075+
// Should NOT auto-identify because user was already identified
1076+
expect(identifySpy).not.toHaveBeenCalled()
1077+
1078+
// Existing identity should be preserved (bootstrap should NOT silently switch identities)
1079+
expect(second.get_distinct_id()).toBe('existing-user')
1080+
expect(second.persistence.get_property(USER_STATE)).toBe('identified')
1081+
})
1082+
})
9451083
})
9461084

9471085
describe('init()', () => {

packages/browser/src/posthog-core.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -633,13 +633,49 @@ export class PostHog implements PostHogInterface {
633633
// isUndefined doesn't provide typehint here so wouldn't reduce bundle as we'd need to assign
634634
// eslint-disable-next-line posthog-js/no-direct-undefined-check
635635
if (config.bootstrap?.distinctID !== undefined) {
636-
const uuid = this.config.get_device_id(uuidv7())
637-
const deviceID = config.bootstrap?.isIdentifiedID ? uuid : config.bootstrap.distinctID
638-
this.persistence.set_property(USER_STATE, config.bootstrap?.isIdentifiedID ? 'identified' : 'anonymous')
639-
this.register({
640-
distinct_id: config.bootstrap.distinctID,
641-
$device_id: deviceID,
642-
})
636+
const bootstrapDistinctId = config.bootstrap.distinctID
637+
const existingDistinctId = this.get_distinct_id()
638+
const existingUserState = this.persistence.get_property(USER_STATE)
639+
640+
if (
641+
config.bootstrap.isIdentifiedID &&
642+
existingDistinctId != null &&
643+
existingDistinctId !== bootstrapDistinctId &&
644+
existingUserState === 'anonymous'
645+
) {
646+
// The server bootstrapped identity for an identified user, but local persistence
647+
// still has an anonymous ID from a previous session. Calling identify() merges
648+
// the anonymous user into the identified user, ensuring consistent identity
649+
// for feature flag evaluation and preventing duplicate $feature_flag_called events.
650+
//
651+
// Note: this runs during _init(), before _loaded() enables the request queue.
652+
// The $identify event is enqueued and flushed once the queue starts. The
653+
// reloadFeatureFlags() call inside identify() sets _reloadDebouncer, so the
654+
// subsequent ensureFlagsLoaded() from _onRemoteConfig is a no-op (no double request).
655+
this.identify(bootstrapDistinctId)
656+
} else if (
657+
config.bootstrap.isIdentifiedID &&
658+
existingDistinctId != null &&
659+
existingDistinctId !== bootstrapDistinctId &&
660+
existingUserState === 'identified'
661+
) {
662+
// The existing user is already identified with a different ID. Silently
663+
// switching identities without an $identify event would corrupt analytics.
664+
// Preserve the existing identity and log a warning.
665+
logger.warn(
666+
'Bootstrap distinctID differs from an already-identified user. ' +
667+
'The existing identity is preserved. Call reset() before reinitializing ' +
668+
'if you intend to switch users.'
669+
)
670+
} else {
671+
const uuid = this.config.get_device_id(uuidv7())
672+
const deviceID = config.bootstrap.isIdentifiedID ? uuid : bootstrapDistinctId
673+
this.persistence.set_property(USER_STATE, config.bootstrap.isIdentifiedID ? 'identified' : 'anonymous')
674+
this.register({
675+
distinct_id: bootstrapDistinctId,
676+
$device_id: deviceID,
677+
})
678+
}
643679
}
644680

645681
if (startInCookielessMode) {

0 commit comments

Comments
 (0)