Is Loom – Screen Recorder & Screen Capture safe?
Loom sends analytics events including your Segment identity and loom.com cookies to api.segment.io.
When you use the extension, it reads your loom.com anonymous ID cookie and transmits it alongside event names and context (app version, extension client type) to Segment at api.segment.io in batches every two seconds. The extension also accepts programmatic commands from the loom.com website to start recordings, and reads additional loom.com cookies for UI state.
Who publishes itAtlassian - 4 other listings from the same operator, none carrying a finding
Atlassian - 4 other listings 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 - 4 listings
Published under a different store account, but sharing the registered address, contact or declared legal entity this one gave the store.
Shared hosts - 3 hostnames
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
Loom Bug Report mode can upload page diagnostics
When Developer Context or Bug Report mode is active, the extension collects page URLs, titles, clicked elements, typed text (passwords masked), console and network data, uploading every 10s.
Not triggered without login; only probes seen.
You start a Loom Bug Report or Developer Context recording while signed in.
Dynamic analysis could not exercise this flow without a logged-in Loom profile.
The extension can record page and network diagnostics from the active tab and buffer them for upload.
The code gates collection on Bug Report, Action Logs, or Maker Mode recording state before starting the collector.
| Field | Value | Why it matters | |
|---|---|---|---|
Page URL | https://app.example.com/account/settings | Shows which page you were viewing during the recording. | |
Page title | Account settings - Example App | Adds human-readable context about the page you had open. | |
Typed text | quarterly rollout notes | Can include text you type into inputs or text areas during the recording. Password-like fields are masked before storage. | |
Console output | Failed to load project settings | Can include messages printed by the page while you are recording the bug report. | |
Network request details | POST https://api.example.com/v1/settings | Can show URLs, methods, headers, and readable request or response bodies exchanged by the page. |
The service worker defines the collector, captures page diagnostics, and uploads JSON
const iee = async () => {
const e = await C8();
return aee(e)
},
aee = e => {
const {
recorderSettings: t,
preRecorder: r
} = e.getState(), {
recording_documentation_type: i
} = t;
var a;
return ((a = i) === Ch.BugReport || a === Ch.ActionLogs || r.recording_page === o5.MakerMode) && (e => {
const t = c7(e),
r = f7(s7)(e),
i = null == t ? 0 : t.memberLimits?.AI_V5 ?? 5;
return r || i > 0
})(e.getState())
},
nee = "[DeveloperContextCollector]",
oee = {
humanReadableTimestamp: "",
system: {},
location: null,
browser: {},
console: {
tabsNavigatedTo: [],
userClickEvents: [],
userTypeEvents: [],
consoleEvents: []
},
network: {
networkRequestEvents: [],
networkResponseEvents: [],
networkRequestHeaderEvents: [],
networkResponseHeaderEvents: []
}
}async handleNavigationUpdate(e, t) {
const r = await C8();
if (!aee(r)) return;
const {
isCollectionStopped: i
} = r.getState().developerContext;
if (i) return;
if (this.isTabOnlyMode && null !== this.firstActiveTabId && this.currentActiveTabId !== this.firstActiveTabId) return;
if (!e.url || !e.title) return;
null === this.firstActiveTabId && void 0 !== e.id && (e.id, this.firstActiveTabId = e.id);
let a = !0;
const n = this.collectedData.console.tabsNavigatedTo,
o = L7(e.url || "");
if (n.length > 0) {
const t = n[n.length - 1];
a = !(t.title === e.title || t.url === o)
}
if (!a) return;
const s = {
id: crypto.randomUUID(),
url: o,
title: e.title,
absoluteTimestampMs: Date.now(),
relativeTimestampSeconds: t
};
n.push(s), this.checkMemoryAndUploadIfNeeded()
}
async handleNetworkRequest(e) {
const t = await C8();
if (!aee(t)) return;
const {
isCollectionStopped: r
} = t.getState().developerContext;
if (r) return;
if (-1 === e.tabId || !this.tabsWithNetworkCapture.has(e.tabId) && e.tabId !== this.currentActiveTabId) return;
if (this.isTabOnlyMode && null !== this.firstActiveTabId && e.tabId !== this.firstActiveTabId) return;
const i = e.url,
a = this.getDomainKey(i, e.type, e.tabId);
if (this.isDuplicateNetworkEvent(a, this.recentNetworkRequests)) return;
let n = "";
if (e.requestBody && e.requestBody.raw && e.requestBody.raw[0]) {
const t = e.requestBody.raw[0].bytes;
t && (n = new TextDecoder("utf-8").decode(t), this.isUnreadableBody(n) && (n = "[Binary/Unreadable Data]"))
}
const o = {
url: L7(e.url),
method: e.method,
requestBody: V7(n) || void 0,
requestHeaders: F7(e.requestHeaders),
tabId: e.tabId,
absoluteTimestampMs: e.absoluteTimestampMs,
relativeTimestampSeconds: e.relativeTimestampSeconds,
requestId: e.requestId,
initiator: H7(e.initiator),
type: e.type
};
this.collectedData.network.networkRequestEvents.push(o), this.checkMemoryAndUploadIfNeeded()
}
async handlePageNetworkEvents({
events: e,
relativeTimestampSeconds: t
}) {
const r = await C8();
if (!aee(r)) return;
if (this.isTabOnlyMode && null !== this.firstActiveTabId && this.currentActiveTabId !== this.firstActiveTabId) return;
const i = e.filter((e => "response" === e.type));
e.length, i.length;
for (const e of i) try {
const r = {
url: L7(e.url),
method: e.method,
statusCode: e.status,
responseBody: V7(e.responseBody),
absoluteTimestampMs: e.timestamp,
relativeTimestampSeconds: t
};
this.collectedData.network.networkResponseEvents.push(r)
} catch (t) {
o8.datadogIncrement(Z6.ERROR.NETWORK_RESPONSE_EVENT_PROCESSING_ERROR), oA.debug(`${nee} handlePageNetworkEvents: Error processing network response event`, {
error: t,
event: e
})
}
i.length, this.checkMemoryAndUploadIfNeeded()
}startUploadInterval() {
this.videoId, this.isUploading, this.stopUploadInterval(), this.uploadIntervalId = setInterval((async () => {
this.videoId, this.estimateCollectedDataSize(), await this.periodicUpload()
}), 1e4), this.uploadIntervalId
}
async checkMemoryAndUploadIfNeeded() {
if (this.isUploading) return;
const e = this.estimateCollectedDataSize();
this.memoryLimit, e < this.memoryLimit || (this.memoryLimit, this.isUploading = !0, await this.uploadDevContextData(), this.isUploading = !1)
}
o.putObject({
Body: a,
Bucket: s,
Key: u,
ContentType: "application/json"
})While Developer Context collection is active, the extension attempts to upload the current buffer every 10 seconds and also uploads when the JSON buffer reaches about 1 MB.
- www.loom.com
Observed connectivity checks and the code-defined GraphQL request for Developer Context upload credentials.
- amazonaws.com
AWS S3 storage backend supported by the upload code when Loom returns S3Info credentials; no S3 request was observed in the unauthenticated dynamic-analysis run.
Extension sends operational metrics to Datadog via Loom GraphQL proxy
Loom's service worker posts batched performance/error counters to www.loom.com/metrics/graphql every 60s (mutation EmitDatadogEvents), tagged with OS, Chrome and extension versions.
No page URLs, but the fingerprint links to your device.
The service worker records an operational event (e.g. a recording error or credential fetch failure).
Events are queued in memory and flushed as a batch every 60 seconds.
A POST request is sent to www.loom.com/metrics/graphql carrying the queued counters.
Each counter is tagged with OS, Chrome version, extension version, and environment before transmission.
GraphQL mutation call in the service worker
const METRIC_PREFIX = "v2.developer_context";
const G6 = {
COLLECTION_STARTED: `${METRIC_PREFIX}.collection.started`,
COLLECTION_FINISHED: `${METRIC_PREFIX}.collection.finished`,
COLLECTION_INCOMPLETE_DETECTED:`${METRIC_PREFIX}.collection.incomplete_detected`,
SERVICE_WORKER_RESTART_DETECTED:`${METRIC_PREFIX}.sw_restart_detected`,
TIME_TO_START_CONSOLE_CAPTURE: `${METRIC_PREFIX}.time_to_start_console_capture`,
ERROR: {
BROWSER_INFO_ERROR: `${METRIC_PREFIX}.error.browser_info_error`,
CREDENTIALS_FETCH_FAILED: `${METRIC_PREFIX}.error.credentials_fetch_failed`,
CREDENTIALS_MISSING: `${METRIC_PREFIX}.error.credentials_missing`,
UPLOAD_FAILED: `${METRIC_PREFIX}.error.upload_failed`,
NETWORK_CAPTURE_START_ERROR: `${METRIC_PREFIX}.error.network_capture_start_error`,
CONSOLE_CAPTURE_START_ERROR: `${METRIC_PREFIX}.error.console_capture_start_error`,
SYSTEM_INFO_ERROR: `${METRIC_PREFIX}.error.system_info_error`,
// ... additional error codes
}
};| Field | Value | Why it matters | |
|---|---|---|---|
Operating system | mac | The OS platform reported by Chrome's system API, sent on every metric flush. | |
Chrome version | 124 | The major Chrome version extracted from the user-agent string. | |
Extension version | 5.5.193 | The installed version of the Loom extension, from the manifest. | |
Manifest version | 3 | Chrome extension manifest version (always 3 for MV3). | |
Environment | production | Deployment environment label (e.g. production, staging). |
- www.loom.com
Vendor's own backend. Acts as a GraphQL proxy to forward batched Datadog metrics from the extension. Loom is owned by Atlassian.
- browser-intake-datadoghq.com
Datadog's intake endpoint (reached indirectly through the Loom proxy). Datadog is a US-based observability SaaS provider.
Loom captures request URLs and headers during Bug Reports
When a signed-in Bug Report recording has developer context enabled, the service worker registers all-URL request listeners collecting request URLs, headers, bodies, and response headers.
Dynamic analysis didn't reach live capture.
You start a Loom Bug Report recording with developer context enabled.
The extension enables web request listeners that can record request and response details across the all-URL permission scope.
| Field | Value | Why it matters | |
|---|---|---|---|
Request URL | https://app.example.com/api/profile?tab=settings (illustrative) | Shows the website or endpoint contacted while the recording is active. | |
Request headers | content-type: application/json (illustrative) | Can include browser and site metadata sent with a request. | |
Request body | {"query":"account settings"} (illustrative) | Can include text the page sends in a request when the browser exposes a request body. | |
Response headers | cache-control: no-store (illustrative) | Can include status and content metadata returned by the site. |
All-URL webRequest listeners and storage handlers
async handleNetworkRequest(e) {
const t = await C8();
if (!aee(t)) return;
const {
isCollectionStopped: r
} = t.getState().developerContext;
if (r) return;
if (-1 === e.tabId || !this.tabsWithNetworkCapture.has(e.tabId) && e.tabId !== this.currentActiveTabId) return;
if (this.isTabOnlyMode && null !== this.firstActiveTabId && e.tabId !== this.firstActiveTabId) return;
const i = e.url,
a = this.getDomainKey(i, e.type, e.tabId);
if (this.isDuplicateNetworkEvent(a, this.recentNetworkRequests)) return;
let n = "";
if (e.requestBody && e.requestBody.raw && e.requestBody.raw[0]) {
const t = e.requestBody.raw[0].bytes;
t && (n = new TextDecoder("utf-8").decode(t), this.isUnreadableBody(n) && (n = "[Binary/Unreadable Data]"))
}
const o = {
url: L7(e.url),
method: e.method,
requestBody: V7(n) || void 0,
requestHeaders: F7(e.requestHeaders),
tabId: e.tabId,
absoluteTimestampMs: e.absoluteTimestampMs,
relativeTimestampSeconds: e.relativeTimestampSeconds,
requestId: e.requestId,
initiator: H7(e.initiator),
type: e.type
};
this.collectedData.network.networkRequestEvents.push(o), this.checkMemoryAndUploadIfNeeded()
}
async handleNetworkResponseHeader(e) {
const t = await C8();
if (!aee(t)) return;
const {
isCollectionStopped: r
} = t.getState().developerContext;
if (r) return;
if (-1 === e.tabId || !this.tabsWithNetworkCapture.has(e.tabId) && e.tabId !== this.currentActiveTabId) return;
if (this.isTabOnlyMode && null !== this.firstActiveTabId && e.tabId !== this.firstActiveTabId) return;
const i = e.url,
a = this.getDomainKey(i, e.type, e.tabId);
if (this.isDuplicateNetworkEvent(a, this.recentNetworkResponseHeaders)) return;
const n = {
responseHeaders: F7(e.responseHeaders) || [],
url: L7(e.url),
method: e.method,
statusCode: e.statusCode,
tabId: e.tabId,
absoluteTimestampMs: e.absoluteTimestampMs,
relativeTimestampSeconds: e.relativeTimestampSeconds,
requestId: e.requestId,
initiator: H7(e.initiator),
type: e.type
};
this.collectedData.network.networkResponseHeaderEvents?.push(n), this.checkMemoryAndUploadIfNeeded()
}
async handleNetworkRequestHeader(e) {
const t = await C8();
if (!aee(t)) return;
const {
isCollectionStopped: r
} = t.getState().developerContext;
if (r) return;
if (-1 === e.tabId || !this.tabsWithNetworkCapture.has(e.tabId) && e.tabId !== this.currentActiveTabId) return;
if (this.isTabOnlyMode && null !== this.firstActiveTabId && e.tabId !== this.firstActiveTabId) return;
const i = e.url,
a = this.getDomainKey(i, e.type, e.tabId);
if (this.isDuplicateNetworkEvent(a, this.recentNetworkRequestHeaders)) return;
const n = {
requestHeaders: F7(e.requestHeaders) || [],
url: L7(e.url),
method: e.method,
tabId: e.tabId,
absoluteTimestampMs: e.absoluteTimestampMs,
relativeTimestampSeconds: e.relativeTimestampSeconds,
requestId: e.requestId,
initiator: H7(e.initiator),
type: e.type
};
this.collectedData.network.networkRequestHeaderEvents?.push(n), this.checkMemoryAndUploadIfNeeded()
qte = chrome.runtime.id;chrome?.webRequest?.onBeforeSendHeaders?.addListener((e => {
C8().then((t => {
const r = t.getState().recording,
{
paused: i
} = r;
r.recorderPhase === tf.active && aee(t) && !i && see.handleNetworkRequestHeader({
...e,
absoluteTimestampMs: e.timeStamp,
relativeTimestampSeconds: r.duration || 0
}).catch((e => {
oA.debug("Error handling network request header in developer context", {
error: e
}, {
team: "workflow-systems"
})
}))
}))
}), {
urls: ["<all_urls>"]
}, ["requestHeaders"]),
chrome?.webRequest?.onBeforeRequest?.addListener((e => {
C8().then((t => {
const r = t.getState().recording,
{
paused: i
} = r;
r.recorderPhase === tf.active && aee(t) && !i && see.handleNetworkRequest({
...e,
absoluteTimestampMs: e.timeStamp,
relativeTimestampSeconds: r.duration || 0
}).catch((e => {
oA.debug("Error handling network request header in developer context", {
error: e
}, {
team: "workflow-systems"
})
}))
}))
}), {
urls: ["<all_urls>"]
}, ["requestBody"]),
chrome?.webRequest?.onCompleted?.addListener((e => {
C8().then((t => {
const r = t.getState().recording,
{
paused: i
} = r;
r.recorderPhase === tf.active && aee(t) && !i && see.handleNetworkResponseHeader({
...e,
absoluteTimestampMs: e.timeStamp,
relativeTimestampSeconds: r.duration || 0
}).catch((e => {
oA.debug("Error handling network response header in developer context", {
error: e
}, {
team: "workflow-systems"
})
}))
}))
}), {
urls: ["<all_urls>"]
}, ["responseHeaders"]);The structure the code builds before upload. Illustrative values; dynamic analysis didn't reach the state needed for a live request.
in-memory developer-context object before upload{
"network": {
"networkRequestEvents": [
{
"url": "https://app.example.com/api/profile?tab=settings",
"type": "xmlhttprequest",
"tabId": 123,
"method": "POST",
"initiator": "https://app.example.com",
"requestId": "12345.67",
"requestBody": "{\"query\":\"account settings\"}",
"requestHeaders": [
"content-type: application/json"
]
}
],
"networkRequestHeaderEvents": [
{
"url": "https://app.example.com/api/profile?tab=settings",
"type": "xmlhttprequest",
"tabId": 123,
"method": "POST",
"initiator": "https://app.example.com",
"requestId": "12345.67",
"requestHeaders": [
"content-type: application/json"
]
}
],
"networkResponseHeaderEvents": [
{
"url": "https://app.example.com/api/profile?tab=settings",
"type": "xmlhttprequest",
"tabId": 123,
"method": "POST",
"initiator": "https://app.example.com",
"requestId": "12345.67",
"statusCode": 200,
"responseHeaders": [
"cache-control: no-store"
]
}
]
}
}Developer-context data is uploaded after collection
async collectSystemInfoData() {
return this.collectedData.humanReadableTimestamp = (new Date).toUTCString(), this.collectedData.system = await this.getSystemInfo(), this.collectedData.location = await this.getLocationInfo(), this.collectedData.browser = await this.getBrowserInfo(), this.collectedData
async uploadDevContextData() {
const e = await C8();
if (!aee(e)) return;
const {
isCollectionStopped: t,
s3Info: r
} = e.getState().developerContext;
if (t) return;
const i = this.estimateCollectedDataSize();
let a;
const n = Boolean(I5(e.getState(), OI.LOOM_TDP_OS_UPLOAD).value),
o = Boolean(I5(e.getState(), OI.LOOM_TDP_OS_DEV_CONTEXT).value);
if (n && o || !r) try {
a = await ree({
videoId: this.videoId
})
} catch (e) {
return this.videoId, o8.datadogIncrement(Z6.ERROR.CREDENTIALS_MISSING), void await this.handleS3UploadFailure()
} else a = r, oA.info(`${nee} Using cached S3 credentials`, {
videoId: this.videoId,
typename: a.__typename
});
const s = structuredClone(this.collectedData);
this.clearCollectedData();
try {
await async function({
credentials: e,
data: t,
videoId: r
}) {
const i = Date.now(),
a = JSON.stringify(t),
n = "ObjectStoreInfo" === e.__typename ? W7.TDPOS : W7.S3;
if (oA.info(`${tee} Uploading developer context data using ${n}`, {
videoId: r,
storageBackend: n,
typename: e.__typename,
credentialType: e.credentialType,
bucket: e.bucket
}), n !== W7.TDPOS) {
const t = e,
n = {
__typename: "S3Credentials",
AccessKeyId: t.accessKeyId,
SecretAccessKey: t.secretAccessKey,
SessionToken: t.sessionToken,
Bucket: t.bucket,
Region: t.region,
Path: t.path
},
o = X7(n),
s = n.Bucket,
u = `${n.Path}/${r}-${i}.json`;
return await $7({
videoId: r,
storage: W7.S3
}).then((e => {
e.data?.updateDeveloperContextStorage?.success || oA.error(`${tee} Failed to update dev_context_storage in DB`, {
videoId: r,
storage: W7.S3,
response: e.data?.updateDeveloperContextStorage
})
})), o.putObject({
Body: a,
Bucket: s,
Key: u,
ContentType: "application/json"
}).then((e => {+5 more findings not shown
Where it sends data
Destinations our analysis observed Loom contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.
- api.segment.io
Loom sends data to api.segment.io. 21 other extensions we have analysed send data here.