Is Otter.ai: Record & Transcribe Meetings - Google Meet & Web Audio safe?

High risk

Otter.ai is high risk. After a signed-in user starts recording, it opens a recording tab, captures audio, opens Otter.ai's transcription WebSocket, sends a start message, then forwards audio buffers. Unauthenticated checks didn't trigger this; login is required.…

Otter.aiv3.10.1Chrome Web Store
75Risk
Who publishes it

Otter.ai - no other listings under this identity, 3 shared hostnames

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

Publisher
Otter.ai

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.

polyfill-fastly.io
Also called by 3 other listings: Magic Eden, RudderStack Assistant, Streamloots Cards
api.rudderstack.com
Also called by 6 other listings
cdn.rudderlabs.com
Also called by 6 other listings

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

Tab audio streams to Otter.ai after recording starts

After a signed-in user starts recording, it opens a recording tab, captures audio, opens Otter.ai's transcription WebSocket, sends a start message, then forwards audio buffers.

Unauthenticated checks didn't trigger this; login is required.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You start an Otter.ai recording from the extension.

The code path requires an authenticated Otter.ai session and a recording action.

The extension did this

The extension captures audio from the active browser tab and sends it to Otter.ai for transcription.

The recording tab sends a JSON start message first, followed by binary audio buffers.

02EvidenceFIELD TABLE
Fields and buffers sent by the recording flow
FieldValueWhy it matters
Recording identifier
speech_id from the recording-start responseLets Otter.ai associate the audio stream with the transcript you started.
Processed offset
offset: 0Tracks how much of the audio stream has already been acknowledged.
Browser-tab audio frames
16 kHz audio buffer, 1024 samplesAudio playing in the captured tab can include meeting speech, media playback, or other spoken content.
Capture stream handle
stream handle for the active tab audioConnects the recording tab to the browser tab whose audio is being recorded.
03EvidenceNETWORK CAPTURE
Captured request
GETwss://ws.aisense.com/api/v2/client
WebSocket upgrade for transcription; available traffic evidence did not record a request body for this connection.
04EvidenceCODE COMPARE
The code that does this

Shipped and deobfuscated recording path evidence

What it actually does
Record-button handler passes the server-provided WebSocket URL and speech IDsrc/content-script/contentscript.js
function EV() {
    const [, t] = gt(So), [, e] = gt(Q1), [, n] = gt(a6), [r] = gt(o6), [i] = gt(N$), {
      mutate: o
    } = sGe(), {
      data: s
    } = Co(), a = l6(sv.audioTag, "enabled"), l = et() ? yve : _ve;
    return {
      startRecording: h.useCallback(async (d = !1) => {
        if (!await mZe()) {
          Is(), Os(c.jsx(Ls, {
            "data-testid": "cannot-start-recording-toast",
            description: JD,
            altText: JD,
            showCloseIcon: !0
          }), {
            duration: Ss.endCurrentRecording
          });
          return
        }
        s && o({
          queryParams: {
            appid: l,
            uuid: gE(),
            userid: JSON.stringify(s.userid),
            start_time: Math.floor(Date.now() / 1e3),
            ignore_event: !0
          }
        }, {
          onSuccess: p => {
            if (et() && a) {
              pZe(p.ws_url, p.speech_id, d, r, i, JSON.stringify(s.userid), p.otid, l), e(p.otid), t("liveTranscription"), n(!0);
              return
            }
            const g = {
              action: Pi.BG_RECORDING_ACTION,
              type: "start",
              wsUrl: p.ws_url,
              speechId: p.speech_id,
              micEnabled: d,
              micMuted: r,
              deviceLabel: i,
              userId: JSON.stringify(s.userid),
              otid: p.otid,
              appId: l
            };
            Rn.runtime.sendMessage(g), e(p.otid), t("liveTranscription")
          },
          onError: p => {
            const g = p;
            g.message && (Is(), Os(c.jsx(Ls, {
              "data-testid": "recording-error-toast",
              description: g.message,
              altText: g.message,
              showCloseIcon: !0
            }), {
              duration: Ss.error
            }))
          }
        })
      }, [r, i, a, e, t, n, o, s, l])
    }
  }
