Is PhantomBuster safe?

Medium risk

PhantomBuster is medium risk. PhantomBuster maps LinkedIn profile data into lead-storage and contact-discovery requests to api.phantombuster.com after side-panel actions like saving a profile. Unauthenticated analysis missed these POSTs; needs a login and panel action.…

PhantomBusterv1.3.10Chrome Web Store
45Risk
Who publishes it

Phantombuster - 1 other listing from the same operator, none carrying a finding

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
PhantomBuster
Declared legal entity
Phantombuster
Registered address
49 Rue de Ponthieu, Paris 75008, FR
Registered contact
Guillaume Boiret

Same operator - 1 listing

Published under a different store account, but sharing the registered address, contact or declared legal entity this one gave the store.

Shared hosts - 1 hostname

Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.

phantombuster.com
Also called by 2 other listings, including PhantomBuster

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

LinkedIn lead details posted to PhantomBuster APIs

PhantomBuster maps LinkedIn profile data into lead-storage and contact-discovery requests to api.phantombuster.com after side-panel actions like saving a profile.

Unauthenticated analysis missed these POSTs; needs a login and panel action.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You save a LinkedIn profile or request contact info from the PhantomBuster side panel.

The unauthenticated test session did not reach this flow.

The extension did this

The extension posts profile-derived fields to PhantomBuster lead-storage or contact-discovery APIs.

The code adds browser-extension headers and can add the current PhantomBuster organization identifier.

02EvidenceFIELD TABLE
Fields sent in lead-storage and contact-discovery flows
FieldValueWhy it matters
Name
Jane Doe (illustrative)Identifies the LinkedIn profile being saved or researched.
LinkedIn profile URL
https://www.linkedin.com/in/jane-doe-123456/Links the saved lead or contact lookup to a specific LinkedIn profile.
Company and role context
Account Executive at Example Corp (illustrative)Describes where the person works and the role shown on the profile.
Contact-discovery inputs
first_name=Jane, last_name=Doe, company=Example Corp (illustrative)Supplies the name, company, LinkedIn URL, and company LinkedIn URL used for contact lookup.
PhantomBuster organization
org_123456 (illustrative)Associates the request with the PhantomBuster workspace selected in the browser.
03EvidenceNETWORK CAPTURE
Captured request
POSThttps://api.phantombuster.com/api/v2/org-storage/leads/save
Code-derived endpoint and method; unauthenticated dynamic analysis did not observe a lead-save POST.
Headers
X-Phantombuster-Orgorg_123456 (illustrative)
x-phantombuster-browser-extensionChrome
x-phantombuster-browser-extension-version1.3.9
04EvidenceNETWORK CAPTURE
Captured request
POSThttps://api.phantombuster.com/api/v1/discover-email
Code-derived endpoint and method; unauthenticated dynamic analysis did not observe a discover-email POST.
Headers
Content-Typeapplication/json
X-Phantombuster-Org-NameExample Workspace (illustrative)
x-phantombuster-browser-extensionChrome
x-phantombuster-browser-extension-version1.3.9
05EvidenceCODE COMPARE
The code that does this

Profile fields mapped into PhantomBuster API requests

What it actually does
Source-map lead-save pathsrc/sidepanel/services/pbV2.ts, lib/leads.ts, hooks/useCreateLeadFromCurrentTab.ts
export async function getCurrentOrgIdFromCookies() {
	const cookies = await getCookies()
	const baseDomain = new URL(import.meta.env.VITE_PHANTOMBUSTER_BASE_URL).hostname

	const currentOrg = findCookie(cookies, CURRENT_ORG_ID_COOKIE, baseDomain)

	return currentOrg?.value ?? ""
}

export const apiV2Client = createClient<paths>({
	baseUrl: new URL("/api/v2", import.meta.env.VITE_PHANTOMBUSTER_API_URL).toString(),
	headers: {
		"x-phantombuster-browser-extension": getBrowserName(),
		"x-phantombuster-browser-extension-version": getExtensionVersion(),
	},
})

const middleware: Middleware = {
	async onRequest({ request }) {
		const orgIdHeader = "X-Phantombuster-Org"

		const currentOrgId = await getCurrentOrgIdFromCookies()
		const hasOrgIdHeader = request.headers.has(orgIdHeader)
		const shouldReplaceOrgIdHeader = !hasOrgIdHeader && currentOrgId

		if (shouldReplaceOrgIdHeader) {
			request.headers.set(orgIdHeader, currentOrgId)
		}

		if (import.meta.env.MODE === "development") {
			// eslint-disable-next-line no-console
			console.info(`[ApiPbV2Client] Making ${request.method} request to: ${request.url}`)
			// eslint-disable-next-line no-console
			console.info(`[ApiPbV2Client] Headers:`, Object.fromEntries(request.headers))
		}

		return request
	},
}

