Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/strict-identify-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog-js': minor
'@posthog/types': minor
---

Add strict_identify config option to throw on invalid distinct IDs in posthog.identify(). Defaults to true when using defaults >= '2026-04-01'. When disabled, invalid calls now log a console error instead of being silently ignored.
78 changes: 78 additions & 0 deletions packages/browser/src/__tests__/identify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,84 @@ describe('identify', () => {
)
})

describe('invalid distinct_id', () => {
it.each([
['undefined', undefined, 'Unique user id has not been set in posthog.identify'],
['null', null, 'Unique user id has not been set in posthog.identify'],
['empty string', '', 'Unique user id has not been set in posthog.identify'],
['false', false, 'Unique user id has not been set in posthog.identify'],
])('should reject %s and log a critical error', async (_label, invalidId, expectedMessage) => {
const token = uuidv7()
const beforeSendMock = jest.fn().mockImplementation((e) => e)
const posthog = await createPosthogInstance(token, { before_send: beforeSendMock })

posthog.identify(invalidId as any)

expect(beforeSendMock).not.toHaveBeenCalled()
expect(mockLogger.critical).toHaveBeenCalledWith(expectedMessage)
})

it.each([
['the string "undefined"', 'undefined'],
['the string "null"', 'null'],
])('should accept %s as a distinct_id without strict_identify', async (_label, coercedId) => {
const token = uuidv7()
const beforeSendMock = jest.fn().mockImplementation((e) => e)
const posthog = await createPosthogInstance(token, { before_send: beforeSendMock })

posthog.identify(coercedId)

expect(beforeSendMock).toHaveBeenCalled()
expect(mockLogger.critical).not.toHaveBeenCalled()
})

it.each([
['undefined', undefined, 'Unique user id has not been set in posthog.identify'],
['null', null, 'Unique user id has not been set in posthog.identify'],
['empty string', '', 'Unique user id has not been set in posthog.identify'],
['false', false, 'Unique user id has not been set in posthog.identify'],
[
'the string "undefined"',
'undefined',
'The string "undefined" was set in posthog.identify which indicates an error. This ID should be unique to the user and not a hardcoded string.',
],
[
'the string "null"',
'null',
'The string "null" was set in posthog.identify which indicates an error. This ID should be unique to the user and not a hardcoded string.',
],
])('should reject %s and throw when strict_identify is true', async (_label, invalidId, expectedMessage) => {
const token = uuidv7()
const beforeSendMock = jest.fn().mockImplementation((e) => e)
const posthog = await createPosthogInstance(token, {
before_send: beforeSendMock,
strict_identify: true,
})

expect(() => posthog.identify(invalidId as any)).toThrow('[PostHog.js] ' + expectedMessage)
expect(beforeSendMock).not.toHaveBeenCalled()
})

it('should default strict_identify to true with the 2026-04-01 defaults', async () => {
const token = uuidv7()
const posthog = await createPosthogInstance(token, {
before_send: (cr) => cr,
defaults: '2026-04-01',
internal_or_test_user_hostname: null,
})

expect(posthog.config.strict_identify).toBe(true)
expect(() => posthog.identify('undefined')).toThrow('[PostHog.js]')
})

it('should default strict_identify to false without the 2026-04-01 defaults', async () => {
const token = uuidv7()
const posthog = await createPosthogInstance(token, { before_send: (cr) => cr })

expect(posthog.config.strict_identify).toBe(false)
})
})

it('should send $is_identified = true with the identify event and following events', async () => {
// arrange
const token = uuidv7()
Expand Down
47 changes: 31 additions & 16 deletions packages/browser/src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
extend,
isCrossDomainCookie,
migrateConfigField,
PostHogConfigError,
safewrapClass,
} from './utils'
import { isLikelyBot } from './utils/blocked-uas'
Expand Down Expand Up @@ -168,12 +169,14 @@
| 'session_recording'
| 'external_scripts_inject_target'
| 'internal_or_test_user_hostname'
| 'strict_identify'
> => ({
rageclick: defaults && defaults >= '2025-11-30' ? { content_ignorelist: true } : true,
capture_pageview: defaults && defaults >= '2025-05-24' ? 'history_change' : true,
session_recording: defaults && defaults >= '2025-11-30' ? { strictMinimumDuration: true } : {},
external_scripts_inject_target: defaults && defaults >= '2026-01-30' ? 'head' : 'body',
internal_or_test_user_hostname: defaults && defaults >= '2026-01-30' ? /^(localhost|127\.0\.0\.1)$/ : undefined,
strict_identify: defaults && defaults >= '2026-04-01' ? true : false,
})

// NOTE: Remember to update `types.ts` when changing a default value
Expand Down Expand Up @@ -2280,6 +2283,31 @@
)
}

private _validateIdentifyId(id: string | undefined): string | undefined {
if (!id) {
return 'Unique user id has not been set in posthog.identify'
}
let reason: string | undefined
if (id === COOKIELESS_SENTINEL_VALUE) {
reason = 'This ID is only used as a sentinel value.'
} else if (
isDistinctIdStringLike(id) ||
(this.config.strict_identify && ['undefined', 'null'].includes(id.toLowerCase()))
) {
reason = 'This ID should be unique to the user and not a hardcoded string.'
}
return reason
? `The string "${id}" was set in posthog.identify which indicates an error. ${reason}`
: undefined
Comment on lines +2299 to +2301
Copy link

Choose a reason for hiding this comment

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

This multi-line ternary expression with template literal needs to be reformatted to match Prettier's style rules. Either put it on a single line or restructure the logic to avoid the formatting conflict.

Spotted by Graphite (based on CI logs)

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

}

