diff --git a/apps/wordcraft-web/src/bridge.rs b/apps/wordcraft-web/src/bridge.rs
new file mode 100644
--- /dev/null
+++ b/apps/wordcraft-web/src/bridge.rs
@@ -0,0 +1,201 @@
+//! The host bridge's decisions, kept free of `web_sys` so they are tested natively: which parent
+//! origin the bridge may talk to, and which saves the parent has confirmed.
+
+use std::collections::BTreeMap;
+
+/// How long the parent has to answer `wordcraft:save` with `wordcraft:saved` (milliseconds).
+pub const SAVE_ACK_TIMEOUT_MS: f64 = 30_000.0;
+
+/// The origin the bridge posts to and hears from, or `None` for no bridge (saves download).
+///
+/// - `own`: this page's origin.
+/// - `parent`: the parent window's origin, `None` when reading it threw (a cross-origin parent).
+/// - `allowed`: the `?host_origin=` query value, an origin the embedding page vouches for.
+///
+/// A same-origin parent is accepted. A cross-origin parent is accepted only when `allowed` is a
+/// valid URL origin (`scheme://host[:port]`, nothing else); messages then go to that exact origin,
+/// so the browser drops them if the parent is anything else. Opaque origins (`"null"`) never match.
+pub fn target_origin(own: &str, parent: Option<&str>, allowed: Option<&str>) -> Option<String> {
+    if let Some(parent) = parent
+        && parent == own
+        && is_origin(own)
+    {
+        return Some(own.to_string());
+    }
+    canonical_origin(allowed?.trim())
+}
+
+/// `s` as a browser serialises an origin (lowercase scheme and host, no default port), or `None`
+/// when it isn't one [`is_origin`] accepts. `MessageEvent.origin` is compared with this string
+/// exactly, so `https://Files.Example:443` must become `https://files.example` to ever match.
+pub fn canonical_origin(s: &str) -> Option<String> {
+    if !is_origin(s) {
+        return None;
+    }
+    let (scheme, rest) = s.split_once("://")?;
+    let scheme = scheme.to_ascii_lowercase();
+    let (host, port) = match rest.split_once(':') {
+        Some((h, p)) => (h, Some(p.parse::<u16>().ok()?)),
+        None => (rest, None),
+    };
+    let host = host.to_ascii_lowercase();
+    match (scheme.as_str(), port) {
+        ("https", Some(443)) | ("http", Some(80)) | (_, None) => Some(format!("{scheme}://{host}")),
+        (_, Some(port)) => Some(format!("{scheme}://{host}:{port}")),
+    }
+}
+
+/// True for a URL origin: `http(s)://host[:port]` with no path, query, fragment or credentials,
+/// and only in forms whose browser serialisation [`canonical_origin`] can produce: an ASCII host
+/// (an IDN must be given in punycode, `xn--…`), and a numeric host only as a dotted-decimal IPv4.
+pub fn is_origin(s: &str) -> bool {
+    let Some((scheme, rest)) = s.split_once("://") else { return false };
+    if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) || rest.is_empty() {
+        return false;
+    }
+    // IPv6 literals aren't accepted; name the host instead.
+    if rest.contains(['/', '?', '#', '@', ' ', '\\', '[', ']']) {
+        return false;
+    }
+    let (host, port) = match rest.split_once(':') {
+        Some((h, p)) => (h, Some(p)),
+        None => (rest, None),
+    };
+    if host.is_empty() || !host.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) {
+        return false;
+    }
+    // Browsers rewrite numeric hosts (`0x7f.1`, `127.1`, `010.0.0.1`) to dotted decimal; accept
+    // only the form they would produce.
+    let last = host.trim_end_matches('.').rsplit('.').next().unwrap_or("");
+    if last.bytes().all(|b| b.is_ascii_digit()) || last.to_ascii_lowercase().starts_with("0x") {
+        let parts: Vec<&str> = host.split('.').collect();
+        let decimal = |p: &&str| !p.is_empty() && (*p == "0" || !p.starts_with('0')) && p.parse::<u8>().is_ok();
+        if parts.len() != 4 || !parts.iter().all(decimal) {
+            return false;
+        }
+    }
+    match port {
+        Some(p) => p.bytes().all(|b| b.is_ascii_digit()) && p.parse::<u16>().is_ok(),
+        None => true,
+    }
+}
+
+/// Saves posted to the parent and not yet confirmed, keyed by the app's save ticket.
+#[derive(Debug, Default)]
+pub struct SaveAcks {
+    /// Request id → (file name, deadline in ms).
+    pending: BTreeMap<u64, (String, f64)>,
+}
+
+impl SaveAcks {
+    /// Record save `id` posted at `now`.
+    pub fn posted_as(&mut self, id: u64, name: &str, now: f64) {
+        self.pending.insert(id, (name.to_string(), now + SAVE_ACK_TIMEOUT_MS));
+    }
+
+    /// The parent confirmed `id`. False when no such save is waiting (an unknown id, or one that
+    /// already timed out and was reported failed).
+    pub fn saved(&mut self, id: u64) -> bool {
+        self.pending.remove(&id).is_some()
+    }
+
+    /// Saves past their deadline at `now`, removed and returned as `(id, name)`.
+    pub fn expire(&mut self, now: f64) -> Vec<(u64, String)> {
+        let late: Vec<u64> = self.pending.iter().filter(|(_, (_, deadline))| now >= *deadline).map(|(id, _)| *id).collect();
+        late.into_iter().filter_map(|id| self.pending.remove(&id).map(|(name, _)| (id, name))).collect()
+    }
+
+    /// Whether any save is still waiting for the parent (the document is reported unsaved).
+    pub fn waiting(&self) -> bool {
+        !self.pending.is_empty()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const OWN: &str = "https://app.example";
+
+    #[test]
+    fn same_origin_parent_enables_the_bridge() {
+        assert_eq!(target_origin(OWN, Some(OWN), None).as_deref(), Some(OWN));
+    }
+
+    #[test]
+    fn cross_origin_parent_disables_the_bridge() {
+        // Reading a cross-origin parent's origin throws (None) or names another origin.
+        assert_eq!(target_origin(OWN, None, None), None);
+        assert_eq!(target_origin(OWN, Some("https://evil.example"), None), None);
+        assert_eq!(target_origin(OWN, Some("null"), None), None);
+        assert_eq!(target_origin("null", Some("null"), None), None);
+    }
+
+    #[test]
+    fn host_origin_parameter_must_be_an_exact_origin() {
+        let files = "https://files.example:8443";
+        assert_eq!(target_origin(OWN, None, Some(files)).as_deref(), Some(files));
+        for bad in
+            ["*", "null", "files.example", "https://files.example/", "https://a@files.example", "javascript://x", "https://", "https://x:99999"]
+        {
+            assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");
+        }
+    }
+
+    #[test]
+    fn host_origin_is_stored_as_the_browser_serialises_it() {
+        // `MessageEvent.origin` is lowercase with no default port; the stored origin must match it.
+        for (given, stored) in [
+            ("https://Files.Example", "https://files.example"),
+            ("HTTPS://FILES.EXAMPLE", "https://files.example"),
+            ("https://files.example:443", "https://files.example"),
+            ("http://files.example:80", "http://files.example"),
+            ("http://files.example:443", "http://files.example:443"),
+            ("https://files.example:80", "https://files.example:80"),
+            ("https://files.example:08443", "https://files.example:8443"),
+            ("https://xn--bcher-kva.example", "https://xn--bcher-kva.example"),
+            ("http://127.0.0.1:8080", "http://127.0.0.1:8080"),
+            ("  https://Files.Example:443  ", "https://files.example"),
+        ] {
+            assert_eq!(target_origin(OWN, None, Some(given)).as_deref(), Some(stored), "{given}");
+        }
+    }
+
+    #[test]
+    fn host_origin_a_browser_would_serialise_differently_is_refused() {
+        for bad in [
+            "https://b\u{fc}cher.example",
+            "https://files.example:",
+            "https://0x7f.0.0.1",
+            "https://127.1",
+            "https://010.0.0.1",
+            "https://256.0.0.1",
+            "https://files%2eexample",
+            "https://[::1]",
+        ] {
+            assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");
+        }
+    }
+
+    #[test]
+    fn a_save_stays_unsaved_until_its_own_confirmation() {
+        let mut acks = SaveAcks::default();
+        acks.posted_as(7, "Document.docx", 0.0);
+        assert!(acks.waiting(), "the document is reported unsaved until the parent answers");
+        assert!(!acks.saved(6), "an unknown id confirms nothing");
+        assert!(acks.waiting());
+        assert!(acks.saved(7));
+        assert!(!acks.waiting());
+        assert!(!acks.saved(7), "a confirmation counts once");
+    }
+
+    #[test]
+    fn an_unanswered_save_times_out() {
+        let mut acks = SaveAcks::default();
+        acks.posted_as(3, "Document.docx", 1_000.0);
+        assert!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS - 1.0).is_empty());
+        assert_eq!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS), vec![(3, "Document.docx".to_string())]);
+        assert!(!acks.saved(3), "a late confirmation is ignored: the save was reported failed");
+        assert!(!acks.waiting());
+    }
+}
diff --git a/apps/wordcraft-web/src/host.rs b/apps/wordcraft-web/src/host.rs
--- a/apps/wordcraft-web/src/host.rs
+++ b/apps/wordcraft-web/src/host.rs
@@ -1,4 +1,4 @@
-//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
+//! The `postMessage` bridge to the parent page (`?host=parent`), for pages that embed
 //! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
 //! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
 //!