apiV2Client.use(middleware)

/** Upsert a lead */
export const saveLead = async (body: SaveLeadsPayload, init?: RequestInit) =>
	apiV2Client.POST("/org-storage/leads/save", {
		body,

		signal: init?.signal,
	})

export function useMutationSaveLead() {
	return useMutation({
		mutationKey: ["/org-storage/leads/save"],
		mutationFn: saveLead,
		onError() {
			toast.error("We couldn’t save the profile. Try again in a bit.", {
				dismissible: true,
			})
		},
	})
}

export const mapProfileToLead = (profile: Profile): Partial<SaveLeadsPayload> => ({
	firstName: profile.firstName,
	lastName: profile.lastName,
	linkedinJobLocation: profile.location?.name,
	linkedinHeadline: profile.headline,
	location: profile.location?.name,
	companyName: profile.jobExperiences?.[0]?.name,
	linkedinCompanyUrl: profile.jobExperiences?.[0]?.linkedinUrl,
	linkedinDescription: profile.summary,
	previousCompanyName: profile.jobExperiences?.[1]?.name,
	linkedinJobTitle: profile.jobExperiences?.[0]?.jobTitle,
	personalEmails: profile.personalEmail ? [profile.personalEmail] : [],
	professionalEmails: profile.professionalEmail ? [profile.professionalEmail] : [],
	phoneNumbers: profile.phoneNumbers ? [...profile.phoneNumbers] : [],
})

/** Save current Linkedin profile to the leads database */
export const useCreateLeadFromCurrentTab = () => {
	const { slug } = useParams<{ slug: string }>()
	const { mutateAsync: saveLead, isIdle, error } = useMutationSaveLead()
	const { data: tab } = useSuspenseTab()
	const { profile } = useSuspenseProfileAndCompany(slug ?? "")

	const createLead = async () => {
		return saveLead({
			linkedinProfileUrl: resolveMeLinkedinSlugToFullUrl({
				tabUrl: extractLinkedInSlugUrl(tab?.url),
				profileSlug: profile?.slug,
			}),
			...mapProfileToLead(profile),
		})
	}

	return {
		createLead,
		createLeadIdle: isIdle,
		createLeadError: error,
	}
}
Source-map contact-discovery pathsrc/sidepanel/services/pbV1.ts and lib/email-discovery.tsx
export async function discoverProfileEmailAndPhone(
	orgName: string,
	body: { payload: EmailDiscoveryPayload | EmailDiscoveryPayload[] },
	init?: RequestInit,
) {
	const res = await apiV1Client.POST<EmailDiscoveryResponse>("/discover-email", {
		body: Object(body),
		headers: {
			"X-Phantombuster-Org-Name": orgName,
		},
		signal: init?.signal,
	})

	if (res.err) {
		console.error(res.err)
		throw res.err
	}

	return res.data.data
}

/**
 * Unfortunatly the v1 api does not have type definition so we will use it without openapi-fetch
 */