Background script obtains a tabCapture stream ID and starts the recording tabsrc/background-script/background.js
async function Nd(t) {
    if (!t.tabId) throw new Error("[Background] No active tab ID when starting recording");
    let e;
    try {
      e = await chrome.tabs.get(t.tabId)
    } catch (b) {
      throw new Error(`[Background] Active tab does not exist anymore, ${b}`)
    }
    const r = await navigator.permissions.query({
        name: "microphone"
      }),
      a = r.state === "prompt" || r.state === "denied",
      o = t.micEnabled && a,
      s = new Promise(b => {
        const P = y => {
          y.action === yt.RT_READY && (chrome.runtime.onMessage.removeListener(P), b(!0))
        };
        chrome.runtime.onMessage.addListener(P)
      }),
      p = await new Promise((b, P) => {
        chrome.tabs.create({
          url: chrome.runtime.getURL("recordingtab.html"),
          pinned: !0,
          active: o
        }, y => {
          if (chrome.runtime.lastError) return P(new Error(`[Background] ${chrome.runtime.lastError.message}`));
          b(y.id)
        })
      });
    if (!p) {
      Rr({
        errorMessage: "[Background] Failed to create recording tab",
        shouldThrow: !0,
        csTabId: e.id
      });
      return
    }
    await s;
    const d = {
      action: yt.RT_MIC_ENABLED,
      type: t.micEnabled ? "enabled" : "disabled"
    };
    chrome.tabs.sendMessage(p, d), Di(!0, p, e.id), await new Promise(b => {
      setTimeout(() => {
        b(!0)
      }, 1e3)
    });
    const m = await new Promise((b, P) => {
        chrome.tabCapture.getMediaStreamId({
          targetTabId: e.id,
          consumerTabId: p
        }, y => {
          if (chrome.runtime.lastError) {
            P(new Error(`[Background] Tabcapture: ${chrome.runtime.lastError.message}`));
            return
          }
          b(y)
        })
      }),
      g = {
        action: yt.RT_RECORDING_ACTION,
        type: "start",
        data: {
          wsUrl: t.wsUrl,
          speechId: t.speechId,
          tabAudioStreamId: m,
          micEnabled: t.micEnabled,
          micMuted: t.micMuted,
          deviceLabel: t.deviceLabel,
          tabFocused: o,
          userId: t.userId,
          otid: t.otid,
          appId: t.appId
        }
      };
    chrome.runtime.sendMessage(g)
  }
Recording tab opens the WebSocket, sends the start message, then sends binary audio bufferssrc/recordingTab/index.js
async function Cp(e) {
    if (pe = new Wr(e.wsUrl), Fr = e.speechId, yg = e.micEnabled, Fi = e.userId, Ii = e.otid, Pi = e.appId, ef = Math.floor(Date.now() / 1e3), Wi = new Op, pe.onopen = async () => {
        pe == null || pe.send(JSON.stringify({
          action: "start",
          speech_id: e.speechId,
          offset: ru
        })), fu = !0, await Wi.read(e.speechId, a => {
          if (a.length > 0)
            for (const l of a) pe == null || pe.send(l)
        })
      }, pe.onmessage = async a => {
        const l = Np(a.data);
        if (!l) return;
        const n = hp.safeParse(l);
        if (!n.success) return;
        n.data.type === "ack" && (ru = n.data.result.processed_offset + 1, ru > Ir && (await Wi.remove(e.speechId, 2 * (ru - Ir)), Ir = ru))
      }, pe.addEventListener("error", () => {
        fu = !1
      }), pe.addEventListener("close", () => {
        fu = !1
      }), e.micEnabled) {
      e.tabFocused && await navigator.mediaDevices.getUserMedia({
        audio: !0
      });
      const l = (await navigator.mediaDevices.enumerateDevices()).filter(u => u.label === e.deviceLabel && u.kind === "audioinput");
      let n = "default";
      l.length > 0 && l[0] && (n = l[0].deviceId), jt = await navigator.mediaDevices.getUserMedia({
        audio: {
          deviceId: {
            exact: n
          }
        }
      })
    }
    const t = await navigator.mediaDevices.getUserMedia({
      audio: {
        mandatory: {
          chromeMediaSource: "tab",
          chromeMediaSourceId: e.tabAudioStreamId
        }
      }
    });
    e.micMuted && tf(!0), Oe && await Oe.close(), Oe = new AudioContext({
      latencyHint: "interactive",
      sampleRate: 16e3
    }), Oe.state === "suspended" && (console.error("[Recording tab] AudioContext is suspended on creation"), await Oe.resume());
    try {
      await Oe.audioWorklet.addModule("src/shared/tab-audio-processor.js")
    } catch (a) {
      throw new Error(`[Recording tab] ${a}`)
    }
    su = Oe.createMediaStreamSource(t), jt && (rl = Oe.createMediaStreamSource(jt)), su.connect(Oe.destination), xt = pg(rl, su, Oe), cu = new AudioWorkletNode(Oe, "tab-audio-processor"), xt.connect(cu), cu.port.onmessage = async a => {
      const l = a.data.audioBuffer.buffer;
      fu && (pe == null || pe.send(l)), await Wi.append(e.speechId, l)
    }
  }
