diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -218,7 +218,7 @@ figcraft-cli mcp
 figcraft-cli mcp --attach 47800
 ```
 
-The desktop app also offers a loopback-only control channel (`figcraft --control <port>`) for inspecting UI state, driving tools with pointer and key events, and taking screenshots. See [`docs/control-protocol.md`](docs/control-protocol.md), [`docs/mcp.md`](docs/mcp.md) and [`docs/cli.md`](docs/cli.md).
+The desktop app also offers a token-authenticated loopback-only control channel (`figcraft --control <port>`, requiring `FIGCRAFT_CONTROL_TOKEN`) for inspecting UI state, driving tools with pointer and key events, and taking screenshots. See [`docs/control-protocol.md`](docs/control-protocol.md), [`docs/mcp.md`](docs/mcp.md) and [`docs/cli.md`](docs/cli.md).
 
 ## Under the hood
 
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (authenticated local control requests (#480); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
 
 FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
 native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 
 ## Progress log
 
+- **2026-10-15** · Local control authentication (M0.9, #480): enabling the loopback control port now requires `FIGCRAFT_CONTROL_TOKEN`. Every request carries that token and is checked before entering the UI queue; missing or wrong tokens and malformed JSON close the connection. Attached MCP uses the same token, sends it only to loopback destinations, and retains the existing no-replay behavior. The frame, worker, queue and timeout bounds from #213 remain in place. Headless MCP needs no token. Numbers unchanged.
 - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
 - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
 - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
@@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-15 | minor | Local control authentication (#480): mandatory environment token, checks before dispatch, close on malformed JSON, and authenticated loopback-only Attached MCP. Existing resource limits and no-replay behavior retained; percentages and estimates unchanged. |
 | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
 | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
 | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs
--- a/apps/figcraft-cli/src/main.rs
+++ b/apps/figcraft-cli/src/main.rs
@@ -23,7 +23,7 @@ USAGE:
   figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
   figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
   figcraft-cli commands [--json] [--filter TEXT]
-  figcraft-cli mcp [--attach PORT]
+  figcraft-cli mcp [--attach PORT]  (FIGCRAFT_CONTROL_TOKEN required for --attach)
   figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
   figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
   figcraft-cli help | --help | --version
diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs
--- a/apps/figcraft/src/control_server.rs
+++ b/apps/figcraft/src/control_server.rs
@@ -1,4 +1,4 @@
-//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI
+//! Localhost JSON-lines control server: each line `{"id", "method", "params", "token"}` is authenticated then queued for the UI
 //! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it.
 
 use std::io::{BufRead, BufReader, Read, Write};
@@ -26,7 +26,7 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
     target.extend(queue.drain(..count));
 }
 
-pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
+pub fn start(port: u16, token: figcraft_collab_net::control_auth::ControlToken, queue: Queue, ctx: CtxSlot) {
     let listener = match TcpListener::bind(("127.0.0.1", port)) {
         Ok(l) => l,
         Err(e) => {
@@ -39,7 +39,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
     let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
         let mut workers = Vec::new();
         for stream in listener.incoming().flatten() {
-            spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
+            spawn_connection(stream, token.clone(), queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
         }
     });
     if let Err(e) = spawned {
@@ -47,20 +47,33 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
     }
 }
 
-fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
+fn spawn_connection(
+    stream: TcpStream,
+    token: figcraft_collab_net::control_auth::ControlToken,
+    queue: Queue,
+    ctx: CtxSlot,
+    workers: &mut Vec<std::thread::JoinHandle<()>>,
+    timeout: Duration,
+) {
     workers.retain(|worker| !worker.is_finished());
     if workers.len() >= MAX_CONNECTIONS {
         return;
     }
     match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
-        let _ = serve(stream, queue, ctx, timeout);
+        let _ = serve(stream, token, queue, ctx, timeout);
     }) {
         Ok(worker) => workers.push(worker),
         Err(e) => log::error!("control connection thread: {e}"),
     }
 }
 
-fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
+fn serve(
+    mut out: TcpStream,
+    token: figcraft_collab_net::control_auth::ControlToken,
+    queue: Queue,
+    ctx: CtxSlot,
+    timeout: Duration,
+) -> std::io::Result<()> {
     out.set_read_timeout(Some(timeout))?;
     out.set_write_timeout(Some(timeout))?;
     let mut reader = BufReader::new(out.try_clone()?);
@@ -77,41 +90,50 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
             writeln!(out, "{}", json!({"id": null, "ok": false, "error": "message too large"}))?;
             break;
         }
-        let Ok(line) = std::str::from_utf8(&line) else { break };
+        let Ok(line) = std::str::from_utf8(&line) else {
+            writeln!(out, "{}", json!({"id": null, "ok": false, "error": "bad JSON"}))?;
+            break;
+        };
         if line.trim().is_empty() {
             continue;
         }
-        let reply = match serde_json::from_str::<Value>(line) {
-            Ok(v) => {
-                let id = v.get("id").cloned().unwrap_or(Value::Null);
-                let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
-                let params = v.get("params").cloned().unwrap_or(json!({}));
-                let (tx, rx) = std::sync::mpsc::channel();
-                let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
-                let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
-                pending.retain(|request| !request.is_cancelled());
-                if pending.len() >= MAX_QUEUED_REQUESTS {
-                    drop(pending);
-                    writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
-                    continue;
-                }
-                pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
-                drop(pending);
-                if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
-                    c.request_repaint();
-                }
-                match rx.recv_timeout(timeout) {
-                    Ok(r) => match r.result {
-                        Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
-                        Err(e) => json!({"id": r.id, "ok": false, "error": e}),
-                    },
-                    Err(_) => {
-                        cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
-                        json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"})
-                    }
-                }
+        let v = match serde_json::from_str::<Value>(line) {
+            Ok(v) => v,
+            Err(_) => {
+                writeln!(out, "{}", json!({"id": null, "ok": false, "error": "bad JSON"}))?;
+                break;
+            }
+        };
+        if !v.get("token").and_then(Value::as_str).is_some_and(|presented| token.matches(presented)) {
+            writeln!(out, "{}", json!({"id": null, "ok": false, "error": "unauthorized"}))?;
+            break;
+        }
+        let id = v.get("id").cloned().unwrap_or(Value::Null);
+        let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
+        let params = v.get("params").cloned().unwrap_or(json!({}));
+        let (tx, rx) = std::sync::mpsc::channel();
+        let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
+        let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+        pending.retain(|request| !request.is_cancelled());
+        if pending.len() >= MAX_QUEUED_REQUESTS {
+            drop(pending);
+            writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
+            continue;
+        }
+        pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
+        drop(pending);
+        if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
+            c.request_repaint();
+        }
+        let reply = match rx.recv_timeout(timeout) {
+            Ok(r) => match r.result {
+                Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
+                Err(e) => json!({"id": r.id, "ok": false, "error": e}),
+            },
+            Err(_) => {
+                cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
+                json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"})
             }
-            Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
         };
         writeln!(out, "{reply}")?;
     }
diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs
--- a/apps/figcraft/src/control_server_tests.rs
+++ b/apps/figcraft/src/control_server_tests.rs
@@ -14,6 +14,10 @@ fn pair() -> (TcpStream, TcpStream) {
     (client, listener.accept().unwrap().0)
 }
 
+fn token() -> figcraft_collab_net::control_auth::ControlToken {
+    figcraft_collab_net::control_auth::ControlToken::new("test_control_token_with_32_chars").unwrap()
+}
+
 fn queue() -> Queue {
     Arc::new(Mutex::new(Vec::new()))
 }
@@ -37,7 +41,7 @@ fn rejects_an_oversized_unterminated_frame_without_dispatch() {
     let (mut client, server) = pair();
     let pending = queue();
     let q = pending.clone();
-    let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
+    let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));
     // Generate bytes as they are written; no giant input string and no terminating newline.
     std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE + 1) as u64), &mut client).unwrap();
     let mut reader = BufReader::new(client);
@@ -52,10 +56,9 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
     let (mut client, server) = pair();
     let pending = queue();
     let q = pending.clone();
-    let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
-    client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
+    let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));
+    client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"},\"token\":\"test_control_token_with_32_chars\"}\r\n").unwrap();
     let mut reader = BufReader::new(client);
-    assert_eq!(reply(&mut reader)["ok"], false);
     let start = Instant::now();
     let r = loop {
         if let Some(r) = pending.lock().unwrap().pop() {
@@ -69,10 +72,119 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
     assert_eq!(r.params, json!({"command": "shape.rectangle"}));
     r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"id": 42})) }).unwrap();
     assert_eq!(reply(&mut reader), json!({"id": "test", "ok": true, "result": {"id": 42}}));
+    reader.get_mut().write_all(b"{\"id\":2,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
+    let start = Instant::now();
+    let r = loop {
+        if let Some(r) = pending.lock().unwrap().pop() {
+            break r;
+        }
+        assert!(start.elapsed() < Duration::from_secs(5));
+        std::thread::sleep(Duration::from_millis(1));
+    };
+    r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Err("ordinary command failure".into()) }).unwrap();
+    assert_eq!(reply(&mut reader), json!({"id": 2, "ok": false, "error": "ordinary command failure"}));
+    reader.get_mut().write_all(b"{\"id\":3,\"method\":\"ui.inspect\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
+    let start = Instant::now();
+    let r = loop {
+        if let Some(r) = pending.lock().unwrap().pop() {
+            break r;
+        }
+        assert!(start.elapsed() < Duration::from_secs(5));
+        std::thread::sleep(Duration::from_millis(1));
+    };
+    r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"stillConnected": true})) }).unwrap();
+    assert_eq!(reply(&mut reader), json!({"id": 3, "ok": true, "result": {"stillConnected": true}}));
     reader.get_ref().shutdown(Shutdown::Write).unwrap();
     worker.join().unwrap().unwrap();
 }
 
+#[test]
+fn missing_or_wrong_token_never_dispatches_and_closes() {
+    for request in [
+        br#"{"id":1,"method":"engine.execute"}"#.as_slice(),
+        br#"{"id":1,"method":"engine.execute","token":"wrong_control_token_with_32_chars"}"#.as_slice(),
+    ] {
+        let (mut client, server) = pair();
+        let pending = queue();
+        let q = pending.clone();
+        let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));
+        client.write_all(request).unwrap();
+        client.write_all(b"\n{\"id\":2,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
+        let mut reader = BufReader::new(client);
+        assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "unauthorized"}));
+        assert_eq!(reader.read(&mut [0]).unwrap(), 0);
+        assert!(pending.lock().unwrap().is_empty());
+        worker.join().unwrap().unwrap();
+    }
+}
+
+#[test]
+fn a_missing_token_after_an_authenticated_request_is_rejected() {
+    let (mut client, server) = pair();
+    let pending = queue();
+    let q = pending.clone();
+    let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));
+    client.write_all(b"{\"id\":1,\"method\":\"ui.inspect\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
+    let mut reader = BufReader::new(client);
+    let start = Instant::now();
+    let r = loop {
+        if let Some(r) = pending.lock().unwrap().pop() {
+            break r;
+        }
+        assert!(start.elapsed() < Duration::from_secs(5));
+        std::thread::sleep(Duration::from_millis(1));
+    };
+    r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({})) }).unwrap();
+    assert_eq!(reply(&mut reader)["ok"], true);
+    reader.get_mut().write_all(b"{\"id\":2,\"method\":\"engine.execute\"}\n").unwrap();
+    assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "unauthorized"}));
+    assert_eq!(reader.read(&mut [0]).unwrap(), 0);
+    assert!(pending.lock().unwrap().is_empty());
+    worker.join().unwrap().unwrap();
+}
+
+#[test]
+fn malformed_http_preamble_closes_before_later_json() {
+    let (mut client, server) = pair();
+    let pending = queue();
+    let q = pending.clone();
+    let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));
+    client.write_all(b"POST / HTTP/1.1\r\n\r\n{\"id\":1,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
+    let mut reader = BufReader::new(client);
+    assert_eq!(reply(&mut reader)["ok"], false);
+    assert_eq!(reader.read(&mut [0]).unwrap(), 0);
+    assert!(pending.lock().unwrap().is_empty());
+    worker.join().unwrap().unwrap();
+}
+
+#[test]
+fn invalid_utf8_replies_then_closes_without_dispatch() {
+    let (mut client, server) = pair();
+    let pending = queue();
+    let q = pending.clone();
+    let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));
+    client.write_all(b"\xff\n{\"id\":1,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
+    let mut reader = BufReader::new(client);
+    assert_eq!(reply(&mut reader)["error"], "bad JSON");
+    assert_eq!(reader.read(&mut [0]).unwrap(), 0);
+    assert!(pending.lock().unwrap().is_empty());
+    worker.join().unwrap().unwrap();
+}
+
+#[test]
+fn malformed_json_replies_then_closes() {
+    let (mut client, server) = pair();
+    let pending = queue();
+    let q = pending.clone();
+    let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));
+    client.write_all(b"{bad JSON}\n{\"id\":1,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
+    let mut reader = BufReader::new(client);
+    assert_eq!(reply(&mut reader)["error"], "bad JSON");
+    assert_eq!(reader.read(&mut [0]).unwrap(), 0);
+    assert!(pending.lock().unwrap().is_empty());
+    worker.join().unwrap().unwrap();
+}
+
 #[test]
 fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
     let (mut client, server) = pair();
@@ -86,8 +198,8 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
     drain_queue(&pending, &mut ui_queue);
     assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
     let q = pending.clone();
-    let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
-    client.write_all(b"{\"id\":1,\"method\":\"engine.execute\"}\n").unwrap();
+    let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));
+    client.write_all(b"{\"id\":1,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
     let mut reader = BufReader::new(client);
     assert_eq!(reply(&mut reader), json!({"id": 1, "ok": false, "error": "control request queue is full"}));
     assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
@@ -106,7 +218,7 @@ fn idle_and_partial_frame_connections_expire() {
         let (done, finished) = mpsc::channel();
         let pending = queue();
         let q = pending.clone();
-        let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(50))));
+        let worker = std::thread::spawn(move || done.send(serve(server, token(), q, context(), Duration::from_millis(50))));
         client.write_all(bytes).unwrap();
         let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
         assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
@@ -123,12 +235,12 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
     let mut clients = Vec::new();
     for _ in 0..MAX_CONNECTIONS {
         let (client, server) = pair();
-        spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
+        spawn_connection(server, token(), pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
         clients.push(client);
     }
     assert_eq!(workers.len(), MAX_CONNECTIONS);
     let (mut extra, server) = pair();
-    spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
+    spawn_connection(server, token(), pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
     assert_eq!(extra.read(&mut [0]).unwrap(), 0);
     assert_eq!(workers.len(), MAX_CONNECTIONS);
     for client in clients {
@@ -140,7 +252,7 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
         std::thread::sleep(Duration::from_millis(1));
     }
     let (mut client, server) = pair();
-    spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
+    spawn_connection(server, token(), pending, ctx, &mut workers, Duration::from_secs(5));
     assert_eq!(workers.len(), 1);
     client.write_all(b"invalid\n").unwrap();
     assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
@@ -154,8 +266,8 @@ fn a_client_that_stops_reading_cannot_hold_a_worker() {
     let pending = queue();
     let q = pending.clone();
     let (done, finished) = mpsc::channel();
-    let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(100))));
-    client.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
+    let worker = std::thread::spawn(move || done.send(serve(server, token(), q, context(), Duration::from_millis(100))));
+    client.write_all(b"{\"id\":1,\"method\":\"document.inspect\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
     let start = Instant::now();
     let r = loop {
         if let Some(r) = pending.lock().unwrap().pop() {
@@ -175,8 +287,8 @@ fn a_limit_sized_frame_is_accepted_at_eof() {
     let (mut client, server) = pair();
     let pending = queue();
     let q = pending.clone();
-    let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
-    let message = b"{\"id\":1,\"method\":\"ui.inspect\"}";
+    let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));
+    let message = b"{\"id\":1,\"method\":\"ui.inspect\",\"token\":\"test_control_token_with_32_chars\"}";
     client.write_all(message).unwrap();
     std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE - message.len()) as u64), &mut client).unwrap();
     client.shutdown(Shutdown::Write).unwrap();
@@ -209,8 +321,8 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
     for id in 0..MAX_QUEUED_REQUESTS {
         let (mut client, server) = pair();
         let q = pending.clone();
-        workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(1))));
-        writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}})).unwrap();
+        workers.push(std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(1))));
+        writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}, "token": "test_control_token_with_32_chars"})).unwrap();
         clients.push(BufReader::new(client));
     }
     let start = Instant::now();
@@ -235,8 +347,8 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
     }));
     let (mut client, server) = pair();
     let q = pending.clone();
-    workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))));
-    client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0}}\n").unwrap();
+    workers.push(std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5))));
+    client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0},\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();
     let start = Instant::now();
     while !pending.lock().unwrap().iter().any(|r| r.id == 100) {
         assert!(start.elapsed() < Duration::from_secs(5));
diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs
--- a/apps/figcraft/src/main.rs
+++ b/apps/figcraft/src/main.rs
@@ -3,8 +3,8 @@
 //! Usage: `figcraft [--control <port>] [files…] [figcraft://join?…]` (an invite link joins that
 //! live session; the app registers itself as the opener of such links: `collab.registerLinks`).
 //!
-//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
-//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
+//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a token-authenticated localhost JSON-lines control server; it requires `FIGCRAFT_CONTROL_TOKEN` in the process environment:
+//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}},"token":"<FIGCRAFT_CONTROL_TOKEN>"}`
 //! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`.
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
@@ -107,6 +107,16 @@ fn main() -> eframe::Result {
     }
     let args = args::parse(&args);
     let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
+    let control_token = match port {
+        Some(_) => match figcraft_collab_net::control_auth::ControlToken::from_env() {
+            Ok(token) => Some(token),
+            Err(e) => {
+                eprintln!("FigCraft control server not started: {e}");
+                std::process::exit(2);
+            }
+        },
+        None => None,
+    };
     // System fonts: index them in the background, then wake the UI (an idle window draws no frames)
     // to lay text out again with them.
     let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
@@ -164,8 +174,8 @@ fn main() -> eframe::Result {
         figcraft_ui_egui::live::open_invite(&mut fig, link);
     }
     let queue: Arc<Mutex<Vec<ControlRequest>>> = Arc::new(Mutex::new(Vec::new()));
-    if let Some(p) = port {
-        control_server::start(p, queue.clone(), ctx_slot.clone());
+    if let (Some(p), Some(token)) = (port, control_token) {
+        control_server::start(p, token, queue.clone(), ctx_slot.clone());
     }
     let icon = services::window_icon();
     let mut viewport = egui::ViewportBuilder::default()
diff --git a/crates/collab-net/src/control_auth.rs b/crates/collab-net/src/control_auth.rs
new file mode 100644
--- /dev/null
+++ b/crates/collab-net/src/control_auth.rs
@@ -0,0 +1,90 @@
+//! Native authentication for the local desktop control channel.
+use std::ffi::OsString;
+use std::fmt;
+use std::io;
+
+use figcraft_collab::same_secret;
+
+/// A validated control-channel token. Its contents are intentionally absent from Debug output.
+#[derive(Clone, PartialEq, Eq)]
+pub struct ControlToken(String);
+
+impl fmt::Debug for ControlToken {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str("ControlToken([REDACTED])")
+    }
+}
+
+impl ControlToken {
+    /// Read and validate the process-scoped control token.
+    pub fn from_env() -> io::Result<Self> {
+        let value = std::env::var_os("FIGCRAFT_CONTROL_TOKEN");
+        Self::from_os_value(value)
+    }
+
+    /// Validate an explicit Unicode token.
+    pub fn new(value: &str) -> io::Result<Self> {
+        validate(value)?;
+        Ok(Self(value.to_owned()))
+    }
+
+    fn from_os_value(value: Option<OsString>) -> io::Result<Self> {
+        let value = value
+            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "FIGCRAFT_CONTROL_TOKEN is required when the control listener is enabled"))?;
+        let value = value.into_string().map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "FIGCRAFT_CONTROL_TOKEN must be Unicode"))?;
+        Self::new(&value)
+    }
+
+    /// Compare a presented token with this token using the shared secret comparison helper.
+    pub fn matches(&self, presented: &str) -> bool {
+        same_secret(&self.0, presented)
+    }
+
+    /// Return the value for an authenticated protocol request.
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+}
+
+fn validate(value: &str) -> io::Result<()> {
+    if !(22..=128).contains(&value.len()) || !value.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
+        return Err(io::Error::new(io::ErrorKind::InvalidInput, "FIGCRAFT_CONTROL_TOKEN must be 22 to 128 URL-safe ASCII characters"));
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+    use super::*;
+
+    #[test]
+    fn rejects_missing_empty_and_non_unicode_environment_values() {
+        assert!(ControlToken::from_os_value(None).is_err());
+        assert!(ControlToken::from_os_value(Some(OsString::new())).is_err());
+        #[cfg(unix)]
+        {
+            use std::os::unix::ffi::OsStringExt;
+            assert!(ControlToken::from_os_value(Some(OsString::from_vec(vec![0xff]))).is_err());
+        }
+        #[cfg(windows)]
+        {
+            use std::os::windows::ffi::OsStringExt;
+            assert!(ControlToken::from_os_value(Some(OsString::from_wide(&[0xd800]))).is_err());
+        }
+    }
+
+    #[test]
+    fn accepts_valid_tokens_and_rejects_invalid_tokens() {
+        assert!(ControlToken::new("a".repeat(22).as_str()).is_ok());
+        assert!(ControlToken::new("a".repeat(128).as_str()).is_ok());
+        let invalid = [String::new(), "a".repeat(21), "a".repeat(129), "contains space 1234567890".into(), "unicode-é-123456789012".into()];
+        for value in invalid {
+            assert!(ControlToken::new(&value).is_err());
+        }
+        let token = ControlToken::new("a_secure_token_with_32_chars").unwrap();
+        assert!(token.matches("a_secure_token_with_32_chars"));
+        assert!(!token.matches("another_secure_token_with_32"));
+        assert!(!format!("{token:?}").contains("secure"));
+    }
+}
diff --git a/crates/collab-net/src/lib.rs b/crates/collab-net/src/lib.rs
--- a/crates/collab-net/src/lib.rs
+++ b/crates/collab-net/src/lib.rs
@@ -22,6 +22,8 @@ pub mod limits;
 mod sessions;
 pub mod throttle;
 
