Is Loom – Screen Recorder & Screen Capture safe?

High risk

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.

Loomv5.5.210Chrome Web Store
75Risk
Who publishes it

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.

Publisher
Loom
Declared legal entity
Atlassian
Registered address
350 Bush Street, Floor 13, San Francisco, CA 94104, US
Registered contact
Vinay Hiremath

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.

app.outreach.io
Also called by 2 other listings, including CallCloud - For Salesloft, Outreach & Hubspot
studio.atlassian.com
Also called by 3 other listings, including Zephyr Capture for JIRA
compiledcssinjs.com
Also called by 5 other listings, including Custom Language for Jira Cloud

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

Publishers can request a review.

Findings

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

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 did this

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.

02EvidenceFIELD TABLE
Diagnostic fields the collector stores while the flow is active
FieldValueWhy it matters
Page URL
https://app.example.com/account/settingsShows which page you were viewing during the recording.
Page title
Account settings - Example AppAdds human-readable context about the page you had open.
Typed text
quarterly rollout notesCan 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 settingsCan include messages printed by the page while you are recording the bug report.
Network request details
POST https://api.example.com/v1/settingsCan show URLs, methods, headers, and readable request or response bodies exchanged by the page.
03EvidenceNETWORK CAPTURE
Captured request
GEThttps://www.loom.com/api/util/ping
Observed during dynamic analysis as a Loom connectivity probe; no Developer Context S3 upload or createDeveloperContextUploadCredentialsV2 GraphQL request was observed without an authenticated Loom recording session.
04EvidenceCODE COMPARE
The code that does this

The service worker defines the collector, captures page diagnostics, and uploads JSON

What it actually does
Readable gate and collected-data schemadeobfuscated/js/sw.js
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: []
  }
}
Readable URL and network collection handlersdeobfuscated/js/sw.js
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()
}
Readable upload cadence and object writedeobfuscated/js/sw.js
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"
})
05EvidenceTEMPORAL PATTERN
When this fires
Every 10 seconds

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.

06EvidenceTHIRD PARTY LIST
Hosts and storage services involved in the observed and code-level flow
  • 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.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

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.

The extension did this

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.

02EvidenceCODE COMPARE
The code that does this

GraphQL mutation call in the service worker

What it actually does
Effective metric names (G6 constants, sw.js:308280)js/sw.js
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
  }
};
03EvidenceFIELD TABLE
Tags appended to every Datadog metric
FieldValueWhy it matters
Operating system
macThe OS platform reported by Chrome's system API, sent on every metric flush.
Chrome version
124The major Chrome version extracted from the user-agent string.
Extension version
5.5.193The installed version of the Loom extension, from the manifest.
Manifest version
3Chrome extension manifest version (always 3 for MV3).
Environment
productionDeployment environment label (e.g. production, staging).
04EvidenceTHIRD PARTY LIST
Where the metrics are sent
  • 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.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You start a Loom Bug Report recording with developer context enabled.

The extension did this

The extension enables web request listeners that can record request and response details across the all-URL permission scope.

02EvidenceFIELD TABLE
Fields stored from browser web requests
FieldValueWhy 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.
03EvidenceCODE COMPARE
The code that does this

All-URL webRequest listeners and storage handlers

What it actually does
          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"]);
04EvidenceSTORAGE DUMP
What's stored on your device

The structure the code builds before upload. Illustrative values; dynamic analysis didn't reach the state needed for a live request.

Locationin-memory developer-context object before upload
Contents (JSON)
{
  "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"
        ]
      }
    ]
  }
}
05EvidenceCODE COMPARE
The code that does this

Developer-context data is uploaded after collection

What it actually does
          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.

Updated 30 September 2026liecbddmkiiihnedobmlmillhodjkdmb