private _identifyError(message: string): void {
if (this.config.strict_identify) {
throw new PostHogConfigError('[PostHog.js] ' + message)
}
logger.critical(message)
}

Check failure on line 2309 in packages/browser/src/posthog-core.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `⏎············?·`The·string·"${id}"·was·set·in·posthog.identify·which·indicates·an·error.·${reason}`⏎···········` with `·?·`The·string·"${id}"·was·set·in·posthog.identify·which·indicates·an·error.·${reason}``

/**
* Associates a user with a unique identifier instead of an auto-generated ID.
* Learn more about [identifying users](/docs/product-analytics/identify)
Expand Down Expand Up @@ -2332,22 +2360,9 @@
)
}

//if the new_distinct_id has not been set ignore the identify event
if (!new_distinct_id) {
logger.error('Unique user id has not been set in posthog.identify')
return
}

if (isDistinctIdStringLike(new_distinct_id)) {
logger.critical(
`The string "${new_distinct_id}" was set in posthog.identify which indicates an error. This ID should be unique to the user and not a hardcoded string.`
)
return
}
if (new_distinct_id === COOKIELESS_SENTINEL_VALUE) {
logger.critical(
`The string "${COOKIELESS_SENTINEL_VALUE}" was set in posthog.identify which indicates an error. This ID is only used as a sentinel value.`
)
const identifyError = this._validateIdentifyId(new_distinct_id)
if (identifyError || !new_distinct_id) {
this._identifyError(identifyError ?? 'Unique user id has not been set in posthog.identify')
return
}

Expand Down
5 changes: 5 additions & 0 deletions packages/browser/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,18 @@ export const trySafe = function <T>(fn: () => T): T | undefined {
}
}

export class PostHogConfigError extends Error {}

export const safewrap = function <F extends (...args: any[]) => any = (...args: any[]) => any>(f: F): F {
return function (...args) {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return f.apply(this, args)
} catch (e) {
if (e instanceof PostHogConfigError) {
throw e
}
logger.critical(
'Implementation error. Please turn on debug mode and open a ticket on https://app.posthog.com/home#panel=support%3Asupport%3A.'
)
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/terser-mangled-names.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@
"_hasPassedMinimumDuration",
"_hasPersonProcessing",
"_hasProcessedRestoreToken",
"_identifyError",
"_ignoreClick",
"_initExtensions",
"_initialPageviewCaptured",
Expand Down Expand Up @@ -556,6 +557,7 @@
"_urlTriggerStatus",
"_urlTriggers",
"_validateEmail",
"_validateIdentifyId",
"_validateSampleRate",
"_visibilityChangeListener",
"_visibilityStateListener",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,16 @@ exports[`config snapshot for PostHogConfig 1`] = `
],
"properties_string_max_length": "number",
"defaults": [
"\\"2026-04-01\\"",
"\\"2026-01-30\\"",
"\\"2025-11-30\\"",
"\\"2025-05-24\\"",
"\\"unset\\""
],
"strict_identify": [
"false",
"true"
],
"__preview_deferred_init_extensions": [
"false",
"true"
Expand Down
12 changes: 11 additions & 1 deletion packages/types/src/posthog-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ export interface HeatmapConfig {
flush_interval_milliseconds: number
}

export type ConfigDefaults = '2026-01-30' | '2025-11-30' | '2025-05-24' | 'unset'
export type ConfigDefaults = '2026-04-01' | '2026-01-30' | '2025-11-30' | '2025-05-24' | 'unset'

export type ExternalIntegrationKind = 'intercom' | 'crispChat'

Expand Down Expand Up @@ -1014,11 +1014,21 @@ export interface PostHogConfig {
* - `'2025-05-24'`: Use updated default behaviors (e.g. capture_pageview defaults to 'history_change')
* - `'2025-11-30'`: Defaults from '2025-05-24' plus additional changes (e.g. strict minimum duration for replay and rageclick content ignore list defaults to active)
* - `'2026-01-30'`: Defaults from '2025-11-30' plus external_scripts_inject_target defaults to 'head' (avoids SSR hydration errors)
* - `'2026-04-01'`: Defaults from '2026-01-30' plus strict_identify defaults to true (throws on invalid distinct IDs)
*
* @default 'unset'
*/
defaults: ConfigDefaults

/**
* When true, posthog.identify() throws an error if called with an invalid distinct_id
* (e.g. undefined, null, empty string, or the string "undefined").
* When false, invalid calls log a console error and return without identifying.
Comment on lines +1024 to +1026
Copy link
Member

Choose a reason for hiding this comment

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

i think SDKs should never throw, swallow users input and log is the best you can do to be a good citizen
throwing might 'break' people's apps, which is worse than just not getting that user identified.

Copy link
Member

Choose a reason for hiding this comment

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

We never want our SDKs to be the cause of customer breakage

It's a SDK philosophy that i learned and agree with

#3261 @lricoy added similar pattern which i dont think is the best approach
Curious about what other @PostHog/team-client-libraries folks think

Copy link
Member Author

Choose a reason for hiding this comment

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

no, fair... i was being overly keen

*
* @default false (or true when defaults >= '2026-04-01')
*/
strict_identify: boolean

/**
* EXPERIMENTAL: Defers initialization of non-critical extensions (autocapture, session recording, etc.)
* to the next event loop tick using setTimeout. This reduces main thread blocking during SDK
Expand Down
Loading