Audio worklet converts page audio into Int16 buffers posted to the recording tabsrc/shared/tab-audio-processor.js
var _ = function(t) {
  "use strict";
  var l = Object.defineProperty;
  var b = (t, e, r) => e in t ? l(t, e, {
    enumerable: !0,
    configurable: !0,
    writable: !0,
    value: r
  }) : t[e] = r;
  var f = (t, e, r) => b(t, typeof e != "symbol" ? e + "" : e, r);
  class e extends AudioWorkletProcessor {
    constructor() {
      super(...arguments);
      f(this, "bufferSize", 1024);
      f(this, "buffer", new Int16Array(this.bufferSize));
      f(this, "bytesWritten", 0)
    }
    isBufferFull() {
      return this.bytesWritten === this.bufferSize
    }
    appendToBuffer(s) {
      this.isBufferFull() && this.flush(), this.buffer[this.bytesWritten] = s, this.bytesWritten += 1
    }
    flush() {
      let s = this.buffer;
      this.bytesWritten < this.bufferSize && (s = s.slice(0, this.bytesWritten)), this.port.postMessage({
        eventType: "data",
        audioBuffer: s
      }), this.bytesWritten = 0
    }
    process(s) {
      const a = s.length;
      for (let u = 0; u < a; u++) {
        const i = s[u];
        if (!(!i || !i[0]))
          for (let o = 0; o < i[0].length; o++) {
            if (!i.length) continue;
            const n = Math.max(-1, Math.min(1, 1 * i[0][o])),
              h = n < 0 ? n * 32768 : n * 32767;
            this.appendToBuffer(h)
          }
      }
      return !0
    }
  }
  return registerProcessor("tab-audio-processor", e), t.TabAudioProcessor = e, Object.defineProperty(t, Symbol.toStringTag, {
    value: "Module"
  }), t
}({});
05EvidenceTHIRD PARTY LIST
Remote service receiving the recording stream
  • ws.aisense.com

    Otter.ai transcription WebSocket that receives the start message and browser-tab audio frames.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

Otter.ai session cookies copied to Aisense API

The extension reads csrftoken/sessionid cookies from your Otter.ai session, writes the same names/values for api.aisense.com, and calls the Aisense login CSRF endpoint.

Unauthenticated testing didn't trigger this; it needs a session first.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You use the extension while signed in to Otter.ai.

The extension did this

The background service worker reads your Otter.ai session cookies and writes matching cookies for the Aisense API domain.

02EvidenceFIELD TABLE
Cookie values copied between domains
FieldValueWhy it matters
Otter CSRF token
csrftoken=9f0a7b6c2d1e4a8b9c0d3e5f6a7b8c9d (illustrative)This value links browser requests back to your signed-in Otter.ai session.
Otter session ID
sessionid=4b7f2d9e8c1a43f0a6d5c2b1e9f08372 (illustrative)This value represents your active Otter.ai browser session and can carry account context to the API domain.
Destination API domain
https://api.aisense.comThis is the domain that receives the copied cookie names and values.
03EvidenceNETWORK CAPTURE
Captured request
GEThttps://api.aisense.com/api/v1/login_csrf
The source calls this endpoint after setting the Aisense-domain cookies; unauthenticated dynamic analysis did not capture a live response.
Headers
Content-Typetext
04EvidenceCODE COMPARE
The code that does this

Background helpers copy Otter cookies to the Aisense API domain

