Lupyd.
Encryption Private Push Notifications

How Encrypted Push Notifications Work Without Google Seeing Your Messages

Push notifications are where many encrypted apps leak message previews to Google. Here's how Lupyd sends a content-free FCM wake-up push, then fetches and decrypts messages on the phone, with real Kotlin and Rust code.

L

Lupyd Team

Lupyd

Published: Oct 13, 2026
•
10 min read
How Encrypted Push Notifications Work Without Google Seeing Your Messages

A Lupyd Push Notification, Step by Step

  1. 01 Firefly server → Google FCM { "ty": "umsg" }, no sender, no text, offline devices only
  2. 02 FCM → Android wakes the app · Kotlin calls Rust over JNI
  3. 03 Rust → Firefly server connects over WebSocket · receives encrypted messages
  4. 04 On the phone decrypt (Signal / MLS) · mute and mention checks
  5. 05 Local notification "Priya: are we still on for 3pm?", written on the device

Google delivers a wake-up signal with no message data. The message itself is fetched and decrypted on the phone.

Push notifications are where many "encrypted" apps quietly leak. To show "Priya: are we still on for 3pm?" on your lock screen, something has to know the sender and the text. If that something is the push service, then Google (for Android's Firebase Cloud Messaging) or Apple sees it too. This post explains how Lupyd delivers notifications for end-to-end encrypted messages on Android without putting any message content in the push.

The Push Notification Problem

On Android, apps can't keep a connection open in the background forever. The OS kills idle processes to save battery. The official way to reach a sleeping app is Firebase Cloud Messaging (FCM): your server sends a message to Google, Google delivers it to the device, and Android wakes your app to handle it.

The easy approach is to put the notification text in the FCM message: sender name, message preview, maybe a group name. It works, it's instant, and it means every message preview passes through Google's infrastructure. For an end-to-end encrypted messenger, that's unacceptable.

There are two common ways to fix it:

  • Encrypted payload: put the ciphertext in the push, and decrypt it on the device. The content is hidden, but it's limited by FCM's payload size and adds complexity.
  • Wake-up only: send a push that carries nothing except "you have something new." The app wakes, connects to its own server and fetches the encrypted messages itself.

Lupyd uses the second approach. Here's how it works, end to end.

Step 1: The Server Sends a Content-Free Push

This is all the FCM message contains when the Firefly server notifies you of a new chat message or an incoming call:

// The entire push the server sends for a new message or call
json!({
    "message": {
        "token": token,
        "android": { "priority": "high" },
        "data": { "ty": "umsg" }
    }
})

No sender, no text, no group name, no message ID. Just "ty": "umsg" and a high-priority flag so Android delivers it promptly. Even if someone could read every FCM message, all they would learn is that a device received a notification at a certain time.

And the server only uses FCM when it has to. If your device already has a live WebSocket connection, the encrypted message goes straight down that connection and no push is sent at all:

if let Some(socket) = sockets.get(&device) {
    socket.send(encrypted_message).await;        // online: deliver directly
} else if !is_self {
    fcm.notify_user_with_new_msgs(device).await; // offline: content-free push
}

Step 2: Android Hands Off to Rust

On the phone, a standard FirebaseMessagingService receives the push. It does almost nothing itself. It packages the (tiny) payload and hands it to our Rust code through JNI:

override fun onMessageReceived(remoteMessage: RemoteMessage) {
    val payload = JSONObject(remoteMessage.data as Map<*, *>).toString()  // {"ty":"umsg"}
    EncryptionBridge.handleFcmMessage(this, payload, applicationInfo.dataDir) // hand off to Rust
}

This is possible because Lupyd's Android app is built with Tauri. The whole app, including the Firefly encryption client, is one Rust library (app_lib). Kotlin can call into it directly, even when the app's UI isn't running.

Step 3: Wake Up, Sync and Decrypt

The Rust handler starts the Firefly client. It restores the signed-in session, opens the local databases and connects to the Firefly server over a WebSocket. Then it waits up to eight seconds for the sync to deliver something:

// Wake the Firefly client: restore session, open databases, connect
initialize_firefly_client(app_data_dir).await?;

// The push says nothing, so wait for sync to deliver the real messages
tokio::time::timeout(Duration::from_secs(8), FCM_NOTIFY.notified()).await;

Over the WebSocket, the server delivers the pending messages exactly as it stored them: encrypted. The Firefly client decrypts them on the device, using the Signal protocol for direct messages and MLS for groups. The keys never leave the phone. That's the same decryption path the app uses when it's open. There's no separate, weaker "notification mode."

Step 4: Build the Notification on the Device

Once a message is decrypted, it's saved to the local store. If no UI is running to show it, Rust builds a native Android notification itself:

async fn on_message(&self, message: UserMessage) {  // already decrypted on the device
    store.insert_user_message(message.clone()).await;

    if EVENT_CHANNEL.receiver_count() == 0 {           // no UI running
        NotificationManager::get().handle_user_message_background(&message).await;
    }
    FCM_NOTIFY.notify_waiters();
}

The notification text, "Priya: are we still on for 3pm?", is created entirely on your phone from data your phone decrypted. Lupyd groups notifications per conversation using Android's MessagingStyle, showing the last few messages from each person or group, and tapping one deep-links straight to that chat or channel.

Mutes, Mentions and Foreground Suppression

Because the decision to notify happens on the device, after decryption, Lupyd can make smart decisions that a server-side push system couldn't make without reading your messages:

  • Foreground suppression. If you're already looking at that conversation, you don't get a notification for it.
  • Muted users, groups and channels, with optional expiry times.
  • Mention overrides. A muted channel can still notify you when someone writes @yourname or @everyone, depending on your preferences.
let mentioned = (text.contains(&format!("@{me}")) && prefs.notify_on_mentions)
    || (text.contains("@everyone") && prefs.notify_on_everyone);

if is_muted { mentioned && prefs.notify_on_mentions } else { true }

A server can only check for "@priya" in a message if it can read the message. Here, the check runs against the decrypted text on your device, so mention notifications work with end-to-end encryption.

Encrypted Inline Replies

Android lets you reply directly from the notification shade. In Lupyd, that reply goes through the same Rust bridge. It's encrypted on the device with the same Firefly client and sent over the encrypted channel, without opening the app:

// Inline reply: encrypted and sent by the same Rust client
external fun sendReply(context: Context, other: String, text: String,
                       isGroup: Boolean, appDataDir: String)

What About Incoming Calls?

Calls follow the same rule. When someone calls you and none of your devices are online, the server holds the call offer and sends the same content-free umsg push. Your phone wakes, connects, and only then receives the call details over the WebSocket. If a device reconnects within 45 seconds, the server forwards the pending offer to it. The caller's identity reaches your phone through Lupyd's own connection, not through Google. We covered the calling stack in Why Lupyd Chose Cloudflare for Calls.

What Google Can and Can't See

Information Typical push (text in payload) Lupyd wake-up push
Message text Visible to the push service Never in the push
Sender name Visible to the push service Never in the push
Group or channel name Often included Never in the push
That a device got a push, and when Visible Visible (unavoidable with FCM)

The last row is worth stating plainly. Any app that uses FCM reveals timing metadata to Google: this device token received a push at 14:02. A wake-up-only design minimizes what the push says, but it can't hide that a push happened. Sending pushes only to devices that are offline helps keep even that to a minimum.

The Tradeoffs

  • Slightly slower notifications. The phone has to wake, connect and sync before it can show anything. On a good connection that's quick. On a poor one, the notification arrives later than a text-in-payload push would.
  • Limited background time. Android gives a woken app a short window to do its work. If sync can't finish in that time (for example, on a very slow network), the notification may not appear until the app next connects.
  • Battery and data. Each push opens a real connection instead of just displaying text. Sending pushes only to offline devices keeps that cost down.
  • Decrypted data on the device. To show previews and search, messages are stored decrypted locally. Keep a screen lock and device encryption on.

For an end-to-end encrypted messenger, we think a slightly slower notification is the right price for keeping messages out of the push service.

Checklist: Building Private Push Notifications

  1. Send no content in the push. A type field is enough.
  2. Only push offline devices. Use your own connection when it's open.
  3. Reuse the app's normal decryption path for background sync. Don't build a weaker shortcut.
  4. Build notifications on the device, after decryption.
  5. Make mute and mention decisions on the device, where the plaintext already is.
  6. Keep secrets and message text out of logs, including push tokens and notification bodies.
  7. Be honest about metadata. Push timing is visible to the push provider.

Frequently Asked Questions

Can Google read Lupyd messages through push notifications?

No. Lupyd's push notifications for messages and calls contain only a type marker, with no message text, sender or group name. The app fetches the encrypted messages from Lupyd's server and decrypts them on your phone.

How do end-to-end encrypted apps show message previews in notifications?

The preview is created on the device. The push only wakes the app, which downloads and decrypts the new message locally, then builds the notification itself. Some apps instead put encrypted data in the push and decrypt it on the device.

What does Firebase Cloud Messaging see?

FCM sees the push payload and delivery metadata, such as which device token received a push and when. With Lupyd, the payload contains no message content, so FCM only learns that a push was delivered.

Why do encrypted notifications sometimes arrive late?

With a wake-up push, the phone has to connect to the messaging server and decrypt the message before it can show anything. On slow networks, or under aggressive battery optimization, that takes longer than displaying text sent inside the push.

Do mention notifications work in muted channels?

Yes, if you enable it. Lupyd checks for @mentions on your device after decrypting the message, so a muted channel can still notify you when someone mentions you or uses @everyone.

Conclusion

A push notification should be a doorbell, not a postcard. In Lupyd, the server rings the doorbell through Google with an empty {"ty": "umsg"}, and your phone opens the door itself: it connects to Lupyd, fetches the encrypted message, decrypts it with keys that never leave the device, and writes the notification locally. You get message previews, mentions and inline replies, and the push service never sees your messages.

Want to go deeper? Read how Lupyd searches encrypted messages on your device or what makes digital communication secure.

Key Takeaways
  • ✓ Lupyd's server sends FCM pushes containing only {"ty": "umsg"}: no message text, sender, or group name.
  • ✓ Pushes are only sent to devices without a live WebSocket; online devices receive encrypted messages directly.
  • ✓ On Android, Kotlin hands the push to Lupyd's Rust core via JNI, which connects, syncs, and decrypts messages with Signal or MLS on the device.
  • ✓ Notification text is built locally after decryption, so mutes, @mention overrides, and foreground suppression work without the server reading messages.
  • ✓ Inline replies from the notification shade are encrypted in Rust and sent through the same Firefly client.
  • ✓ FCM still reveals delivery timing metadata, and wake-up pushes can be slightly slower than text-in-payload pushes.
Try Lupyd

The workspace where nothing leaks

End-to-end encrypted channels and chat for your team, built on the Signal Protocol and MLS. Available on macOS, Windows, Linux and Android.

Open Source

Explore Lupyd on GitHub

Inspect our open-source repositories, cryptography libraries, and client tools on GitHub.

Explore Lupyd on GitHub