Is Fireflies: AI meeting notes safe?
Fireflies is medium risk. When a Fireflies recording starts in Meet, the extension creates a meeting record, starts recording, converts captured audio to an MP3 blob, requests a signed upload URL, and uploads it as audio/mp3. No request body captured live.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Fireflies Uploads Meeting Audio to Backend Storage
When a Fireflies recording starts in Meet, the extension creates a meeting record, starts recording, converts captured audio to an MP3 blob, requests a signed upload URL, and uploads it as audio/mp3.
No request body captured live.
You start a Fireflies recording from a Google Meet page.
The extension records meeting audio, converts it to MP3, and uploads it to Fireflies storage.
It also sends meeting setup metadata through the Fireflies GraphQL service.
| Field | Value | Why it matters | |
|---|---|---|---|
Meeting URL | https://meet.google.com/abc-defg-hij | Links the recording to the Meet session you were in. | |
Meeting title | Weekly Product Sync | Adds human-readable meeting context to the recording record. | |
Meeting identifier | meet_7b2f1c9e4a8d4c2f | Ties the uploaded audio file to one Fireflies meeting record. | |
Audio MIME type | audio/mp3 | Labels the uploaded recording as an MP3 audio file. | |
Audio size | 1843200 | Reveals how large the captured meeting audio file is. | |
Audio device list | {"deviceId":"default","label":"Built-in Microphone","groupId":"8f24cce7a6"} | Describes the microphone inputs available during the recording setup. |
| Content-Type | application/json |
| Authorization | Bearer <redacted> |
| Content-Type | audio/mp3 |
Meet recording creates metadata and prepares the audio file
var manualStartRecordingProps = {
title: meetTitle,
url: window.location.href,
isLoading: !isGrowthBookReady,
onStart: function onStart() {
return relayMessage(_misc.Msg.START_MANUAL_RECORDING);
},
onInviteSuccess: closePopup,
canInviteNotetaker: invitedNotetaker === false,
location: "banner",
autoCaptureEnabled: true
}; var startRecording = /*#__PURE__*/ function() {
var _ref11 = (0, _asyncToGenerator2["default"])(/*#__PURE__*/ (0, _regenerator2["default"])().m(function _callee9() {
var isAutoCaptured, shouldCheckFred, notificationShown, _Text$notifications$C, heading, text, subHeading, meeting, markRecordingOnPage, resp, meetingInstance, audioDevices, allDevices, _args9 = arguments, _t3, _t4, _t5, _t6, _t7, _t8, _t9, _t0, _t1, _t10;
return (0, _regenerator2["default"])().w(function(_context9) {
while(true)switch(_context9.p = _context9.n){
case 0:
isAutoCaptured = _args9.length > 0 && _args9[0] !== undefined ? _args9[0] : false;
shouldCheckFred = _args9.length > 1 && _args9[1] !== undefined ? _args9[1] : false;
if (!isCompanionMode()) {
_context9.n = 7;
break;
}
notificationShown = false; // Show a message to the user about companion mode limitations
_context9.p = 1;
_Text$notifications$C = _localization["default"].notifications.CompanionModeDetected, heading = _Text$notifications$C.title, text = _Text$notifications$C.message, subHeading = _Text$notifications$C.subHeading;
showNotification({
type: "screen-notification",
heading: heading,
subHeading: subHeading,
text: text,
ttlSeconds: 30
});
notificationShown = true;
// Log additional details about the meeting when companion mode is detected
_context9.n = 2;
return waitForExtensionId();
case 2:
_t3 = _misc.sendToBackground;
_t4 = extensionId.current;
_t5 = CONTENT_SCRIPT_ID;
_t6 = "Companion mode detected during recording attempt: ".concat(window.location.href, ", Meeting title: ");
_context9.n = 3;
return getMeetingTitle();
case 3:
_t7 = _t6.concat.call(_t6, _context9.v, ", isAutoCaptured: ").concat(isAutoCaptured);
_t8 = {
script: _t5,
errorMessage: _t7
};
_context9.n = 4;
return _t3(_t4, {
name: "capture-content-script-error",
body: _t8
});
case 4:
_context9.n = 6;
break;
case 5:
_context9.p = 5;
_t9 = _context9.v;
if (!notificationShown) {
// Fallback in case localization is not available
showNotification({
type: "screen-notification",
heading: "Audio Recording Not Available",
subHeading: "Companion Mode Detected",
text: "You're in Google Meet's companion mode, which doesn't allow microphone access. The recording may result in blank audio.",
ttlSeconds: 30
});
notificationShown = true;
}
case 6:
return _context9.a(2);
case 7:
_context9.n = 8;
return getMeetingTitle();
case 8:
_t0 = _context9.v;
_t1 = window.location.href;
meeting = {
title: _t0,
url: _t1
};
markRecordingOnPage = function markRecordingOnPage() {
hasRecordedOnPage.current = true;
}; // Add listener if auto capture allowed and fred toggle is disabled
if (isAutoCaptured && shouldCheckFred) removeUserJoinListener.current = (0, _misc.sendStenographerCmd)("getUser", undefined, handleGmeetAttendee);
_context9.n = 9;
return waitForExtensionId();
case 9:
_context9.n = 10;
return (0, _misc.sendToBackground)(extensionId.current, {
name: "create-meeting",
body: {
meeting: meeting,
isAutoCaptured: isAutoCaptured,
createdBy: "google-meet-".concat(isAutoCaptured ? "auto" : "manual", "-capture")
}
});
case 10:
resp = _context9.v;
meetingInstance = resp.data;
if (!meetingInstance) {
_context9.n = 16;
break;
}
audioDevices = [];
_context9.p = 11;
_context9.n = 12;
return navigator.mediaDevices.enumerateDevices();
case 12:
allDevices = _context9.v;
audioDevices = allDevices.filter(function(d) {
return d.kind === "audioinput";
}).map(function(d) {
return {
deviceId: d.deviceId,
label: d.label || "",
groupId: d.groupId
};
});
_context9.n = 14;
break;
case 13:
_context9.p = 13;
_t10 = _context9.v;
case 14:
_context9.n = 15;
return waitForExtensionId();
case 15:
_context9.n = 16;
return (0, _misc.sendToBackground)(extensionId.current, {
name: "start-recording",
body: {
id: meetingInstance.id,
isAutoCaptured: isAutoCaptured,
audioDevices: audioDevices,
recordingType: recordingType.current
}
}).then(function(response) {
if (!(response !== null && response !== void 0 && response.status)) return;
var shouldToggleCaptions = !hasRecordedOnPage.current;
markRecordingOnPage();
if (shouldToggleCaptions) toggleCCButton();
setMeeting(meetingInstance);
meetingIdRef.current = meetingInstance.id;
setMeetingState("STARTED");
showNotification();
});
case 16:
return _context9.a(2);
}
}, _callee9, null, [
[
11,
13
],
[
1,
5
]
]);
}));
return function startRecording() {
return _ref11.apply(this, arguments);
};
}(); meetingAudio = new Blob(validAudioRecords.map(function(record) {
return record.blob;
}), {
type: "audio/webm"
});
if (!(!meetingAudio || meetingAudio.size === 0)) {
_context9.n = 10;
break;
}
return _context9.a(2, handleTranscodingError((0, _objectSpread2["default"])((0, _objectSpread2["default"])({}, transcodeErrorParams), {}, {
state: _types.MeetingState.NOT_ENOUGH_AUDIO_SEGMENT,
processed: true
})));
case 10:
_context9.p = 10;
if (ffmpegInstance) {
_context9.n = 14;
break;
}
ffmpegInstance = new _ffmpeg2.FFmpeg((_chrome$runtime = chrome.runtime) === null || _chrome$runtime === void 0 ? void 0 : _chrome$runtime.getURL("assets/ffmpeg/ffmpeg-core.wasm"), (_chrome$runtime2 = chrome.runtime) === null || _chrome$runtime2 === void 0 ? void 0 : _chrome$runtime2.getURL("assets/ffmpeg/ffmpeg-core.js"), (_chrome$runtime3 = chrome.runtime) === null || _chrome$runtime3 === void 0 ? void 0 : _chrome$runtime3.getURL("assets/ffmpeg/ffmpeg-core.worker.js"));
_context9.p = 11;
_context9.n = 12;
return ffmpegInstance.load();
case 12:
_context9.n = 14;
break;
case 13:
_context9.p = 13;
_t6 = _context9.v;
throw new Error("FFMPEG_ASSET_UNAVAILABLE: ".concat(_t6.message));
case 14:
_context9.p = 14;
_context9.n = 15;
return (0, _ffmpeg.fetchFile)(meetingAudio);
case 15:
audio = _context9.v;
_context9.n = 17;
break;
case 16:
_context9.p = 16;
_t7 = _context9.v;
throw new Error("AUDIO_BLOB_READ_FAILED: ".concat(_t7.message));
case 17:
_offscreen.OffscreenLogger.info("transcoding audio for meeting ".concat(meetingId, " with ").concat(tries, " tries"));
_offscreen.OffscreenLogger.info("transcoding audio with ".concat(meetingAudio.size, " bytes"));
_context9.n = 18;
return ffmpegInstance.addFile("audio_".concat(meetingId, ".mp3"), audio);
case 18:
_context9.n = 19;
return ffmpegInstance.run("-i", "audio_".concat(meetingId, ".mp3"), "output_".concat(meetingId, ".mp3"));
case 19:
_context9.n = 20;
return ffmpegInstance.getFile("output_".concat(meetingId, ".mp3"));
case 20:
file = _context9.v;
blob = new Blob([
file
], {
type: "audio/mp3"
});
if (!(!blob || blob.size === 0)) {
_context9.n = 21;
break;
}
throw new Error("meeting blob is not created properly");
case 21:
_context9.p = 21;
_context9.n = 22;
return databaseService.db.meetings.update(meetingId, {
audio_file: blob,
has_audio: 1
});The service worker requests a signed URL and uploads the MP3
var START_UPLOAD_MUTATION = (0, _client.gql)(_templateObject4 || (_templateObject4 = (0, _taggedTemplateLiteral2["default"])([
"\n mutation startUpload($meetingId: String!, $mimeType: String!, $size: Float!, $client: String!) {\n startUpload(meetingId: $meetingId, mimeType: $mimeType, size: $size, client: $client) {\n uploadUrl\n mediaUrl\n }\n }\n"
])));
function startUploadViaGraphQL(_x7, _x8) {
return _startUploadViaGraphQL.apply(this, arguments);
}
function _startUploadViaGraphQL() {
_startUploadViaGraphQL = (0, _asyncToGenerator2["default"])(/*#__PURE__*/ (0, _regenerator2["default"])().m(function _callee4(token, input) {
var client, _yield$client$mutate4, data, uploadUrl, _t4;
return (0, _regenerator2["default"])().w(function(_context4) {
while(true)switch(_context4.p = _context4.n){
case 0:
getGraphqlLog().info("startUpload | meetingId=".concat(input.meetingId, " | mimeType=").concat(input.mimeType, " | size=").concat(input.size));
client = (0, _appollo.getApolloClient)(token);
_context4.p = 1;
_context4.n = 2;
return client.mutate({
mutation: START_UPLOAD_MUTATION,
variables: input
});
case 2:
_yield$client$mutate4 = _context4.v;
data = _yield$client$mutate4.data;
uploadUrl = data.startUpload.uploadUrl;
getGraphqlLog().info("startUpload success | meetingId=".concat(input.meetingId, " | hasUrl=").concat(!!uploadUrl));
return _context4.a(2, uploadUrl);
case 3:
_context4.p = 3;
_t4 = _context4.v;
getGraphqlLog().error("startUpload failed | meetingId=".concat(input.meetingId, " | size=").concat(input.size, " | error=").concat(_t4.message));
throw _t4;
case 4:
return _context4.a(2);
}
}, _callee4, null, [
[
1,
3
]
]);
}));
return _startUploadViaGraphQL.apply(this, arguments);
}var UploadAudioTask = exports.UploadAudioTask = /*#__PURE__*/ function(_Task) {
function UploadAudioTask() {
(0, _classCallCheck2["default"])(this, UploadAudioTask);
return (0, _callSuper2["default"])(this, UploadAudioTask, [
"UploadAudioTask"
]);
}
(0, _inherits2["default"])(UploadAudioTask, _Task);
return (0, _createClass2["default"])(UploadAudioTask, [
{
key: "do",
value: function() {
var _do2 = (0, _asyncToGenerator2["default"])(/*#__PURE__*/ (0, _regenerator2["default"])().m(function _callee2(params) {
var startTime, meeting, report, errorMessage, accessToken, audio, meetingId, uploadUrl, err, uploadStartTime, uploadResult, _uploadResult$error, _uploadResult$error2, _uploadResult$error3, uploadEndTime, endTime, _t2;
return (0, _regenerator2["default"])().w(function(_context2) {
while(true)switch(_context2.p = _context2.n){
case 0:
startTime = Date.now();
_context2.n = 1;
return _singletons.meetingService.getMeeting(params.id);
case 1:
meeting = _context2.v;
report = _logger.LogReport["with"]("Meeting - processing report", params.id);
if (meeting.parse_id) {
_context2.n = 2;
break;
}
errorMessage = "Meeting audio cannot be uploaded because parse_id is missing";
report.error(errorMessage);
throw new Error(errorMessage);
case 2:
if (!meeting.has_uploaded_audio) {
_context2.n = 4;
break;
}
_context2.n = 3;
return _singletons.meetingService.update(meeting.id, {
upload_progress: 60
});
case 3:
return _context2.a(2, {
id: params.id
});
case 4:
_context2.n = 5;
return _singletons.authService.getAccessToken();
case 5:
accessToken = _context2.v;
if (accessToken) {
_context2.n = 6;
break;
}
console.error("no token found");
return _context2.a(2, {});
case 6:
_context2.n = 7;
return _singletons.meetingService.update(meeting.id, {
upload_progress: 10
});
case 7:
audio = meeting.audio_file;
if (audio) {
_context2.n = 8;
break;
}
report.error("audio file not found for meeting ".concat(params.id));
throw new Error(_misc.transcodeError);
case 8:
meetingId = meeting.parse_id; // Step 1: Get signed URL via GraphQL startUpload mutation
_context2.p = 9;
_context2.n = 10;
return (0, _mediaStorageGraphql.startUploadViaGraphQL)(accessToken, {
meetingId: meetingId,
mimeType: "audio/mp3",
size: audio.size,
client: _constants.configStore.getConfig("APP_CODE")
});
case 10:
uploadUrl = _context2.v;
if (uploadUrl) {
_context2.n = 11;
break;
}
throw new Error("startUpload response missing uploadUrl");
case 11:
if (uploadUrl.startsWith("https://")) {
_context2.n = 12;
break;
}
throw new Error("Signed URL must use HTTPS protocol");
case 12:
report.debug("Obtained signed URL for upload: ".concat(uploadUrl.substring(0, 50), "..."));
_context2.n = 14;
break;
case 13:
_context2.p = 13;
_t2 = _context2.v;
err = _t2;
throw new _errors.TaskWarning("signed-url-request-failed", {
message: "Failed to obtain signed URL for audio upload",
id: params.id,
meta: {
parseId: meeting.parse_id,
errorMessage: err.message
}
});
case 14:
// Step 2: Upload audio to cloud storage using signed URL
uploadStartTime = Date.now();
_context2.n = 15;
return (0, _uploadWithRetry.uploadWithRetry)({
url: uploadUrl,
blob: audio,
contentType: "audio/mp3",
timeout: _uploadWithRetry.AUDIO_UPLOAD_CONFIG.timeout,
retryHandler: _uploadWithRetry.AUDIO_UPLOAD_CONFIG.retryHandler,
onProgress: function() {
var _onProgress = (0, _asyncToGenerator2["default"])(/*#__PURE__*/ (0, _regenerator2["default"])().m(function _callee(progress) {
var taskProgress, _t;
return (0, _regenerator2["default"])().w(function(_context) {
while(true)switch(_context.p = _context.n){
case 0:
_context.p = 0;
// Note: Service workers only have fetch API which doesn't support upload progress
// onProgress is called at 0% (start) and 100% (complete) only
// Map to task progress: 10% (start) -> 55% (complete) -> 60% (metadata updated)
taskProgress = 10 + Math.floor(progress.percentage * 0.45);
_context.n = 1;
return _singletons.meetingService.update(meeting.id, {
upload_progress: taskProgress
});
case 1:
if (progress.percentage === 0) report.debug("Starting upload: ".concat(audio.size, " bytes"));
else if (progress.percentage === 100) report.debug("Upload complete: ".concat(audio.size, " bytes in ").concat(Date.now() - uploadStartTime, "ms"));
_context.n = 3;
break;
case 2:
_context.p = 2;
_t = _context.v;
report.error("Failed to update upload progress: ".concat(_t.message || _t, ". ") + "Context: meetingId=".concat(meeting.id, ", audioSize=").concat(audio.size, ", ") + "uploadStartTime=".concat(uploadStartTime, ", progress=").concat(progress.percentage, "%"));
// Swallow error to prevent rejecting the upload operation
case 3:
return _context.a(2);
}
}, _callee, null, [
[
0,
2
]
]);
}));
function onProgress(_x2) {
return _onProgress.apply(this, arguments);
}
return onProgress;
}()
});
case 15:
uploadResult = _context2.v;
if (uploadResult.success) {
_context2.n = 16;
break;
}
uploadEndTime = Date.now();
throw new _errors.TaskWarning("audio-upload-to-storage-failed", {
message: "Failed to upload audio to cloud storage",
id: params.id,
meta: {
parseId: meeting.parse_id,
uploadUrl: uploadUrl.substring(0, 100),
statusCode: uploadResult.statusCode,
errorType: (_uploadResult$error = uploadResult.error) === null || _uploadResult$error === void 0 ? void 0 : _uploadResult$error.type,
errorMessage: (_uploadResult$error2 = uploadResult.error) === null || _uploadResult$error2 === void 0 ? void 0 : _uploadResult$error2.message,
audioSize: audio.size,
uploadDuration: uploadEndTime - uploadStartTime,
attempt: (_uploadResult$error3 = uploadResult.error) === null || _uploadResult$error3 === void 0 ? void 0 : _uploadResult$error3.context.attempt
}
});
case 16:
report.info("Audio upload succeeded: ".concat(audio.size, " bytes uploaded in ").concat(Date.now() - uploadStartTime, "ms"));
endTime = Date.now();
(0, _analytics.analyticsTrack)(_analytics2.SegmentAnalyticsEvents.UPLOAD_MP3_AUDIO, (0, _objectSpread2["default"])((0, _objectSpread2["default"])({}, meeting.meeting_analytics || {}), {}, {
uploadTime: endTime - startTime,
audioChunksCount: meeting.audio_segments,
sizeMp3: audio.size,
parseId: meeting.parse_id
}));
_context2.n = 17;
return _singletons.meetingService.update(params.id, {
has_uploaded_audio: 1,
upload_progress: 60
});
case 17:
_offscreen["default"].send("remove-audio-chunks", meeting.id);
return _context2.a(2, {
id: params.id
});
}
}, _callee2, null, [
[
9,
13
]
]);
}));
function _do(_x) {
return _do2.apply(this, arguments);
}
return _do;
}()
}
]);
}(_task.Task); */ function uploadWithRetry(_x) {
return _uploadWithRetry.apply(this, arguments);
}
/**
* Upload configuration for audio files specifically
* Pre-configured with audio-appropriate timeout and retry settings
*/ function _uploadWithRetry() {
_uploadWithRetry = (0, _asyncToGenerator2["default"])(/*#__PURE__*/ (0, _regenerator2["default"])().m(function _callee(options) {
var url, blob, contentType, _options$timeout, timeout, _options$retryHandler, retryHandler, onProgress, api, networkError, _t;
return (0, _regenerator2["default"])().w(function(_context) {
while(true)switch(_context.p = _context.n){
case 0:
url = options.url, blob = options.blob, contentType = options.contentType, _options$timeout = options.timeout, timeout = _options$timeout === void 0 ? 1800000 : _options$timeout, _options$retryHandler = options.retryHandler, retryHandler = _options$retryHandler === void 0 ? new _retry.RetryHandler((0, _objectSpread2["default"])((0, _objectSpread2["default"])({}, _retry.DEFAULT_RETRY_CONFIG), {}, {
maxRetries: 3,
// More conservative for uploads
baseDelay: 1000,
maxDelay: 10000
})) : _options$retryHandler, onProgress = options.onProgress; // Report upload start (0%)
if (!onProgress) {
_context.n = 1;
break;
}
_context.n = 1;
return onProgress({
loaded: 0,
total: blob.size,
percentage: 0
});
case 1:
// Create FFApi instance without baseUrl (we have full URL) and without auth (signed URLs are pre-authorized)
api = new _ffApi.FFApi("", undefined, {
timeout: timeout,
retryHandler: retryHandler
});
_context.p = 2;
_context.n = 3;
return api.putBlob(url, blob, {
"Content-Type": contentType
}, true // withRetry
);
case 3:
if (!onProgress) {
_context.n = 4;
break;
}
_context.n = 4;
return onProgress({
loaded: blob.size,
total: blob.size,
percentage: 100
});
case 4:
return _context.a(2, {
success: true,
statusCode: 200 // Successful upload (2xx status)
});
case 5:
_context.p = 5;
_t = _context.v;
// FFApi throws NetworkError on failure
networkError = _t instanceof _errors.NetworkError ? _t : new _errors.NetworkError(_t.message || "Upload failed", _errors.NetworkErrorType.UNKNOWN_ERROR, {
url: url,
method: "PUT",
attempt: retryHandler.currentAttempt || 1,
maxRetries: retryHandler.maxRetries,
timestamp: Date.now(),
isOnline: navigator.onLine
});
return _context.a(2, {
success: false,
statusCode: networkError.statusCode,
error: networkError
});
}
}, _callee, null, [
[
2,
5
]
]);
}));
return _uploadWithRetry.apply(this, arguments);
}- gateway.fireflies.ai
Receives GraphQL mutations that create meeting records and return signed upload URLs.
- media-storage.firefliesapp.com
Receives the MP3 meeting audio blob through the signed upload URL.
- calendar.firefliesapp.com
Configured endpoint for calendar event access around meeting workflows.
- user-service-rest.fireflies.ai
Configured endpoint for Fireflies user authentication and profile requests.