What it actually does
Cookie-copying login CSRF helpersrc/background-script/background.js
async function fd() {
    const t = "https://otter.ai",
      e = "https://api.aisense.com";
    try {
      const r = await de(t, "csrftoken"),
        a = await de(t, "sessionid");
      await wn("csrftoken", r, e), await wn("sessionid", a, e)
    } catch {}
    const n = await fetch(`${e}/api/v1/${fe.LOGIN_CSRF}`, {
      method: "GET",
      headers: {
        "Content-Type": "text"
      }
    });
    try {
      const r = await de(t, "csrftoken"),
        a = await de(t, "sessionid");
      await wn("csrftoken", r, e), await wn("sessionid", a, e)
    } catch {}
    return n.json()
  }
Cookie readersrc/background-script/background.js
function de(t, e) {
    return new Promise((n, r) => {
      chrome.cookies.get({
        url: t,
        name: e
      }, function(a) {
        a ? n(a.value) : r(chrome.runtime.lastError)
      })
    })
  }
Cookie writersrc/background-script/background.js
function wn(t, e, n) {
    return new Promise((r, a) => {
      chrome.cookies.set({
        name: t,
        value: e,
        url: n
      }, o => {
        o ? r(o.value) : a(chrome.runtime.lastError)
      })
    })
  }
05EvidenceTHIRD PARTY LIST
Remote domains involved in this path
  • otter.ai

    Provides the csrftoken and sessionid cookie values read by the background service worker.

  • api.aisense.com

    Receives csrftoken and sessionid cookies and the /api/v1/login_csrf request.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

Greenhouse candidate name sent to Otter search

With Greenhouse integration on, clicking Otter's control on an interview page reads the candidate name, page URL, job title, and form context, then queries Otter's advanced_search endpoint for matches.

No live request captured.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You use Otter's Greenhouse integration on an interview feedback page.

The extension did this

The content script reads candidate and form context, then searches Otter conversations using the candidate name.

02EvidenceFIELD TABLE
Greenhouse page data used by the integration
FieldValueWhy it matters
Candidate name
Jane Candidate (illustrative)This identifies the applicant whose Greenhouse page you are viewing.
Greenhouse page URL
https://app.greenhouse.io/guides/interview_kits/12345/people/67890 (illustrative)This ties the action to a specific Greenhouse workflow page in your browser.
Interview form fields
Problem Solving, Communication, Technical Depth (illustrative)This describes the evaluation fields visible on the page.
Job and interview metadata
Senior Product Manager, Onsite Interview (illustrative)This adds hiring context to the transcript search and generated feedback.
03EvidenceNETWORK CAPTURE
Captured request
GEThttps://otter.ai/forward/api/v1/advanced_search?query=Jane%20Candidate&relevance=true&size=5
The source builds this GET pattern from the candidate name; no live Greenhouse response was recorded in the supplied evidence.
04EvidenceCODE COMPARE
The code that does this

Greenhouse content script extracts candidate/page context and sends the search message

What it actually does
Candidate-name extractorsrc/prototypes/greenhouse/content-script.js
async function Cy() {
    var e, a;
    const t = await $t.candidateName.getSelectorString();
    return ((a = (e = document.querySelector(t)) == null ? void 0 : e.textContent) == null ? void 0 : a.trim()) || void 0
  }
Greenhouse page-context assemblersrc/prototypes/greenhouse/content-script.js
async function Ry() {
    const [t, e, a, l, r, n, o, i, c] = await Promise.all([_y(), Ay(), ky(), Ty(), Dy(), Fy(), Ny(), zy(), Cy()]), u = {
      formFields: e,
      pageUrl: window.location.href,
      extractedAt: new Date().toISOString()
    };
    if (t !== "unknown" && (u.pageType = t), i !== void 0 && (u.jobTitle = i), c !== void 0 && (u.candidateName = c), l !== void 0 || a.length > 0 || r !== void 0 || n.length > 0 || o !== void 0) {
      const g = {};
      l !== void 0 && (g.interviewStep = l), a.length > 0 && (g.focusAttributes = a), r !== void 0 && (g.interviewGuide = r), n.length > 0 && (g.scorecardAttributes = n), o !== void 0 && (g.keyTakeawaysPrompt = o), u.metadata = g
    }
    return u
  }