@@ -6,9 +6,11 @@
 //!   listening; send a file now.
 //! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
 //!   `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
-//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
-//!   every file WordCraft would otherwise download. The parent stores it and reports failures
-//!   itself. `saveAs` says whether the file becomes the document's file:
+//! - `wordcraft:save` (to the parent): `{ id, name, saveAs, bytes }` (a transferred `ArrayBuffer`)
+//!   for every file WordCraft would otherwise download. The parent stores it and answers
+//!   `wordcraft:saved { id }`; until then the document counts as unsaved. With no answer in 30 s
+//!   WordCraft posts `wordcraft:failed { id, error }` and the document stays unsaved.
+//!   `saveAs` says whether the file becomes the document's file:
 //!   - Save: `false`. It writes the opened file under its own name and format, as the desktop
 //!     app does. A name the parent never sent (an untitled document, an imported .doc saved as
 //!     .docx) is a new file: `true`, as for Save As.
@@ -18,19 +20,27 @@
 //!   - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
 //!     Export Page as PNG: `false`. They are copies; the document's file stays the same.
 //! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
-//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
+//! - `wordcraft:saved` (from the parent): `{ id }`, the file from that `wordcraft:save` is stored.
+//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start, or
+//!   `{ id, error }` when a save wasn't confirmed in time.
 //!
