diff --git a/apps/app/src/lib/clipboard.test.ts b/apps/app/src/lib/clipboard.test.ts
new file mode 100644
index 0000000000..8778b9a542
--- /dev/null
+++ b/apps/app/src/lib/clipboard.test.ts
@@ -0,0 +1,119 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const toastMocks = vi.hoisted(() => ({
+  error: vi.fn(),
+  success: vi.fn(),
+}));
+
+vi.mock("@/components/ui/app-toast", () => ({
+  appToast: toastMocks,
+}));
+
+import {
+  copyTextToClipboard,
+  copyToClipboardWithToast,
+} from "./clipboard";
+
+function installClipboard(writeText: (text: string) => Promise<void>): void {
+  Object.defineProperty(navigator, "clipboard", {
+    configurable: true,
+    value: { writeText },
+  });
+}
+
+function removeClipboard(): void {
+  Object.defineProperty(navigator, "clipboard", {
+    configurable: true,
+    value: undefined,
+  });
+}
+
+function installEditingCommand(
+  implementation: (command: string) => boolean,
+) {
+  const execCommand = vi.fn(implementation);
+  Object.defineProperty(document, "execCommand", {
+    configurable: true,
+    value: execCommand,
+  });
+  return execCommand;
+}
+
+afterEach(() => {
+  document.body.replaceChildren();
+  toastMocks.error.mockReset();
+  toastMocks.success.mockReset();
+  vi.restoreAllMocks();
+  removeClipboard();
+});
+
+describe("copyTextToClipboard", () => {
+  it("uses the Clipboard API when it succeeds", async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    const editingCopy = installEditingCommand(() => true);
+    installClipboard(writeText);
+
+    await expect(copyTextToClipboard("hello")).resolves.toBe(true);
+
+    expect(writeText).toHaveBeenCalledWith("hello");
+    expect(editingCopy).not.toHaveBeenCalled();
+  });
+
+  it.each([
+    ["is unavailable", false],
+    ["rejects", true],
+  ])(
+    "falls back to the editing command when the Clipboard API %s",
+    async (_label, clipboardRejects) => {
+      if (clipboardRejects) {
+        installClipboard(
+          vi.fn().mockRejectedValue(new DOMException("Not allowed")),
+        );
+      } else {
+        removeClipboard();
+      }
+      const editingCopy = installEditingCommand(() => {
+        const textarea = document.querySelector("textarea");
+        expect(textarea?.value).toBe("LAN copy");
+        return true;
+      });
+
+      await expect(copyTextToClipboard("LAN copy")).resolves.toBe(true);
+
+      expect(editingCopy).toHaveBeenCalledWith("copy");
+      expect(document.querySelector("textarea")).toBeNull();
+    },
+  );
+
+  it("restores focus and reports failure when both copy methods fail", async () => {
+    removeClipboard();
+    installEditingCommand(() => false);
+    const button = document.createElement("button");
+    document.body.append(button);
+    button.focus();
+
+    await expect(copyTextToClipboard("nope")).resolves.toBe(false);
+
+    expect(document.activeElement).toBe(button);
+    expect(document.querySelector("textarea")).toBeNull();
+  });
+});
+
+describe("copyToClipboardWithToast", () => {
+  it("shows the configured error only after both copy methods fail", async () => {
+    installClipboard(vi.fn().mockRejectedValue(new Error("denied")));
+    installEditingCommand(() => false);
+
+    await expect(
+      copyToClipboardWithToast("text", {
+        errorMessage: "Couldn't copy",
+        successMessage: "Copied it",
+      }),
+    ).resolves.toBe(false);
+
+    expect(toastMocks.error).toHaveBeenCalledWith("Couldn't copy");
+    expect(toastMocks.success).not.toHaveBeenCalled();
+  });
+});
diff --git a/apps/app/src/lib/clipboard.ts b/apps/app/src/lib/clipboard.ts
index ae97e330dc..fc4a14a142 100644
--- a/apps/app/src/lib/clipboard.ts
+++ b/apps/app/src/lib/clipboard.ts
@@ -8,6 +8,90 @@ interface CopyToClipboardOptions {
   errorMessage?: string | null;
 }
 
+/**
+ * Copies through the browser's legacy editing command. Unlike the async
+ * Clipboard API, this remains available on plain-HTTP LAN origins when it is
+ * called synchronously from a user gesture.
+ */
+function copyWithEditingCommand(text: string): boolean {
+  if (
+    typeof document === "undefined" ||
+    document.body === null ||
+    typeof document.execCommand !== "function"
+  ) {
+    return false;
+  }
+
+  const activeElement =
+    document.activeElement instanceof HTMLElement
+      ? document.activeElement
+      : null;
+  const selection = document.getSelection();
+  const selectedRanges = selection
+    ? Array.from({ length: selection.rangeCount }, (_, index) =>
+        selection.getRangeAt(index).cloneRange(),
+      )
+    : [];
+  const textarea = document.createElement("textarea");
+  textarea.value = text;
+  textarea.readOnly = true;
+  textarea.setAttribute("aria-hidden", "true");
+  Object.assign(textarea.style, {
+    border: "0",
+    height: "1px",
+    left: "0",
+    opacity: "0",
+    padding: "0",
+    pointerEvents: "none",
+    position: "fixed",
+    top: "0",
+    width: "1px",
+  });
+  document.body.append(textarea);
+
+  let copied = false;
+  try {
+    textarea.focus({ preventScroll: true });
+    textarea.select();
+    textarea.setSelectionRange(0, textarea.value.length);
+    copied = document.execCommand("copy");
+  } catch {
+    copied = false;
+  } finally {
+    textarea.remove();
+    if (activeElement?.isConnected) {
+      activeElement.focus({ preventScroll: true });
+    }
+    if (selection) {
+      selection.removeAllRanges();
+      for (const range of selectedRanges) {
+        selection.addRange(range);
+      }
+    }
+  }
+  return copied;
+}
+
+/**
+ * Copies text using the modern API where available, with a user-gesture
+ * fallback for browsers serving bb from a non-secure LAN origin.
+ */
+export async function copyTextToClipboard(text: string): Promise<boolean> {
+  if (
+    typeof navigator !== "undefined" &&
+    typeof navigator.clipboard?.writeText === "function"
+  ) {
+    try {
+      await navigator.clipboard.writeText(text);
+      return true;
+    } catch {
+      // A present Clipboard API can still reject because of origin policy or
+      // permissions. The synchronous editing command may remain available.
+    }
+  }
+  return copyWithEditingCommand(text);
+}
+
 /**
  * Copies text to the clipboard and surfaces success/failure via appToast.
  * Returns `true` on success, `false` on failure.
@@ -19,18 +103,13 @@ export async function copyToClipboardWithToast(
     errorMessage = "Failed to copy",
   }: CopyToClipboardOptions = {},
 ): Promise<boolean> {
-  if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
-    if (errorMessage) appToast.error(errorMessage);
-    return false;
-  }
-  try {
-    await navigator.clipboard.writeText(text);
+  const copied = await copyTextToClipboard(text);
+  if (copied) {
     if (successMessage) appToast.success(successMessage);
     return true;
-  } catch {
-    if (errorMessage) appToast.error(errorMessage);
-    return false;
   }
+  if (errorMessage) appToast.error(errorMessage);
+  return false;
 }
 
 export interface ClipboardCopyOptions extends CopyToClipboardOptions {
