Is PhantomBuster safe?
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.…
Who publishes itPhantombuster - 1 other listing from the same operator, none carrying a finding
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.
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.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
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.
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 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.
| Field | Value | Why 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. |
| X-Phantombuster-Org | org_123456 (illustrative) |
| x-phantombuster-browser-extension | Chrome |
| x-phantombuster-browser-extension-version | 1.3.9 |
| Content-Type | application/json |
| X-Phantombuster-Org-Name | Example Workspace (illustrative) |
| x-phantombuster-browser-extension | Chrome |
| x-phantombuster-browser-extension-version | 1.3.9 |
Profile fields mapped into PhantomBuster API requests
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,
}
}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,
}
}- api.phantombuster.com
Receives lead-storage and contact-discovery requests generated from LinkedIn side-panel actions.
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.
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 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.
| Field | Value | Why 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-123456 | Identifies 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%20engineer | Can describe LinkedIn search results being processed when the request type is search. |
| csrf-token | ajax:1234567890123456789 (illustrative) |
| x-restli-protocol-version | 2.0.0 |
Cookie extraction and Voyager request handling in the shipped background bundle
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)
}
})- 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.