-//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
+//! The bridge is installed only for a parent on the page's own origin, or for the exact origin
+//! `?host_origin=` names (a bare `scheme://host[:port]`). Messages go to that origin, and only the
+//! parent window on it is heard. Any other parent gets no bridge: Save downloads and this page
+//! guards leaving it. In host mode the parent
 //! guards leaving the page, and `?author=` names the user for comments and tracked changes.
 
-use std::cell::Cell;
+use std::cell::{Cell, RefCell};
 use std::rc::Rc;
 
 use wasm_bindgen::JsCast as _;
 use wasm_bindgen::JsValue;
 use wasm_bindgen::closure::Closure;
+use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
 use wordcraft_ui_egui::{Inbox, Services, WordApp};
 
+use crate::bridge::SaveAcks;
+
 /// The longest user name taken from `?author=`.
 const MAX_AUTHOR_CHARS: usize = 200;
 
@@ -39,28 +49,30 @@ pub struct Bridge {
     window: web_sys::Window,
     parent: web_sys::Window,
     origin: String,
+    /// The unsaved-changes state last reported to the parent.
+    reported: Rc<Cell<Option<bool>>>,
+    /// The document's own unsaved-changes state (from `on_dirty`).
+    dirty: Rc<Cell<bool>>,
+    /// The app's saves in flight; each `wordcraft:save` is a ticket.
+    saves: AsyncSaves,
+    /// Tickets waiting for `wordcraft:saved`, with their deadlines.
+    acks: Rc<RefCell<SaveAcks>>,
 }
 
 impl Bridge {
     /// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
-    /// on another origin: messages only go to this origin, so the browser would drop every save
-    /// without an error while the document showed as saved.
-    pub fn new() -> Option<Self> {
+    /// on another origin that `host_origin` doesn't name exactly ([`crate::bridge::target_origin`]).
+    pub fn new(host_origin: Option<&str>, saves: AsyncSaves) -> Option<Self> {
         let window = web_sys::window()?;
         let parent = window.parent().ok().flatten()?;
         if js_sys::Object::is(&parent, &window) {
             return None;
         }
-        let origin = window.location().origin().ok()?;
-        // An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
-        if origin == "null" {
-            return None;
-        }
-        // Reading another origin's location throws, which is `None` too.
-        if parent.location().origin().ok()? != origin {
-            return None;
-        }
-        Some(Self { window, parent, origin })
+        let own = window.location().origin().ok()?;
+        // Reading a cross-origin parent's location throws: that counts as cross-origin.
+        let parent_origin = parent.location().origin().ok();
+        let origin = crate::bridge::target_origin(&own, parent_origin.as_deref(), host_origin)?;
+        Some(Self { window, parent, origin, reported: Rc::default(), dirty: Rc::default(), saves, acks: Rc::default() })
     }
 
     /// Hand saved and exported files to the parent instead of downloading them, report unsaved
@@ -72,17 +84,30 @@ impl Bridge {
         // Anything else that would download goes to the parent too.
         let bridge = self.clone();
         services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
-        // The browser's save picker is never used, so no save finishes later.
-        services.async_saves = None;
+        // A save finishes when the parent confirms it (`wordcraft:saved`), like the save picker's.
+        services.async_saves = Some(self.saves.clone());
         let bridge = self.clone();
-        let reported: Rc<Cell<Option<bool>>> = Rc::default();
         services.on_dirty = Some(Box::new(move |dirty: bool| {
-            if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
-                reported.set(Some(dirty));
-            }
+            bridge.dirty.set(dirty);
+            bridge.report_dirty();
         }));
     }
 
+    /// Once a frame: fail saves the parent didn't confirm in time, and report unsaved changes
+    /// (a save still waiting for the parent counts as unsaved).
+    pub fn tick(&self, ctx: &egui::Context) {
+        let expired = self.acks.borrow_mut().expire(js_sys::Date::now());
+        for (id, name) in expired {
+            let error = format!("{name}: the host page didn't confirm the save");
+            self.saves.finish(id, SaveOutcome::Failed(error.clone()));
+            self.send(&[("type", "wordcraft:failed".into()), ("id", JsValue::from_f64(id as f64)), ("error", error.into())]);
+        }
+        if self.acks.borrow().waiting() {
+            ctx.request_repaint_after(std::time::Duration::from_secs(1));
+        }
+        self.report_dirty();
+    }
+
     /// `?author=`: the host's name for the user signs comments and tracked changes.
     pub fn set_author(app: &mut WordApp, author: &str) {
         let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
@@ -100,32 +125,67 @@ impl Bridge {
         self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
     }
 
+    fn report_dirty(&self) {
+        let dirty = self.dirty.get() || self.acks.try_borrow().map_or(true, |a| a.waiting());
+        if self.reported.get() != Some(dirty) && self.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
+            self.reported.set(Some(dirty));
+        }
+    }
+
     fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
         let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
         let buffer = js_sys::Uint8Array::from(bytes).buffer();
-        let message = message(&[
+        // The app's save ticket is the id the parent answers with.
+        let id = self.saves.start();
+        let sent = message(&[
             ("type", "wordcraft:save".into()),
+            ("id", JsValue::from_f64(id as f64)),
             ("name", name.as_str().into()),
             ("saveAs", save_as.into()),
             ("bytes", buffer.clone().into()),
         ])
-        .ok_or_else(|| "couldn't build the message".to_string())?;
-        self.parent
-            .post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
-            .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
+        .ok_or_else(|| "couldn't build the message".to_string())
+        .and_then(|message| {
+            self.parent
+                .post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
+                .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
+        });
+        match sent {
+            Ok(()) => {
+                self.acks.borrow_mut().posted_as(id, &name, js_sys::Date::now());
+                self.report_dirty();
+                Ok(())
+            }
+            Err(e) => {
+                self.saves.finish(id, SaveOutcome::Failed(e.clone()));
+                Err(e)
+            }
+        }
     }
 
     /// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
     fn listen(&self, inbox: Inbox, ctx: egui::Context) {
         let parent = self.parent.clone();
         let origin = self.origin.clone();
+        let bridge = self.clone();
         let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
             if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
                 return;
             }
             let data = event.data();
-            if string(&data, "type").as_deref() != Some("wordcraft:open") {
-                return;
+            match string(&data, "type").as_deref() {
+                Some("wordcraft:open") => {}
+                Some("wordcraft:saved") => {
+                    let id = js_sys::Reflect::get(&data, &JsValue::from_str("id")).ok().and_then(|v| v.as_f64());
+                    if let Some(id) = id.filter(|id| id.is_finite() && *id >= 0.0 && id.fract() == 0.0)
+                        && bridge.acks.borrow_mut().saved(id as u64)
+                    {
+                        bridge.saves.finish(id as u64, SaveOutcome::Saved);
+                        ctx.request_repaint();
+                    }
+                    return;
+                }
+                _ => return,
             }
             let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
             let Some(bytes) = bytes(&data) else { return };
diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs
--- a/apps/wordcraft-web/src/main.rs
+++ b/apps/wordcraft-web/src/main.rs
@@ -8,7 +8,8 @@
 //! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
 //! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
 //! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
-//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
+//! `?host=parent` (inside an `<iframe>`) talks to the parent page instead (same-origin, or the exact
+//! origin `&host_origin=` names): it sends the
 //! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
 //! comments and tracked changes.
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -18,6 +19,8 @@ mod prefs;
 #[cfg(any(target_arch = "wasm32", test))]
 mod save_fallback;
 
+#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
+mod bridge;
 #[cfg(target_arch = "wasm32")]
 mod host;
 #[cfg(target_arch = "wasm32")]
diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs
--- a/apps/wordcraft-web/src/web.rs
+++ b/apps/wordcraft-web/src/web.rs
@@ -27,12 +27,16 @@ pub fn start() {
             log::error!("missing <canvas id=\"{CANVAS_ID}\">");
             return;
         };
-        // `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
+        // `?host=parent`: a same-origin page (or the one `?host_origin=` names) framing WordCraft opens documents in it and stores
         // what it saves (`host`). That page guards leaving it, so this one doesn't.
-        let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
-        let app_bridge = bridge.clone();
         let dirty = Rc::new(Cell::new(false));
         let saves = AsyncSaves::default();
+        let bridge = if query_param("host").as_deref() == Some("parent") {
+            crate::host::Bridge::new(query_param("host_origin").as_deref(), saves.clone())
+        } else {
+            None
+        };
+        let app_bridge = bridge.clone();
         if bridge.is_none()
             && let Err(e) = guard_unload(dirty.clone(), saves.clone())
         {
@@ -99,7 +103,7 @@ pub fn start() {
                         crate::host::Bridge::set_author(&mut app, &author);
                         prefs.keep_own_author(&app.session.author, &own);
                     }
-                    Ok(Box::new(WebShell { app, inbox, messages, prefs }))
+                    Ok(Box::new(WebShell { app, inbox, messages, prefs, bridge: app_bridge }))
                 }),
             )
             .await;
@@ -134,6 +138,7 @@ struct WebShell {
     inbox: Inbox,
     messages: Rc<RefCell<Vec<String>>>,
     prefs: crate::prefs::PrefsSync,
+    bridge: Option<crate::host::Bridge>,
 }
 
 fn local_storage() -> Option<web_sys::Storage> {
@@ -154,6 +159,9 @@ impl eframe::App for WebShell {
         for msg in msgs {
             self.app.status(msg);
         }
+        if let Some(bridge) = &self.bridge {
+            bridge.tick(ctx);
+        }
         self.app.logic(ctx);
         // Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).
         if let Some(json) = self.prefs.changed(&self.app) {