User-click handlersrc/prototypes/greenhouse/content-script.js
async function jy() {
    var r, n;
    if (!await My()) {
      console.warn("[Greenhouse Prototype] Not an interview feedback page, skipping"), alert("This doesn't appear to be a Greenhouse interview feedback page.");
      return
    }
    const t = await Ry();
    console.info("[Greenhouse Prototype] Extracted data:", t);
    const e = t.formFields.length > 0,
      a = !!((n = (r = t.metadata) == null ? void 0 : r.scorecardAttributes) != null && n.length);
    if (!e && !a) {
      console.warn("[Greenhouse Prototype] No form fields or scorecard attributes found"), alert("No form fields or scorecard attributes found on this page. The page structure may have changed.");
      return
    }
    if (!t.candidateName) {
      console.warn("[Greenhouse Prototype] No candidate name found"), alert("Cannot find candidate name on this page. Please make sure you are on a Greenhouse interview feedback page.");
      return
    }
    const l = await Uy(t.candidateName);
    l && await By(l, t)
  }
Search message sendersrc/prototypes/greenhouse/content-script.js
async function Uy(t) {
    console.info(`[Greenhouse Prototype] Searching for conversations with candidate: ${t}`);
    try {
      const e = {
          type: "GREENHOUSE_SEARCH_CONVERSATIONS",
          candidateName: t
        },
        {
          success: a,
          error: l,
          conversations: r = []
        } = await chrome.runtime.sendMessage(e);
      if (!a) return console.error("[Greenhouse Prototype] Failed to search conversations:", l), alert(`Failed to search conversations: ${l}`), null;
      if (console.info("[Greenhouse Prototype] Found conversations:", r), r.length === 0) return alert(`No interview conversations found for "${t}". Please make sure you have a recorded interview with this candidate.`), null;
      if (r.length === 1) {
        const {
          speech_otid: o,
          title: i
        } = r[0];
        return console.info(`[Greenhouse Prototype] Auto-selected conversation with speech_otid=${o} and title=${i}`), o
      }
      const n = await Oy(r);
      return n ? n.speech_otid : (console.info("[Greenhouse Prototype] User cancelled conversation selection"), null)
    } catch (e) {
      return console.error("[Greenhouse Prototype] Uncaught error in findSpeechOtidByCandidateName:", e), alert("Failed to find conversation. Please try again."), null
    }
  }
05EvidenceCODE COMPARE
The code that does this

Background service worker converts the candidate name into an Otter search request

What it actually does
Advanced-search fetch helpersrc/background-script/background.js
async function yd(t) {
    const e = `${rn}/advanced_search?query=${encodeURIComponent(t)}&relevance=true&size=5`,
      n = await de(Nt, "csrftoken");
    return await fetch(e, {
      method: "GET",
      credentials: "include",
      headers: {
        "x-csrftoken": n
      }
    })
  }
Search response handlersrc/background-script/background.js
async function Pd(t) {
    try {
      console.info("[Greenhouse Prototype] Searching for conversations with candidate:", t);
      const e = await yd(t);
      if (console.info("[Greenhouse Prototype] Search response status:", e.status), !e.ok) {
        const r = await e.text();
        return console.error("[Greenhouse Prototype] Search API error:", r), {
          success: !1,
          error: `Search API returned ${e.status}: ${r}`
        }
      }
      const n = await e.json();
      return console.info("[Greenhouse Prototype] Search results:", n), console.info("[Greenhouse Prototype] Conversations found:", n.hits.length), {
        success: !0,
        conversations: n.hits
      }
    } catch (e) {
      return console.error("[Greenhouse Prototype] Search exception:", e), {
        success: !1,
        error: e instanceof Error ? e.message : String(e)
      }
    }
  }
Greenhouse message dispatchersrc/background-script/background.js
async function Id(t) {
    if (t.type === "GREENHOUSE_SEARCH_CONVERSATIONS") {
      console.info("[Greenhouse Prototype] Searching for conversations");
      const e = await Pd(t.candidateName);
      return console.info("[Greenhouse Prototype] searchConversations result:", e), e
    }
    if (t.type === "GREENHOUSE_GENERATE_FORM_FILLS") {
      console.info("[Greenhouse Prototype] Generating form fills");
      const e = await wd(t.speechOtid, t.payload);
      return console.info("[Greenhouse Prototype] generateGreenhouseFormFills result:", e), e
    }
    return {
      success: !1,
      error: "Unknown message type"
    }
  }
06EvidenceTHIRD PARTY LIST
Remote service receiving the Greenhouse search query
  • otter.ai

    Receives the candidate-name query at /forward/api/v1/advanced_search.

+1 more finding not shown

Updated 30 September 2026bnmojkbbkkonlmlfgejehefjldooiedp