+#[cfg(not(target_arch = "wasm32"))]
+pub mod control_auth;
 #[cfg(not(target_arch = "wasm32"))]
 pub mod guest;
 #[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs
--- a/crates/mcp/src/backend.rs
+++ b/crates/mcp/src/backend.rs
@@ -93,6 +93,7 @@ pub fn decode_data(v: &Value) -> Result<Vec<u8>, String> {
 /// A running FigCraft app, reached through its loopback control port (`figcraft --control PORT`).
 pub struct Attached {
     addr: String,
+    token: figcraft_collab_net::control_auth::ControlToken,
     conn: Option<(BufReader<TcpStream>, TcpStream)>,
     next_id: u64,
 }
@@ -100,12 +101,17 @@ pub struct Attached {
 impl Attached {
     /// Connect to `addr` (e.g. `127.0.0.1:7979`), failing fast when nothing is listening.
     pub fn connect(addr: &str) -> std::io::Result<Self> {
-        let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
+        Self::connect_with_token(addr, figcraft_collab_net::control_auth::ControlToken::from_env()?)
+    }
+
+    /// Connect with an explicit validated token.
+    pub fn connect_with_token(addr: &str, token: figcraft_collab_net::control_auth::ControlToken) -> std::io::Result<Self> {
+        let mut r = Self { addr: addr.to_string(), token, conn: None, next_id: 1 };
         r.reconnect()?;
         Ok(r)
     }
 
-    /// Connect to `127.0.0.1:port`.
+    /// Connect to 127.0.0.1:port.
     pub fn port(port: u16) -> std::io::Result<Self> {
         Self::connect(&format!("127.0.0.1:{port}"))
     }
@@ -117,7 +123,14 @@ impl Attached {
     fn reconnect(&mut self) -> std::io::Result<()> {
         self.conn = None;
         let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
-        for sa in self.addr.to_socket_addrs()? {
+        let addresses: Vec<_> = self.addr.to_socket_addrs()?.collect();
+        if addresses.is_empty() {
+            return Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)));
+        }
+        if addresses.iter().any(|sa| !sa.ip().is_loopback()) {
+            return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "attached control connections require loopback addresses"));
+        }
+        for sa in addresses {
             match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
                 Ok(s) => {
                     s.set_nodelay(true).ok();
@@ -165,7 +178,7 @@ impl Backend for Attached {
     fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
         let id = self.next_id;
         self.next_id += 1;
-        let line = json!({"id": id, "method": method, "params": params}).to_string();
+        let line = json!({"id": id, "method": method, "params": params, "token": self.token.as_str()}).to_string();
         if self.conn.is_none() || self.stale() {
             self.reconnect().map_err(|e| {
                 format!("FigCraft app at {} is not reachable: {e} (request was not sent; start it with `figcraft --control PORT`)", self.addr)
diff --git a/crates/mcp/tests/attached.rs b/crates/mcp/tests/attached.rs
--- a/crates/mcp/tests/attached.rs
+++ b/crates/mcp/tests/attached.rs
@@ -8,6 +8,14 @@ use std::net::TcpListener;
 use figcraft_mcp::{Attached, Backend, Headless, Server};
 use serde_json::{Value, json};
 
+fn token() -> figcraft_collab_net::control_auth::ControlToken {
+    figcraft_collab_net::control_auth::ControlToken::new("test_control_token_with_32_chars").unwrap()
+}
+
+fn attach(port: u16) -> Attached {
+    Attached::connect_with_token(&format!("127.0.0.1:{port}"), token()).unwrap()
+}
+
 fn fake_app() -> u16 {
     let listener = TcpListener::bind("127.0.0.1:0").unwrap();
     let port = listener.local_addr().unwrap().port();
@@ -18,6 +26,7 @@ fn fake_app() -> u16 {
             for line in BufReader::new(stream).lines() {
                 let Ok(line) = line else { break };
                 let v: Value = serde_json::from_str(&line).unwrap_or(Value::Null);
+                assert_eq!(v["token"], "test_control_token_with_32_chars");
                 let method = v["method"].as_str().unwrap_or("");
                 let reply = match h.call(method, v["params"].clone()) {
                     Ok(r) => json!({"id": v["id"], "ok": true, "result": r}),
@@ -41,7 +50,7 @@ fn call(s: &mut Server, tool: &str, args: Value) -> Value {
 #[test]
 fn attached_backend_forwards_everything() {
     let port = fake_app();
-    let backend = Attached::port(port).unwrap();
+    let backend = attach(port);
     assert!(backend.describe().contains(&port.to_string()));
     let mut s = Server::new(Box::new(backend));
     let r = call(&mut s, "create_node", json!({"type": "frame", "width": 64, "height": 32, "props": {"fill": "#FF0000"}}));
@@ -65,10 +74,21 @@ fn attached_backend_forwards_everything() {
     assert_eq!(err["isError"], true);
 }
 
+#[test]
+fn remote_destinations_are_rejected_before_connecting() {
+    let result = Attached::connect_with_token("192.0.2.1:7979", token());
+    let error = match result {
+        Ok(_) => panic!("remote destination unexpectedly accepted"),
+        Err(error) => error,
+    };
+    assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
+    assert!(error.to_string().contains("loopback"));
+}
+
 #[test]
 fn attached_to_nothing_fails_cleanly() {
     let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
-    assert!(Attached::port(port).is_err());
+    assert!(Attached::connect_with_token(&format!("127.0.0.1:{port}"), token()).is_err());
 }
 
 #[test]
@@ -84,6 +104,7 @@ fn lost_mutation_reply_is_not_replayed_and_next_call_reconnects() {
             let mut line = String::new();
             BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
             let request: Value = serde_json::from_str(&line).unwrap();
+            assert_eq!(request["token"], "test_control_token_with_32_chars");
             let method = request["method"].as_str().unwrap();
             methods.push(method.to_string());
             let result = h.call(method, request["params"].clone()).unwrap();
@@ -93,7 +114,7 @@ fn lost_mutation_reply_is_not_replayed_and_next_call_reconnects() {
         }
         methods
     });
-    let mut backend = Attached::port(port).unwrap();
+    let mut backend = attach(port);
     let result = backend.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 64, "height": 32}}));
     assert!(result.is_err(), "a lost reply must not turn into a successful replay: {result:?}");
     let error = result.unwrap_err();
@@ -119,6 +140,7 @@ fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() {
             let mut line = String::new();
             BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
             let request: Value = serde_json::from_str(&line).unwrap();
+            assert_eq!(request["token"], "test_control_token_with_32_chars");
             let method = request["method"].as_str().unwrap();
             methods.push(method.to_string());
             let result = h.call(method, request["params"].clone()).unwrap();
@@ -128,7 +150,7 @@ fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() {
         }
         methods
     });
-    let mut backend = Attached::port(port).unwrap();
+    let mut backend = attach(port);
     backend.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 64, "height": 32}})).unwrap();
     closed_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
     std::thread::sleep(std::time::Duration::from_millis(100));
@@ -141,7 +163,7 @@ fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() {
 #[test]
 fn reconnect_failure_reports_request_not_sent() {
     let listener = TcpListener::bind("127.0.0.1:0").unwrap();
-    let mut backend = Attached::port(listener.local_addr().unwrap().port()).unwrap();
+    let mut backend = attach(listener.local_addr().unwrap().port());
     drop(listener);
     assert!(backend.call("document.inspect", json!({})).is_err());
     let error = backend.call("document.inspect", json!({})).unwrap_err();
diff --git a/docs/cli.md b/docs/cli.md
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -131,7 +131,7 @@ machines.
 ### `mcp [--attach PORT]`
 
 Serve MCP on stdio: headless by default, or attached to a running app started with
-`figcraft --control PORT`. See [mcp.md](mcp.md). The headless session can host, join and publish
+`figcraft --control PORT`. Attached mode requires the same `FIGCRAFT_CONTROL_TOKEN` in the app and CLI process environments; headless mode remains token-free. See [mcp.md](mcp.md) for setup. The headless session can host, join and publish
 live sessions (`collab.*`: without a key on a server open to the public, with `collab.serverInfo`,
 `collab.manageFile` for the owner token and `collab.report`) and use plugin directories
 (`plugin.directory.*`); the one-shot `run` can't (no network link).
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -3,22 +3,29 @@
 The desktop app can be driven by other programs (tests, scripts, `figcraft-cli mcp --attach`,
 sibling Crafting Apps) through a loopback socket:
 
-```sh
+```bash
+export FIGCRAFT_CONTROL_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
 cargo run -p figcraft -- --control 7979 [file.figcraft|file.svg|image]
 ```
 
-The app listens on `127.0.0.1:<port>` only. Each connection carries **newline-delimited JSON**:
-one request object per line, one reply line per request, in order.
+The app listens on `127.0.0.1:<port>` only. Starting it with `--control` or `FIGCRAFT_CONTROL_PORT` requires `FIGCRAFT_CONTROL_TOKEN`. The quick start creates a fresh 256-bit token; the app and every attached client must inherit that same value. The token must contain 22–128 URL-safe ASCII characters; the app does not log its value. Do not put it on a command line or use `setx`. Headless MCP mode does not use this token. It protects the listener from local processes without the secret; same-user malware that can read process environments can read it too.
+
+Each connection carries **newline-delimited JSON**:
+one request object per line, one reply line per request, in order. The top-level `token` must be present and valid on every request. A missing or wrong token receives a generic `unauthorized` reply and closes the connection; malformed nonempty JSON also closes it after the error reply.
+
+```bash
+printf '{"id":1,"method":"ui.inspect","params":{},"token":"%s"}\n' "$FIGCRAFT_CONTROL_TOKEN"
+```
 
 ```text
-request:  {"id": 1, "method": "engine.execute", "params": {...}}
+request:  {"id": 1, "method": "engine.execute", "params": {...}, "token": "<FIGCRAFT_CONTROL_TOKEN>"}
 reply:    {"id": 1, "ok": true, "result": ...}
           {"id": 1, "ok": false, "error": "message"}
 ```
 
 Requests are queued and run on the UI thread at the start of the next frame, so replies reflect the
 real UI state. A request that the UI doesn't answer within 60 s gets a timeout error. A line that is
-not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}`. The implementation is in
+not JSON gets `{"id": null, "ok": false, "error": "bad JSON"}`. The implementation is in
 `crates/ui-egui/src/control.rs` (methods) and `apps/figcraft/src/control_server.rs` (socket).
 
 A request line is limited to **32 MiB before the newline**, including whitespace. The reader stops
@@ -54,10 +61,10 @@ calls never open dialogs: dialog requests are dropped. Returns the command's res
 commands return `{"id": N}`).
 
 ```json
-{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}
+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}
 {"id":1,"ok":true,"result":{"id":2}}
-{"id":2,"method":"engine.execute","params":{"command":"node.set","params":{"ids":[2],"props":{"fill":"#FF6B6B","cornerRadius":8}}}}
-{"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}}
+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":2,"method":"engine.execute","params":{"command":"node.set","params":{"ids":[2],"props":{"fill":"#FF6B6B","cornerRadius":8}}}}
+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}}
 ```
 
 ### `engine.commands {}`
@@ -144,8 +151,8 @@ tools, a press on a frame's or section's name label (as the canvas last drew it,
 frame's top edge) selects and drags that frame.
 
 ```json
-{"id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}}
-{"id":5,"method":"ui.pointer","params":{"kind":"drag","x":200,"y":200,"to":[300,260],"shift":true}}
+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}}
+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":5,"method":"ui.pointer","params":{"kind":"drag","x":200,"y":200,"to":[300,260],"shift":true}}
 ```
 
 ### `ui.key {key, shift?, alt?, cmd?, ctrl?}`
@@ -181,9 +188,9 @@ arrive in the same frame, so send a press and its release (or a double-click's t
 separate calls. Returns `{queued}`.
 
 ```json
-{"id":6,"method":"ui.input","params":{"events":[{"move":[1000,315]}]}}
-{"id":7,"method":"ui.input","params":{"events":[{"down":[1000,315]}]}}
-{"id":8,"method":"ui.input","params":{"events":[{"up":[1000,315]}]}}
+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":6,"method":"ui.input","params":{"events":[{"move":[1000,315]}]}}
+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":7,"method":"ui.input","params":{"events":[{"down":[1000,315]}]}}
+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":8,"method":"ui.input","params":{"events":[{"up":[1000,315]}]}}
 ```
 
 ### `ui.menu.invoke {id}`
@@ -273,13 +280,17 @@ Close the app (every window, without asking about unsaved changes).
 
 ## Example session (shell)
 
-```sh
+The app and client inherit the token from this Bash shell. Once the desktop window is ready, continue with the socket commands.
+
+```bash
+export FIGCRAFT_CONTROL_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
 port=7979
-figcraft --control $port &
+figcraft --control "$port" &
+# Wait until the desktop window is ready before continuing.
 exec 3<>/dev/tcp/127.0.0.1/$port
-echo '{"id":1,"method":"engine.execute","params":{"command":"frame.create","params":{"preset":"iPhone 16","x":0,"y":0}}}' >&3
+printf '{"id":1,"method":"engine.execute","params":{"command":"frame.create","params":{"preset":"iPhone 16","x":0,"y":0}},"token":"%s"}\n' "$FIGCRAFT_CONTROL_TOKEN" >&3
 read -r reply <&3; echo "$reply"
-echo '{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/figcraft.png"}}' >&3
+printf '{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/figcraft.png"},"token":"%s"}\n' "$FIGCRAFT_CONTROL_TOKEN" >&3
 read -r reply <&3; echo "$reply"
 ```
 
diff --git a/docs/mcp.md b/docs/mcp.md
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -17,7 +17,15 @@ The server lives in `crates/mcp` (`figcraft-mcp`) and is started by the CLI:
 | `figcraft-cli mcp` | **Headless**: an in-process engine session with one untitled document. No window; renders with the CPU renderer. |
 | `figcraft-cli mcp --attach <port>` | **Attached**: drives a running FigCraft app through its control channel (`figcraft --control <port>`). The user sees every change live. |
 
-`--attach` also takes `host:port`. The control channel only listens on 127.0.0.1.
+`--attach` also takes `host:port`. The app listens on 127.0.0.1 only. Attached clients resolve the target once per connection and reject before dialing if any resolved address is non-loopback; remote hosts and DNS names with any remote result are refused.
+
+Attached mode now requires `FIGCRAFT_CONTROL_TOKEN` in both the desktop app and `figcraft-cli` process environments. Set one fresh, random token in the launching shell before starting either process; use at least 128 random bits. For example, PowerShell:
+
+```powershell
+$env:FIGCRAFT_CONTROL_TOKEN = python -c "import secrets; print(secrets.token_urlsafe(32))"
+```
+
+Keep this variable in that shell's process environment; do not use `setx` or put the token on a command line. The accepted value is 22–128 URL-safe ASCII characters. Headless `figcraft-cli mcp` needs no token. The token prevents access by local processes without it, but same-user malware that can inspect process environments can read it too.
 
 Attached calls are never automatically replayed after a transport failure: the app may have
 applied the request before its reply was lost. The error reports that the operation may have
@@ -48,7 +56,7 @@ The Linux AppImage bundles the CLI: `FigCraft.AppImage cli <args>` runs `figcraf
 ```sh
 # headless
 claude mcp add figcraft -- /path/to/figcraft-cli mcp
-# drive the running app (start it with: figcraft --control 7979)
+# drive the running app (both processes inherit FIGCRAFT_CONTROL_TOKEN; start app with: figcraft --control 7979)
 claude mcp add figcraft-app -- /path/to/figcraft-cli mcp --attach 7979
 ```
 