export const apiV1Client = {
	async request<T>(
		method: string,
		endpoint: `/${string}`,
		options: RequestInit = {},
	): Promise<
		| { data: T; err?: never }
		| {
				err: Error
				data?: never
		  }
	> {
		options.headers = {
			...options.headers,
			"Content-Type": "application/json",
			"x-phantombuster-browser-extension": getBrowserName(),
			"x-phantombuster-browser-extension-version": getExtensionVersion(),
		}
		options.method = method

		if (options.body) {
			options.body = JSON.stringify(options.body)
		}

		return fetch(new URL(`/api/v1${endpoint}`, import.meta.env.VITE_PHANTOMBUSTER_API_URL), options)
			.then(async (res) => {
				if (!res.ok) {
					throw new Error(await res.text())
				}

				return { data: (await res.json()) as T }
			})
			.catch((err: Error) => {
				console.error(err)
				return { err }
			})
	},

	GET<T>(endpoint: `/${string}`) {
		return this.request<T>("GET", endpoint)
	},

	POST<T>(endpoint: `/${string}`, options: RequestInit) {
		return this.request<T>("POST", endpoint, options)
	},

	PUT<T>(endpoint: `/${string}`, options: RequestInit) {
		return this.request<T>("PUT", endpoint, options)
	},

	DELETE<T>(endpoint: `/${string}`) {
		return this.request<T>("DELETE", endpoint)
	},

	PATCH<T>(endpoint: `/${string}`, options: RequestInit) {
		return this.request<T>("PATCH", endpoint, options)

export function useEmailAndPhoneDiscovery({ orgName, profile }: { orgName: string; profile: Profile }) {
	const { data, error, isPending, mutateAsync } = useMutationDiscoverProfileEmailAndPhone(profile.slug ?? "")
	const errorBoundary = useErrorBoundary()

	const discoverMutationKey = [DISCOVER_PROFILE_EMAIL_AND_PHONE_KEY, profile.slug]

	const [mutationStatus] = useMutationState({
		filters: { mutationKey: discoverMutationKey },
		select: (mutation) => mutation.state.status,
	})

	async function fetchContactInfo() {
		errorBoundary.resetBoundary()

		const company = findProfileMainCompany(profile)
		try {
			await mutateAsync({
				orgName,
				payload: {
					first_name: profile.firstName,
					last_name: profile.lastName,
					company: company?.name,
					linkedin: constructLinkedinProfileUrlFromPublicIdentifier(profile.slug),
					company_linkedin: company?.linkedinUrl,
				},
			})
		} catch (err) {
			errorBoundary.showBoundary(err)
		}
	}

	return {
		data,
		error,
		isLoading: isPending || mutationStatus === "pending",
		fetchContactInfo,
	}
}
06EvidenceTHIRD PARTY LIST
External API destination
  • api.phantombuster.com

    Receives lead-storage and contact-discovery requests generated from LinkedIn side-panel actions.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

LinkedIn profile data requested with the user session

Using PhantomBuster on a LinkedIn profile makes it read the session cookie, use it as CSRF token for Voyager GET requests, and return data to the side panel.

Unauthenticated analysis didn't observe this; needs a logged-in account and panel.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You open PhantomBuster on a LinkedIn profile or search result.

The unauthenticated test did not trigger the flow; the code path is tied to side-panel use.

The extension did this

The extension reads the LinkedIn cookie value and requests LinkedIn Voyager data as your browser session.

The background script handles profile, company, identity, and search request types.

02EvidenceFIELD TABLE
Fields used or returned by the LinkedIn request path
FieldValueWhy it matters
LinkedIn session-derived token
ajax:1234567890123456789 (illustrative)Lets the request use your logged-in LinkedIn browser session for the profile-data request.
Profile identifier
jane-doe-123456Identifies which LinkedIn member profile the side panel is requesting.
Profile and company data
Jane Doe, Account Executive at Example Corp (illustrative)Describes the person or company shown in the side panel.
Search result path
/search/results/people/?keywords=security%20engineerCan describe LinkedIn search results being processed when the request type is search.
03EvidenceNETWORK CAPTURE
Captured request
GEThttps://www.linkedin.com/voyager/api/identity/dash/profiles?q=memberIdentity&memberIdentity=jane-doe-123456&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-35
Code-derived endpoint and method; unauthenticated dynamic analysis did not observe a live Voyager request.
Headers
csrf-tokenajax:1234567890123456789 (illustrative)
x-restli-protocol-version2.0.0
04EvidenceCODE COMPARE
The code that does this

Cookie extraction and Voyager request handling in the shipped background bundle

What it actually does
Source-map TypeScript for the same pathsrc/shared/voyager.ts and src/background/background.ts
export const findLinkedInCsrfToken = (cookies: Cookies.Cookie[]) => {
	// The CSRF token is required to make authenticated calls to the LinkedIn API and is stored in the "JSESSIONID" cookie for some reason
	// The JSESSIONID cookie takes the following form: "ajax:<random number>"
	// The extra quotes need to be removed before it can be used
	const csrfToken = cookies
		.find((cookie) => cookie.domain === ".www.linkedin.com" && cookie.name === "JSESSIONID")
		?.value?.replace(/"/g, "")
	return csrfToken ?? null
}

async function sendVoyagerRequest<TResponse>(endpoint: `/${string}`, csrfToken: string): Promise<TResponse> {
	try {
		const response = await fetch(`https://www.linkedin.com/voyager/api${endpoint}`, {
			headers: {
				"csrf-token": csrfToken,
				"accept-language": "en-US,en;q=0.9,fr-FR;q=0.8,fr;q=0.7",
				"sec-ch-ua": '"Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"',
				"sec-ch-ua-mobile": "?0",
				"sec-ch-ua-platform": '"Windows"',
				"sec-fetch-dest": "empty",
				"sec-fetch-mode": "cors",
				"sec-fetch-site": "same-origin",
				"x-li-lang": "en_US",
				"x-li-track":
					'{"clientVersion":"0.2.*","osName":"web","timezoneOffset":2,"deviceFormFactor":"DESKTOP","mpName":"settings-web","displayDensity":1}',
				"x-restli-protocol-version": "2.0.0",
			},
			referrer: "https://www.linkedin.com/feed/",
			referrerPolicy: "strict-origin-when-cross-origin",
			body: null,
			method: "GET",
			mode: "cors",
			credentials: "include",
		})

		if (!response.ok) {
			const text = await response.text()
			throw new Error(`LinkedIn API error (${response.status}): ${text}`)
		}

		return response.json() as TResponse
	} catch (err) {
		throw err
	}
}

export async function handleVoyagerRequest(request: VoyagerRequest): Promise<VoyagerResponse> {
	try {
		const browserCookies = await browser.cookies.getAll({})
		const csrfToken = findLinkedInCsrfToken(browserCookies)

		if (!csrfToken) {
			throw new Error("CSRF token not found")
		}

		let data: unknown
		switch (request.type) {
			case "profile":
				data = await sendVoyagerRequest<VoyagerProfileResponse>(
					`/identity/dash/profiles?q=memberIdentity&memberIdentity=${request.slug}&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-35`,
					csrfToken,
				)
				break
			case "company":
				data = await sendVoyagerRequest<VoyagerCompanyResponse>(
					`/organization/companies?q=universalName&universalName=${request.slug}&decorationId=com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12`,
					csrfToken,
				)
				break
			case "identityDashProfiles":
				data = await sendVoyagerRequest<IVoyagerDashProfileResponse>(
					`/graphql?variables=(memberIdentity:${request.slug})&queryId=voyagerIdentityDashProfiles.c7452e58fa37646d09dae4920fc5b4b9`,
					csrfToken,
				)
				break
			case "search":
				data = await sendVoyagerRequest<VoyagerSearchResultResponse>(`/${request.slug}`, csrfToken)
				break
		}

		return {
			voyagerResponse: {
				type: request.type,
				data,
			},
		}
	} catch (error) {
		return {
			voyagerResponse: {
				type: request.type,
				data: null,
				error: error instanceof Error ? error.message : "Unknown error",
			},
		}
	}
}

browser.runtime.onMessage.addListener(async (msg: FromContentScriptRuntimeMessages, sender: Runtime.MessageSender) => {
	try {
		if (msg.voyagerRequest) {
			const response = await handleVoyagerRequest(msg.voyagerRequest)
			if (sender.tab?.id) {
				await sendMessage(sender.tab.id, response)
			}
			return response
		}

		if (msg.action === "update_tab" && msg.newUrl) {
			await updateCurrentTabUrl(msg.newUrl)
		}

		if (msg.newTab && sender.tab) {
			await newTab(msg.newTab.websiteName, msg.newTab.url, sender.tab)
		} else if (msg.getCookies && sender.tab && msg.getCookies.websiteName !== "X") {
			await getCookies(msg.getCookies.websiteName, sender.tab)
		} else if (msg.getCookies && sender.tab && msg.getCookies.websiteName === "X") {
			if (!isChromium) {
				// We only execute the permission request in Chrome. Firefox has the required permissions to retrieve the session cookie from x.com
				// The permissions API is not working on Firefox (bug -> https://bugzilla.mozilla.org/show_bug.cgi?id=1392624)
				await getCookies(msg.getCookies.websiteName, sender.tab)
				return
			}

			const permissionsGranted = await browser.permissions.request({
				origins: ["*://*.x.com/*"],
			})
			if (permissionsGranted) {
				await getCookies(msg.getCookies.websiteName, sender.tab)
			} else {
				await sendNotification(
					"PhantomBuster",
					"You must approve new permissions for x.com to retrieve your Twitter session cookie.",
				)
			}
		} else if (msg.notif) {
			await sendNotification(msg.notif.title || "PhantomBuster", msg.notif.message)
		} else if (msg.restartMe && sender.tab && sender.tab.id) {
			await sendMessage(sender.tab.id, { restart: true })
		} else if (msg.userInfo && sender.tab?.id) {
			await handleUserInfoRequest(sender.tab.id, msg.userInfo)
		}
	} catch (error) {
		handleError(error)
	}
})
05EvidenceTHIRD PARTY LIST
Hosts involved in this flow
  • www.linkedin.com

    Receives the authenticated Voyager API request for profile, company, identity, or search data.

  • api.phantombuster.com

    Receives saved lead data in the related side-panel workflow when the user saves a lead.

Updated 30 September 2026mdlnjfcpdiaclglfbdkbleiamdafilil