diff --git a/.env.example b/.env.example
index b9bfcada0e..a4da8c0296 100644
--- a/.env.example
+++ b/.env.example
@@ -59,6 +59,28 @@ RELAY_URL=ws://localhost:3000
 # (use `just web` for Vite HMR instead).
 # BUZZ_WEB_DIR=./web/dist
 
+# -----------------------------------------------------------------------------
+# Admin Dashboard (private moderation surface)
+# -----------------------------------------------------------------------------
+# Host name that serves the read-only moderation dashboard and its
+# /api/admin/v1 endpoints. Leave unset to keep the admin surface absent.
+# Setting it requires one of the two authentication variables below.
+# BUZZ_ADMIN_HOST=admin.localhost:3000
+#
+# Option A — Bearer token (recommended for public-facing / OSS deployments):
+# Exactly 64 hex characters (32 bytes); generate with `openssl rand -hex 32`.
+# `just admin` generates a throwaway one per run and prints it.
+# BUZZ_ADMIN_TOKEN=<64 hex characters>
+#
+# Option B — Network-layer auth (for deployments behind a VPN or firewall):
+# Set only when the admin API is already protected at the network layer.
+# The relay logs a WARN on every startup in this mode.
+# Only the exact value "true" is accepted; any other non-empty value fails.
+# BUZZ_ADMIN_INSECURE_NO_AUTH=true
+#
+# Directory holding the built dashboard assets (`pnpm -C admin-web build`).
+# BUZZ_ADMIN_WEB_DIR=./admin-web/dist
+
 # Shared Redis-backed admission limits. Defaults shown below; each value must
 # be a positive integer.
 # BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71a4bbd449..e0bf32cbb3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
 # Changelog
 
+## Unreleased
+
+- **Breaking:** the relay admin moderation API (`/api/admin/v1`) now requires
+  explicit authentication when `BUZZ_ADMIN_HOST` is set. Choose one mode:
+  - **Token mode:** set `BUZZ_ADMIN_TOKEN` to exactly 64 hex characters
+    (`openssl rand -hex 32`). Every API request requires `Authorization: Bearer`.
+    The dashboard prompts for the token on first load.
+  - **Network-layer mode:** set `BUZZ_ADMIN_INSECURE_NO_AUTH=true` (exact value
+    only). Use only when the admin API is already protected by a VPN or private
+    ingress. The relay logs a `WARN` on every startup. The dashboard skips the
+    token prompt and renders directly.
+  - Both set at the same time, or neither, is a startup error.
+  `Host`/`Origin` matching is retained in both modes as defense-in-depth.
+
 ## v0.5.3
 
 ### Desktop and shared changes
diff --git a/Justfile b/Justfile
index 64a1f36daf..98d6a1f614 100644
--- a/Justfile
+++ b/Justfile
@@ -390,7 +390,11 @@ admin: bootstrap _ensure-migrations
     pnpm -C admin-web build
     export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}"
     export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}"
+    # The relay refuses to start without a token, and never logs one. Mint a
+    # throwaway per run so no dev secret is ever committed or reused.
+    export BUZZ_ADMIN_TOKEN="${BUZZ_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
     echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
+    echo "Admin token (paste into dashboard prompt): ${BUZZ_ADMIN_TOKEN}"
     cargo run -p buzz-relay
 
 # Seed deterministic reports and product feedback for local admin dashboard review
@@ -403,7 +407,7 @@ admin-check: fmt-check
     cargo test -p buzz-relay api::admin
     cargo test -p buzz-relay router::tests
     pnpm -C admin-web check
-    pnpm -C admin-web exec playwright test
+    pnpm -C admin-web test:e2e
 
 # Start the relay server in release mode
 relay-release: _ensure-migrations
diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx
index f39ecc33c5..6b96c2f83e 100644
--- a/admin-web/src/App.tsx
+++ b/admin-web/src/App.tsx
@@ -6,7 +6,13 @@ import {
   useMemo,
   useState,
 } from "react";
-import { ApiFailure, request } from "./api";
+import {
+  ApiFailure,
+  probeAuthRequired,
+  request,
+  requestObjectUrl,
+} from "./api";
+import { setToken, useToken } from "./token";
 import type {
   FeedbackDetail,
   FeedbackSummary,
@@ -460,7 +466,7 @@ function FeedbackDetailView({ id }: { id: string }) {
                     <dd className="attachments">
                       {attachments.map((attachment) => (
                         <Attachment
-                          key={`${attachment.hash}-${attachment.url}`}
+                          key={`${attachment.hash}-${attachment.path}`}
                           attachment={attachment}
                         />
                       ))}
@@ -491,7 +497,7 @@ function FeedbackDetailView({ id }: { id: string }) {
 type FeedbackStatuses = Record<string, boolean>;
 
 interface FeedbackAttachment {
-  url: string;
+  path: string;
   sourceUrl: string;
   mimeType: string;
   hash: string;
@@ -551,7 +557,7 @@ function feedbackAttachments(
     const parsedSize = Number(values.get("size"));
     return [
       {
-        url: `/api/admin/v1/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
+        path: `/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
         sourceUrl: safeUrl,
         mimeType,
         hash,
@@ -596,7 +602,9 @@ function stripAttachmentMarkdown(
 }
 
 function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
-  const url = attachment.url;
+  const [objectUrl, setObjectUrl] = useState<string>();
+  const [failed, setFailed] = useState(false);
+  const path = attachment.path;
   const name =
     attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
   const metadata = [
@@ -607,33 +615,76 @@ function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
     .filter(Boolean)
     .join(" · ");
 
+  useEffect(() => {
+    // The API requires an Authorization header, so the bytes are fetched here
+    // and handed to the DOM as an object URL revoked on replacement/unmount.
+    let url: string | undefined;
+    let active = true;
+    setObjectUrl(undefined);
+    setFailed(false);
+    requestObjectUrl(path)
+      .then((created) => {
+        if (!active) {
+          URL.revokeObjectURL(created);
+          return;
+        }
+        url = created;
+        setObjectUrl(created);
+      })
+      .catch(() => {
+        if (active) setFailed(true);
+      });
+    return () => {
+      active = false;
+      if (url) URL.revokeObjectURL(url);
+    };
+  }, [path]);
+
+  const detail = failed ? "Could not load attachment" : metadata;
+
   if (attachment.mimeType.startsWith("image/")) {
     return (
       <figure className="image-attachment">
-        <a href={url} target="_blank" rel="noreferrer">
-          <img src={url} alt={name} loading="lazy" />
-        </a>
+        {objectUrl ? (
+          <a href={objectUrl} target="_blank" rel="noreferrer">
+            <img src={objectUrl} alt={name} />
+          </a>
+        ) : (
+          <div className="attachment-placeholder">
+            {failed ? "Unavailable" : "Loading…"}
+          </div>
+        )}
         <figcaption>
           <span>{name}</span>
-          <small>{metadata}</small>
+          <small>{detail}</small>
         </figcaption>
       </figure>
     );
   }
 
+  const label = (
+    <>
+      <FileIcon />
+      <span>
+        <strong>{name}</strong>
+        <small>{detail}</small>
+      </span>
+    </>
+  );
+
+  // Until the bytes are fetched there is nothing a link could point at: the
+  // API path itself would open unauthenticated in a new tab.
+  if (!objectUrl) return <div className="file-attachment">{label}</div>;
+
   return (
     <a
       className="file-attachment"
-      href={url}
+      href={objectUrl}
       target="_blank"
       rel="noreferrer"
       download={name}
     >
-      <FileIcon />
-      <span>
-        <strong>{name}</strong>
-        <small>{metadata}</small>
-      </span>
+      {label}
       <ArrowIcon />
     </a>
   );
@@ -797,10 +848,81 @@ function ArrowIcon() {
   );
 }
 
+/// The whole dashboard is behind the credential: with no token in session
+/// storage there is nothing worth rendering, and every API call would 401.
+function TokenPrompt({ rejected }: { rejected: boolean }) {
+  const [value, setValue] = useState("");
+  return (
+    <div className="app">
+      <form
+        className="state token-prompt"
+        onSubmit={(event) => {
+          event.preventDefault();
+          const token = value.trim();
+          if (token) setToken(token);
+        }}
+      >
+        <h2>Admin token required</h2>
+        <p>
+          {rejected
+            ? "That token was rejected. Enter the operator token for this deployment."
+            : "Enter the operator token for this deployment. It is kept for this browser session only."}
+        </p>
+        <label>
+          <span className="visually-hidden">Admin token</span>
+          <input
+            type="password"
+            name="token"
+            autoComplete="off"
+            placeholder="Admin token"
+            value={value}
+            onChange={(event) => setValue(event.target.value)}
+          />
+        </label>
+        <button type="submit">Continue</button>
+      </form>
+    </div>
+  );
+}
+
 export function App() {
   const { path } = usePath();
+  const token = useToken();
+  // Distinguishes the first visit from a token the relay just rejected.
+  const [everHadToken, setEverHadToken] = useState(token !== null);
+  useEffect(() => {
+    if (token !== null) setEverHadToken(true);
+  }, [token]);
+
+  // When no token is stored, probe the relay to find out whether it requires
+  // one. In insecure_no_auth mode the probe returns 200 and the dashboard
+  // renders without a credential; in token mode it returns 401 and the prompt
+  // is shown. `null` means the probe is still in flight.
+  const [authRequired, setAuthRequired] = useState<boolean | null>(
+    token !== null ? false : null,
+  );
+  useEffect(() => {
+    if (token !== null) {
+      setAuthRequired(false);
+      return;
+    }
+    let active = true;
+    probeAuthRequired().then((required) => {
+      if (active) setAuthRequired(required);
+    });
+    return () => {
+      active = false;
+    };
+  }, [token]);
+
   const report = path.match(/^\/reports\/([^/]+)$/);
   const feedback = path.match(/^\/feedback\/([^/]+)$/);
+
+  // Probe still in flight — render nothing to avoid a visible flash.
+  if (authRequired === null) return null;
+
+  if (authRequired && !token) return <TokenPrompt rejected={everHadToken} />;
+
   const content = report ? (
     <ReportDetail id={report[1]} />
   ) : feedback ? (
diff --git a/admin-web/src/api.ts b/admin-web/src/api.ts
index 9e5aa7569f..6f05dfe605 100644
--- a/admin-web/src/api.ts
+++ b/admin-web/src/api.ts
@@ -1,3 +1,5 @@
+import { clearToken, getToken } from "./token";
+
 const PREFIX = "/api/admin/v1";
 
 export class ApiFailure extends Error {
@@ -9,11 +11,24 @@ export class ApiFailure extends Error {
   }
 }
 
-export async function request<T>(path: string): Promise<T> {
+/// Every admin API call goes through here so the operator token is attached
+/// exactly once and a rejected credential is cleared in exactly one place.
+/// When no token is present the request is sent without an Authorization
+/// header; in insecure_no_auth mode the relay returns 200, while in token
+/// mode it returns 401 and the caller discovers that auth is required.
+async function send(path: string, accept: string): Promise<Response> {
+  const token = getToken();
+  const headers: Record<string, string> = { accept };
+  if (token) headers.authorization = `Bearer ${token}`;
   const response = await fetch(`${PREFIX}${path}`, {
     credentials: "same-origin",
-    headers: { accept: "application/json" },
+    headers,
   });
+  if (response.status === 401) {
+    // Idempotent: concurrent 401s collapse into a single re-prompt.
+    clearToken();
+    throw new ApiFailure(401, "The admin token was rejected.");
+  }
   if (!response.ok) {
     const envelope = await response.json().catch(() => null);
     throw new ApiFailure(
@@ -21,5 +36,40 @@ export async function request<T>(path: string): Promise<T> {
       envelope?.error?.message ?? `Request failed (${response.status})`,
     );
   }
+  return response;
+}
+
+export async function request<T>(path: string): Promise<T> {
+  const response = await send(path, "application/json");
   return response.json() as Promise<T>;
 }
+
+/// Probe whether the relay requires a bearer token. Issues one unauthenticated
+/// request and returns `true` if auth is required (token prompt should be shown)
+/// or `false` if the relay returned 200 (insecure_no_auth mode, no token needed).
+/// Used by the App shell to skip the token prompt for deployments that rely on
+/// network-layer access control.
+export async function probeAuthRequired(): Promise<boolean> {
+  try {
+    const response = await fetch(`${PREFIX}/reports`, {
+      credentials: "same-origin",
+      headers: { accept: "application/json" },
+    });
+    // 200 → insecure_no_auth mode, no token needed.
+    // Anything else — including 401, 403, 404, 5xx — means auth is required
+    // or the deployment is misconfigured. Show the prompt rather than silently
+    // skipping it.
+    return response.status !== 200;
+  } catch {
+    // Network error — treat as auth-required so the prompt is shown.
+    return true;
+  }
+}
+
+/// Attachments cannot be fetched by `<img src>` or `<a href>` because those
+/// carry no Authorization header. Callers render the object URL and must
+/// revoke it when it is replaced or unmounted.
+export async function requestObjectUrl(path: string): Promise<string> {
+  const response = await send(path, "*/*");
+  return URL.createObjectURL(await response.blob());
+}
diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css
index 93ebceeb14..357fef0a0b 100644
--- a/admin-web/src/styles.css
+++ b/admin-web/src/styles.css
@@ -667,6 +667,26 @@ dd {
   color: #9f2424;
 }
 
+.token-prompt input {
+  width: min(22rem, 70vw);
+  padding: 0.65rem 0.9rem;
+  border: 1px solid rgb(35 30 30 / 16%);
+  border-radius: 0.75rem;
+  font: inherit;
+}
+
+.token-prompt button {
+  margin-top: 1rem;
+}
+
+.attachment-placeholder {
+  min-height: 8rem;
+  display: grid;
+  place-content: center;
+  color: rgb(35 30 30 / 48%);
+  font-size: 0.8rem;
+}
+
 @media (max-width: 720px) {
   .app-header {
     width: min(100% - 2rem, 1120px);
diff --git a/admin-web/src/token.ts b/admin-web/src/token.ts
new file mode 100644
index 0000000000..7d5f4ef3c1
--- /dev/null
+++ b/admin-web/src/token.ts
@@ -0,0 +1,56 @@
+import { useSyncExternalStore } from "react";
+
+/// Session-scoped so the credential dies with the browser tab. It is never
+/// written to localStorage or a cookie, and never placed in a URL.
+const STORAGE_KEY = "buzz-admin-token";
+
+const listeners = new Set<() => void>();
+let token = read();
+
+function read(): string | null {
+  try {
+    return sessionStorage.getItem(STORAGE_KEY);
+  } catch {
+    return null;
+  }
+}
+
+function publish(next: string | null) {
+  if (token === next) return;
+  token = next;
+  for (const listener of listeners) listener();
+}
+
+function subscribe(listener: () => void) {
+  listeners.add(listener);
+  return () => {
+    listeners.delete(listener);
+  };
+}
+
+export function getToken() {
+  return token;
+}
+
+export function setToken(next: string) {
+  try {
+    sessionStorage.setItem(STORAGE_KEY, next);
+  } catch {
+    // The token still works for this page load if storage is blocked.
+  }
+  publish(next);
+}
+
+/// Idempotent so concurrent 401s from parallel requests re-prompt once.
+export function clearToken() {
+  try {
+    sessionStorage.removeItem(STORAGE_KEY);
+  } catch {
+    // Nothing was stored; the in-memory token is authoritative.
+  }
+  publish(null);
+}
+
+export function useToken() {
+  return useSyncExternalStore(subscribe, getToken, getToken);
+}
diff --git a/admin-web/tests/auth.spec.ts b/admin-web/tests/auth.spec.ts
new file mode 100644
index 0000000000..3a3f9f8aa8
--- /dev/null
+++ b/admin-web/tests/auth.spec.ts
@@ -0,0 +1,448 @@
+import { expect, type Page, test } from "@playwright/test";
+
+const TOKEN =
+  "5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
+const STORAGE_KEY = "buzz-admin-token";
+
+async function seedToken(page: Page, token = TOKEN) {
+  await page.addInitScript(
+    ([key, value]) => {
+      sessionStorage.setItem(key, value);
+    },
+    [STORAGE_KEY, token],
+  );
+}
+
+/// Records the Authorization header of every admin API call the SPA makes.
+async function recordAuthorization(page: Page, body: unknown = []) {
+  const seen: (string | undefined)[] = [];
+  await page.route("**/api/admin/v1/**", async (route) => {
+    const authorization = route.request().headers().authorization;
+    seen.push(authorization);
+    // Simulate token-mode relay: 401 without a credential, 200 with one.
+    if (!authorization) {
+      await route.fulfill({
+        status: 401,
+        contentType: "application/json",
+        body: JSON.stringify({
+          error: { code: "unauthorized", message: "token required" },
+        }),
+      });
+    } else {
+      await route.fulfill({
+        contentType: "application/json",
+        body: JSON.stringify(body),
+      });
+    }
+  });
+  return seen;
+}
+
+test("the dashboard prompts for a token before any api call", async ({
+  page,
+}) => {
+  const seen = await recordAuthorization(page);
+  await page.goto("/reports");
+  await expect(
+    page.getByRole("heading", { name: "Admin token required" }),
+  ).toBeVisible();
+  // The probe fires unauthenticated (no Authorization header), but no
+  // bearer-credentialed call has been made yet.
+  expect(seen.filter(Boolean)).toHaveLength(0);
+
+  await page.getByPlaceholder("Admin token").fill(TOKEN);
+  await page.getByRole("button", { name: "Continue" }).click();
+  await expect(
+    page.getByRole("heading", { name: "Open reports" }),
+  ).toBeVisible();
+  expect(seen).toContain(`Bearer ${TOKEN}`);
+});
+
+test("api calls carry the stored token", async ({ page }) => {
+  await seedToken(page);
+  const seen = await recordAuthorization(page);
+  await page.goto("/reports");
+  await expect(
+    page.getByRole("heading", { name: "Open reports" }),
+  ).toBeVisible();
+  expect(seen).toEqual([`Bearer ${TOKEN}`]);
+});
+
+test("the token survives a reload within the session", async ({ page }) => {
+  // Simulate token mode: 401 for unauthenticated, 200 for authenticated.
+  await page.route("**/api/admin/v1/**", (route) => {
+    const authorization = route.request().headers().authorization;
+    if (!authorization) {
+      route.fulfill({
+        status: 401,
+        contentType: "application/json",
+        body: JSON.stringify({
+          error: { code: "unauthorized", message: "token required" },
+        }),
+      });
+    } else {
+      route.fulfill({ contentType: "application/json", body: "[]" });
+    }
+  });
+  await page.goto("/reports");
+  await page.getByPlaceholder("Admin token").fill(TOKEN);
+  await page.getByRole("button", { name: "Continue" }).click();
+  await expect(
+    page.getByRole("heading", { name: "Open reports" }),
+  ).toBeVisible();
+
+  await page.reload();
+  await expect(
+    page.getByRole("heading", { name: "Open reports" }),
+  ).toBeVisible();
+  await expect(
+    page.getByRole("heading", { name: "Admin token required" }),
+  ).toHaveCount(0);
+});
+
+test("a rejected token clears storage and re-prompts once", async ({
+  page,
+}) => {
+  await seedToken(page, "f".repeat(64));
+  await page.route("**/api/admin/v1/**", (route) =>
+    route.fulfill({
+      status: 401,
+      contentType: "application/json",
+      body: JSON.stringify({
+        error: { code: "unauthorized", message: "rejected" },
+      }),
+    }),
+  );
+
+  await page.goto("/reports");
+  const prompt = page.getByRole("heading", { name: "Admin token required" });
+  await expect(prompt).toHaveCount(1);
+  await expect(page.getByText("That token was rejected.")).toBeVisible();
+  expect(
+    await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY),
+  ).toBeNull();
+});
+
+test("attachments are fetched with the token and rendered from blob urls", async ({
+  page,
+}) => {
+  const id = "feedback-with-attachments";
+  const imageHash = "a".repeat(64);
+  const fileHash = "b".repeat(64);
+  const imageUrl = `https://design.buzz.xyz/media/${imageHash}.png`;
+  const fileUrl = `https://design.buzz.xyz/media/${fileHash}.txt`;
+  await seedToken(page);
+
+  const attachmentRequests: { path: string; authorization?: string }[] = [];
+  await page.route(`**/api/admin/v1/feedback/${id}/attachments/**`, (route) => {
+    attachmentRequests.push({
+      path: new URL(route.request().url()).pathname,
+      authorization: route.request().headers().authorization,
+    });
+    route.fulfill({ contentType: "application/octet-stream", body: "bytes" });
+  });
+  await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
+    route.fulfill({
+      contentType: "application/json",
+      body: JSON.stringify({
+        id,
+        communityId: "one",
+        communityHost: "design.buzz.xyz",
+        eventId: "31".repeat(32),
+        submitterPubkey: "21".repeat(32),
+        category: "bug",
+        body: "Composer froze.",
+        tags: [
+          [
+            "imeta",
+            `url ${imageUrl}`,
+            "m image/png",
+            `x ${imageHash}`,
+            "filename screenshot.png",
+          ],
+          [
+            "imeta",
+            `url ${fileUrl}`,
+            "m text/plain",
+            `x ${fileHash}`,
+            "filename diagnostics.txt",
+          ],
+        ],
+        eventCreatedAt: "2026-07-17T17:25:00Z",
+        receivedAt: "2026-07-17T17:30:00Z",
+      }),
+    }),
+  );
+
+  await page.goto(`/feedback/${id}`);
+  await expect(
+    page.getByRole("img", { name: "screenshot.png" }),
+  ).toHaveAttribute("src", /^blob:/);
+  await expect(
+    page.getByRole("link", { name: /diagnostics.txt/ }),
+  ).toHaveAttribute("href", /^blob:/);
+
+  expect(attachmentRequests.map((request) => request.path).sort()).toEqual(
+    [
+      `/api/admin/v1/feedback/${id}/attachments/${imageHash}`,
+      `/api/admin/v1/feedback/${id}/attachments/${fileHash}`,
+    ].sort(),
+  );
+  for (const request of attachmentRequests) {
+    expect(request.authorization).toBe(`Bearer ${TOKEN}`);
+  }
+});
+
+interface ObjectUrlLog {
+  created: string[];
+  revoked: string[];
+}
+
+declare global {
+  interface Window {
+    objectUrlLog: ObjectUrlLog;
+    clearedCount: number;
+  }
+}
+
+/// Records every object URL the SPA creates and revokes, so a test can prove a
+/// blob handed to the DOM is released rather than merely replaced.
+async function instrumentObjectUrls(page: Page) {
+  await page.addInitScript(() => {
+    const log: ObjectUrlLog = { created: [], revoked: [] };
+    window.objectUrlLog = log;
+    const create = URL.createObjectURL.bind(URL);
+    const revoke = URL.revokeObjectURL.bind(URL);
+    URL.createObjectURL = (source: Blob | MediaSource) => {
+      const url = create(source);
+      log.created.push(url);
+      return url;
+    };
+    URL.revokeObjectURL = (url: string) => {
+      log.revoked.push(url);
+      revoke(url);
+    };
+  });
+}
+
+const FEEDBACK_ID = "feedback-with-attachments";
+const IMAGE_HASH = "a".repeat(64);
+const FILE_HASH = "b".repeat(64);
+
+/// A feedback detail carrying one image and one non-image attachment.
+async function routeFeedbackDetail(page: Page) {
+  const host = "design.buzz.xyz";
+  await page.route(`**/api/admin/v1/feedback?**`, (route) =>
+    route.fulfill({ contentType: "application/json", body: "[]" }),
+  );
+  await page.route(`**/api/admin/v1/feedback/${FEEDBACK_ID}`, (route) =>
+    route.fulfill({
+      contentType: "application/json",
+      body: JSON.stringify({
+        id: FEEDBACK_ID,
+        communityId: "one",
+        communityHost: host,
+        eventId: "31".repeat(32),
+        submitterPubkey: "21".repeat(32),
+        category: "bug",
+        body: "Composer froze.",
+        tags: [
+          [
+            "imeta",
+            `url https://${host}/media/${IMAGE_HASH}.png`,
+            "m image/png",
+            `x ${IMAGE_HASH}`,
+            "filename screenshot.png",
+          ],
+          [
+            "imeta",
+            `url https://${host}/media/${FILE_HASH}.txt`,
+            "m text/plain",
+            `x ${FILE_HASH}`,
+            "filename diagnostics.txt",
+          ],
+        ],
+        eventCreatedAt: "2026-07-17T17:25:00Z",
+        receivedAt: "2026-07-17T17:30:00Z",
+      }),
+    }),
+  );
+}
+
+test("attachment object urls are revoked when the view is left", async ({
+  page,
+}) => {
+  await seedToken(page);
+  await instrumentObjectUrls(page);
+  await routeFeedbackDetail(page);
+  await page.route(
+    `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`,
+    (route) =>
+      route.fulfill({ contentType: "application/octet-stream", body: "bytes" }),
+  );
+
+  await page.goto(`/feedback/${FEEDBACK_ID}`);
+  const imageUrl = await page
+    .getByRole("img", { name: "screenshot.png" })
+    .getAttribute("src");
+  const fileUrl = await page
+    .getByRole("link", { name: /diagnostics.txt/ })
+    .getAttribute("href");
+  expect(imageUrl).toMatch(/^blob:/);
+  expect(fileUrl).toMatch(/^blob:/);
+  expect(await page.evaluate(() => window.objectUrlLog.revoked)).toEqual([]);
+
+  await page.getByRole("link", { name: "Back to feedback" }).click();
+  await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible();
+
+  await expect
+    .poll(() => page.evaluate(() => window.objectUrlLog.revoked))
+    .toEqual(expect.arrayContaining([imageUrl, fileUrl]));
+});
+
+test("an attachment that arrives after the view is left is revoked immediately", async ({
+  page,
+}) => {
+  await seedToken(page);
+  await instrumentObjectUrls(page);
+  await routeFeedbackDetail(page);
+  let release = () => {};
+  const held = new Promise<void>((resolve) => {
+    release = resolve;
+  });
+  await page.route(
+    `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`,
+    async (route) => {
+      await held;
+      await route.fulfill({
+        contentType: "application/octet-stream",
+        body: "bytes",
+      });
+    },
+  );
+
+  await page.goto(`/feedback/${FEEDBACK_ID}`);
+  // Both fetches are held, so no blob exists yet.
+  await expect(page.getByText("Loading…")).toBeVisible();
+  expect(await page.evaluate(() => window.objectUrlLog.created)).toEqual([]);
+
+  // Leave before either fetch resolves, then let both complete.
+  await page.getByRole("link", { name: "Back to feedback" }).click();
+  await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible();
+  release();
+
+  await expect
+    .poll(() => page.evaluate(() => window.objectUrlLog.revoked.length))
+    .toBe(2);
+  const log = await page.evaluate(() => window.objectUrlLog);
+  expect(log.revoked.sort()).toEqual(log.created.sort());
+  // Nothing was ever handed to the DOM: the blobs outlived their view.
+  await expect(page.getByRole("img", { name: "screenshot.png" })).toHaveCount(
+    0,
+  );
+});
+
+test("concurrent rejected requests re-prompt exactly once", async ({
+  page,
+}) => {
+  await seedToken(page, "f".repeat(64));
+  await routeFeedbackDetail(page);
+  // Counts how many rejections reached the centralized clearing path, so the
+  // test can distinguish "two 401s collapsed into one prompt" from "only one
+  // request ever failed".
+  await page.addInitScript(() => {
+    let cleared = 0;
+    const remove = sessionStorage.removeItem.bind(sessionStorage);
+    sessionStorage.removeItem = (key: string) => {
+      cleared += 1;
+      remove(key);
+    };
+    Object.defineProperty(window, "clearedCount", { get: () => cleared });
+  });
+
+  // Both attachment requests are held until the second arrives, so the two
+  // 401s are in flight at the same time.
+  let secondArrived = () => {};
+  const bothInFlight = new Promise<void>((resolve) => {
+    secondArrived = resolve;
+  });
+  let pending = 2;
+  await page.route(
+    `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`,
+    async (route) => {
+      pending -= 1;
+      if (pending === 0) secondArrived();
+      await bothInFlight;
+      await route.fulfill({
+        status: 401,
+        contentType: "application/json",
+        body: JSON.stringify({
+          error: { code: "unauthorized", message: "rejected" },
+        }),
+      });
+    },
+  );
+
+  await page.goto(`/feedback/${FEEDBACK_ID}`);
+  await expect(
+    page.getByRole("heading", { name: "Admin token required" }),
+  ).toHaveCount(1);
+  await expect(page.getByText("That token was rejected.")).toBeVisible();
+  expect(
+    await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY),
+  ).toBeNull();
+  expect(pending).toBe(0);
+  await expect
+    .poll(() => page.evaluate(() => window.clearedCount))
+    .toBeGreaterThanOrEqual(2);
+  await expect(
+    page.getByRole("heading", { name: "Admin token required" }),
+  ).toHaveCount(1);
+});
+
+test("probe: insecure_no_auth mode skips the token prompt when probe returns 200", async ({
+  page,
+}) => {
+  // No token in storage. The probe to /api/admin/v1/reports returns 200,
+  // indicating the relay runs in insecure_no_auth mode. The dashboard must
+  // render directly without showing the token prompt.
+  await page.route("**/api/admin/v1/reports**", (route) =>
+    route.fulfill({ contentType: "application/json", body: "[]" }),
+  );
+
+  await page.goto("/reports");
+
+  await expect(
+    page.getByRole("heading", { name: "Open reports" }),
+  ).toBeVisible();
+  await expect(
+    page.getByRole("heading", { name: "Admin token required" }),
+  ).toHaveCount(0);
+});
+
+test("probe: token mode shows the prompt when probe returns 401", async ({
+  page,
+}) => {
+  // No token in storage. The probe to /api/admin/v1/reports returns 401,
+  // indicating the relay requires a bearer token. The prompt must be shown.
+  await page.route("**/api/admin/v1/**", (route) =>
+    route.fulfill({
+      status: 401,
+      contentType: "application/json",
+      body: JSON.stringify({
+        error: { code: "unauthorized", message: "token required" },
+      }),
+    }),
+  );
+
+  await page.goto("/reports");
+
+  await expect(
+    page.getByRole("heading", { name: "Admin token required" }),
+  ).toBeVisible();
+  // The prompt must not say "rejected" on a fresh first visit.
+  await expect(page.getByText("That token was rejected.")).toHaveCount(0);
+  await expect(page.getByRole("heading", { name: "Open reports" })).toHaveCount(
+    0,
+  );
+});
diff --git a/admin-web/tests/csp.spec.ts b/admin-web/tests/csp.spec.ts
new file mode 100644
index 0000000000..343862ad33
--- /dev/null
+++ b/admin-web/tests/csp.spec.ts
@@ -0,0 +1,109 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { expect, test } from "@playwright/test";
+
+const ROUTER_RS = fileURLToPath(
+  new URL("../../crates/buzz-relay/src/router.rs", import.meta.url),
+);
+
+/// The exact policy the relay serves on admin SPA documents. Read from the
+/// relay source rather than copied, so this test can never pass against a
+/// policy operators do not actually get. The preview server used here serves
+/// no CSP of its own, so it is injected below.
+function adminCsp() {
+  const source = readFileSync(ROUTER_RS, "utf8");
+  const match = source.match(/const ADMIN_CSP: &str = "([^"]+)";/);
+  if (!match) throw new Error(`ADMIN_CSP not found in ${ROUTER_RS}`);
+  return match[1];
+}
+
+const TOKEN =
+  "5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
+
+test("the relay admin csp does not break the built dashboard", async ({
+  page,
+}) => {
+  const csp = adminCsp();
+  expect(csp).toContain("frame-ancestors 'none'");
+  expect(csp).not.toContain("unsafe-inline");
+
+  await page.addInitScript((token) => {
+    sessionStorage.setItem("buzz-admin-token", token);
+  }, TOKEN);
+  await page.route("**/api/admin/v1/**", (route) =>
+    route.fulfill({ contentType: "application/json", body: "[]" }),
+  );
+  // Only the document request: the API call to /reports carries a query string.
+  await page.route(
+    (url) => url.pathname === "/reports" && url.search === "",
+    async (route) => {
+      const response = await route.fetch();
+      await route.fulfill({
+        response,
+        headers: { ...response.headers(), "content-security-policy": csp },
+      });
+    },
+  );
+
+  const violations: string[] = [];
+  page.on("console", (message) => {
+    if (message.text().includes("Content Security Policy"))
+      violations.push(message.text());
+  });
+
+  await page.goto("/reports");
+
+  // Rendering at all proves the bundle's script and stylesheet loaded, and the
+  // empty state proves the fetch to /api/admin/v1 survived `connect-src 'self'`.
+  await expect(
+    page.getByRole("heading", { name: "Open reports" }),
+  ).toBeVisible();
+  await expect(page.getByText("No records.")).toBeVisible();
+  expect(violations).toEqual([]);
+});
+
+test("the linked favicon loads under the admin csp", async ({ page }) => {
+  const csp = adminCsp();
+
+  await page.route(
+    (url) => url.pathname === "/" && url.search === "",
+    async (route) => {
+      const response = await route.fetch();
+      await route.fulfill({
+        response,
+        headers: { ...response.headers(), "content-security-policy": csp },
+      });
+    },
+  );
+
+  const violations: string[] = [];
+  page.on("console", (message) => {
+    if (message.text().includes("Content Security Policy"))
+      violations.push(message.text());
+  });
+
+  await page.goto("/");
+
+  const href = await page.locator("link[rel=icon]").getAttribute("href");
+  expect(href).toBe("/favicon.svg");
+
+  // Headless Chromium never issues the `<link rel=icon>` request itself, so
+  // load the same file the same way the policy sees it: an image fetch under
+  // `img-src 'self'`. A blocked fetch rejects `decode()`; a successful one
+  // proves the icon renders, and that the SVG's own inline <style> is not
+  // subject to the embedding document's `style-src`.
+  const decoded = await page.evaluate(async (src) => {
+    const image = new Image();
+    image.src = src;
+    try {
+      await image.decode();
+      return { ok: true, width: image.naturalWidth };
+    } catch (error) {
+      return { ok: false, error: String(error) };
+    }
+  }, href as string);
+
+  expect(decoded.ok).toBe(true);
+  expect(decoded.width).toBeGreaterThan(0);
+  expect(violations).toEqual([]);
+});
diff --git a/admin-web/tests/routes.spec.ts b/admin-web/tests/routes.spec.ts
index 3c965dd2d8..3fbdd8a0c0 100644
--- a/admin-web/tests/routes.spec.ts
+++ b/admin-web/tests/routes.spec.ts
@@ -1,6 +1,12 @@
 import { expect, test } from "@playwright/test";
 
+const TOKEN =
+  "5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
+
 test.beforeEach(async ({ page }) => {
+  await page.addInitScript((token) => {
+    sessionStorage.setItem("buzz-admin-token", token);
+  }, TOKEN);
   await page.route("**/api/admin/v1/**", async (route) => {
     await route.fulfill({ contentType: "application/json", body: "[]" });
   });
@@ -301,16 +307,10 @@ test("feedback attachments render from imeta without raw markdown", async ({
   await expect(page.getByText("![image]", { exact: false })).toHaveCount(0);
   await expect(
     page.getByRole("img", { name: "screenshot.png" }),
-  ).toHaveAttribute(
-    "src",
-    `/api/admin/v1/feedback/${id}/attachments/${"a".repeat(64)}`,
-  );
+  ).toHaveAttribute("src", /^blob:/);
   await expect(
     page.getByRole("link", { name: /diagnostics.txt/ }),
-  ).toHaveAttribute(
-    "href",
-    `/api/admin/v1/feedback/${id}/attachments/${"b".repeat(64)}`,
-  );
+  ).toHaveAttribute("href", /^blob:/);
   const fileHeight = await page
     .locator(".file-attachment")
     .evaluate((element) => element.getBoundingClientRect().height);
diff --git a/crates/buzz-relay/CHANGELOG.md b/crates/buzz-relay/CHANGELOG.md
index c0d6ab9ccd..b64cacd089 100644
--- a/crates/buzz-relay/CHANGELOG.md
+++ b/crates/buzz-relay/CHANGELOG.md
@@ -1,5 +1,19 @@
 # Changelog
 
+## Unreleased
+
+- **Breaking:** `/api/admin/v1` now requires explicit authentication when
+  `BUZZ_ADMIN_HOST` is set. Choose one mode:
+  - **Token mode:** set `BUZZ_ADMIN_TOKEN` (exactly 64 hex characters,
+    `openssl rand -hex 32`); startup fails closed without it. Every request
+    requires `Authorization: Bearer`. Unauthenticated requests get a uniform
+    `401` with `WWW-Authenticate: Bearer` before any database or media access.
+  - **Network-layer mode:** set `BUZZ_ADMIN_INSECURE_NO_AUTH=true` (exact value
+    only). Use only when the admin API is already protected at the network layer
+    (VPN, firewall, private ingress). The relay logs a `WARN` on every startup.
+  - Both set at the same time, or neither, is a startup error.
+  `Host`/`Origin` checks are demoted to defense-in-depth in both modes.
+
 ## relay-v0.2.0
 
 - feat: relay invite links (mint + claim + landing page + deep link) ([#1668](https://github.com/block/buzz/pull/1668)) ([`2e529aab7`](https://github.com/block/buzz/commit/2e529aab759a18c1bb81e447f3696fe99db53a27))
diff --git a/crates/buzz-relay/src/api/admin/auth.rs b/crates/buzz-relay/src/api/admin/auth.rs
index 71b8c9a8a5..a526fc936c 100644
--- a/crates/buzz-relay/src/api/admin/auth.rs
+++ b/crates/buzz-relay/src/api/admin/auth.rs
@@ -1,6 +1,7 @@
 use axum::http::{header, HeaderMap};
 
 use super::error::ApiError;
+use crate::config::{AdminAuth, AdminToken};
 use crate::state::AppState;
 
 pub(crate) fn is_admin_host(state: &AppState, headers: &HeaderMap) -> bool {
@@ -19,6 +20,14 @@ pub fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError>
         .admin
         .as_ref()
         .ok_or_else(ApiError::not_found)?;
+    // Credential first: an unauthenticated caller learns nothing about which
+    // Host or Origin the deployment expects. In InsecureNoAuth mode the
+    // bearer check is skipped — the operator has asserted that network-layer
+    // controls substitute for it.
+    match &config.auth {
+        AdminAuth::Token(token) => authorize_bearer(token, headers)?,
+        AdminAuth::InsecureNoAuth => {}
+    }
     if !is_admin_host(state, headers) {
         return Err(ApiError::forbidden());
     }
@@ -32,6 +41,42 @@ pub fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError>
     Ok(())
 }
 
+/// Require exactly one `Authorization: Bearer <64 hex>` header matching the
+/// configured operator token. Every rejection is the same 401 envelope.
+fn authorize_bearer(token: &AdminToken, headers: &HeaderMap) -> Result<(), ApiError> {
+    let mut values = headers.get_all(header::AUTHORIZATION).iter();
+    let (Some(value), None) = (values.next(), values.next()) else {
+        return Err(ApiError::unauthorized());
+    };
+    let credential = value
+        .to_str()
+        .ok()
+        .and_then(bearer_credential)
+        .ok_or_else(ApiError::unauthorized)?;
+    // Length and hex shape are public facts about the credential format, so
+    // rejecting them early leaks nothing; only the value compare must be
+    // constant-time.
+    let mut presented = [0u8; 32];
+    hex::decode_to_slice(credential, &mut presented)
+        .map_err(|_| ApiError::unauthorized())
+        .and_then(|()| {
+            token
+                .matches(&presented)
+                .then_some(())
+                .ok_or_else(ApiError::unauthorized)
+        })
+}
+
+/// Extract the credential from an `Authorization` value. RFC 9110 makes the
+/// auth scheme case-insensitive, so `bearer` is as valid as `Bearer`.
+fn bearer_credential(value: &str) -> Option<&str> {
+    let (scheme, credential) = value.split_once(' ')?;
+    scheme
+        .eq_ignore_ascii_case("Bearer")
+        .then(|| credential.trim_start_matches(' '))
+        .filter(|credential| !credential.is_empty())
+}
+
 fn origin_matches_host(origin: &str, host: &str) -> bool {
     origin
         .strip_prefix("https://")
@@ -41,7 +86,7 @@ fn origin_matches_host(origin: &str, host: &str) -> bool {
 
 #[cfg(test)]
 mod tests {
-    use super::origin_matches_host;
+    use super::{bearer_credential, origin_matches_host};
 
     #[test]
     fn browser_origin_must_match_admin_host() {
@@ -59,4 +104,15 @@ mod tests {
         ));
         assert!(!origin_matches_host("null", "admin.example.com"));
     }
+
+    #[test]
+    fn bearer_scheme_is_case_insensitive_and_credential_is_non_empty() {
+        assert_eq!(bearer_credential("Bearer abc"), Some("abc"));
+        assert_eq!(bearer_credential("bearer abc"), Some("abc"));
+        assert_eq!(bearer_credential("BEARER  abc"), Some("abc"));
+        assert_eq!(bearer_credential("Bearer "), None);
+        assert_eq!(bearer_credential("Basic abc"), None);
+        assert_eq!(bearer_credential("abc"), None);
+        assert_eq!(bearer_credential(""), None);
+    }
 }
diff --git a/crates/buzz-relay/src/api/admin/error.rs b/crates/buzz-relay/src/api/admin/error.rs
index 02190384f5..052205a579 100644
--- a/crates/buzz-relay/src/api/admin/error.rs
+++ b/crates/buzz-relay/src/api/admin/error.rs
@@ -42,6 +42,14 @@ impl ApiError {
         }
     }
 
+    pub fn unauthorized() -> Self {
+        Self {
+            status: StatusCode::UNAUTHORIZED,
+            code: "unauthorized",
+            message: "a valid admin bearer token is required",
+        }
+    }
+
     pub fn not_found() -> Self {
         Self {
             status: StatusCode::NOT_FOUND,
@@ -61,7 +69,7 @@ impl ApiError {
 
 impl IntoResponse for ApiError {
     fn into_response(self) -> Response {
-        (
+        let mut response = (
             self.status,
             Json(ErrorEnvelope {
                 error: ErrorBody {
@@ -71,7 +79,16 @@ impl IntoResponse for ApiError {
                 },
             }),
         )
-            .into_response()
+            .into_response();
+        // RFC 9110 requires a challenge on every 401 so clients know which
+        // scheme to present.
+        if self.status == StatusCode::UNAUTHORIZED {
+            response.headers_mut().insert(
+                axum::http::header::WWW_AUTHENTICATE,
+                axum::http::HeaderValue::from_static("Bearer"),
+            );
+        }
+        response
     }
 }
 
diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs
index 21f30065f0..8c22a6e029 100644
--- a/crates/buzz-relay/src/api/admin/mod.rs
+++ b/crates/buzz-relay/src/api/admin/mod.rs
@@ -328,15 +328,65 @@ fn summarize_body(body: &str, tags: &serde_json::Value) -> String {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use axum::{body::Body, http::Request};
+    use axum::{
+        body::Body,
+        http::{Request, StatusCode},
+    };
     use tower::ServiceExt;
 
+    const TOKEN: &str = "5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
+
     async fn test_state() -> Arc<crate::state::AppState> {
+        let mut config = crate::config::Config::from_env().expect("default config loads");
+        config.require_relay_membership = false;
+        config.redis_url = "redis://127.0.0.1:1".to_string();
+        let mut token = [0u8; 32];
+        hex::decode_to_slice(TOKEN, &mut token).expect("test token is hex");
+        config.admin = Some(crate::config::AdminConfig {
+            host: "admin.example".to_string(),
+            auth: crate::config::AdminAuth::Token(crate::config::AdminToken::from_bytes(token)),
+            web_dir: None,
+        });
+        let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool");
+        let db = buzz_db::Db::from_pool(pool.clone());
+        let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
+            .create_pool(Some(deadpool_redis::Runtime::Tokio1))
+            .expect("redis pool");
+        let pubsub = Arc::new(
+            buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
+                .await
+                .expect("pubsub manager"),
+        );
+        let audit = buzz_audit::AuditService::new(pool.clone());
+        let auth = buzz_auth::AuthService::new(config.auth.clone());
+        let search = buzz_search::SearchService::new(pool.clone());
+        let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
+            db.clone(),
+            buzz_workflow::WorkflowConfig::default(),
+        ));
+        let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage");
+        let (state, _audit_shutdown) = crate::state::AppState::new(
+            config,
+            db,
+            redis_pool,
+            audit,
+            pubsub,
+            auth,
+            search,
+            workflow_engine,
+            nostr::Keys::generate(),
+            media_storage,
+        );
+        Arc::new(state)
+    }
+
+    async fn insecure_no_auth_state() -> Arc<crate::state::AppState> {
         let mut config = crate::config::Config::from_env().expect("default config loads");
         config.require_relay_membership = false;
         config.redis_url = "redis://127.0.0.1:1".to_string();
         config.admin = Some(crate::config::AdminConfig {
             host: "admin.example".to_string(),
+            auth: crate::config::AdminAuth::InsecureNoAuth,
             web_dir: None,
         });
         let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool");
@@ -374,84 +424,234 @@ mod tests {
 
     const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
 
+    /// Every route the admin API mounts. Each must enforce the credential.
+    fn mounted_routes() -> Vec<String> {
+        let id = Uuid::nil();
+        vec![
+            "/reports".to_string(),
+            format!("/reports/{id}"),
+            "/feedback".to_string(),
+            format!("/feedback/{id}"),
+            format!("/feedback/{id}/attachments/{HASH}"),
+        ]
+    }
+
+    fn authorized(uri: &str) -> axum::http::request::Builder {
+        Request::builder()
+            .uri(uri)
+            .header(header::HOST, "admin.example")
+            .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
+    }
+
+    fn status_request(builder: axum::http::request::Builder) -> Request<Body> {
+        builder.body(Body::empty()).expect("request")
+    }
+
+    async fn status_for(
+        state: Arc<crate::state::AppState>,
+        request: Request<Body>,
+    ) -> axum::response::Response {
+        router(state).oneshot(request).await.expect("response")
+    }
+
     #[tokio::test]
-    async fn report_detail_requires_admin_host_before_database_access() {
-        let response = router(test_state().await)
-            .oneshot(
+    async fn every_route_rejects_a_missing_credential_before_database_access() {
+        let state = test_state().await;
+        for uri in mounted_routes() {
+            let response = status_for(
+                state.clone(),
                 Request::builder()
-                    .uri(format!("/reports/{}", Uuid::nil()))
-                    .header(header::HOST, "community.example")
+                    .uri(&uri)
+                    .header(header::HOST, "admin.example")
                     .body(Body::empty())
                     .expect("request"),
             )
-            .await
-            .expect("response");
-        assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN);
+            .await;
+            assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}");
+        }
     }
 
     #[tokio::test]
-    async fn report_detail_rejects_unknown_report() {
-        let response = router(test_state().await)
-            .oneshot(
+    async fn every_route_rejects_a_wrong_credential_before_database_access() {
+        let state = test_state().await;
+        let wrong = "f".repeat(64);
+        for uri in mounted_routes() {
+            let response = status_for(
+                state.clone(),
                 Request::builder()
-                    .uri(format!("/reports/{}", Uuid::nil()))
+                    .uri(&uri)
                     .header(header::HOST, "admin.example")
+                    .header(header::AUTHORIZATION, format!("Bearer {wrong}"))
                     .body(Body::empty())
                     .expect("request"),
             )
-            .await
-            .expect("response");
-        assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
+            .await;
+            assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}");
+        }
     }
 
     #[tokio::test]
-    async fn feedback_attachment_requires_admin_host_before_database_access() {
-        let response = router(test_state().await)
-            .oneshot(
+    async fn malformed_credentials_all_collapse_to_the_same_challenge() {
+        let state = test_state().await;
+        for value in [
+            format!("Basic {TOKEN}"),
+            TOKEN.to_string(),
+            "Bearer ".to_string(),
+            "Bearer".to_string(),
+            format!("Bearer {}", &TOKEN[..63]),
+            format!("Bearer {TOKEN}00"),
+            format!("Bearer {}", "z".repeat(64)),
+        ] {
+            let response = status_for(
+                state.clone(),
                 Request::builder()
-                    .uri(format!("/feedback/{}/attachments/{HASH}", Uuid::nil()))
-                    .header(header::HOST, "community.example")
+                    .uri("/reports")
+                    .header(header::HOST, "admin.example")
+                    .header(header::AUTHORIZATION, &value)
                     .body(Body::empty())
                     .expect("request"),
             )
-            .await
-            .expect("response");
-        assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN);
+            .await;
+            assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{value}");
+            assert_eq!(
+                response
+                    .headers()
+                    .get(header::WWW_AUTHENTICATE)
+                    .and_then(|value| value.to_str().ok()),
+                Some("Bearer"),
+                "{value}"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn duplicate_authorization_headers_are_rejected() {
+        let response = status_for(
+            test_state().await,
+            Request::builder()
+                .uri("/reports")
+                .header(header::HOST, "admin.example")
+                .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
+                .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
+                .body(Body::empty())
+                .expect("request"),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+    }
+
+    #[tokio::test]
+    async fn lowercase_bearer_scheme_is_accepted() {
+        let response = status_for(
+            test_state().await,
+            Request::builder()
+                .uri(format!("/reports/{}", Uuid::nil()))
+                .header(header::HOST, "admin.example")
+                .header(header::AUTHORIZATION, format!("bearer {TOKEN}"))
+                .body(Body::empty())
+                .expect("request"),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+    }
+
+    #[tokio::test]
+    async fn a_valid_credential_on_the_wrong_host_is_forbidden_not_unauthorized() {
+        let response = status_for(
+            test_state().await,
+            Request::builder()
+                .uri(format!("/reports/{}", Uuid::nil()))
+                .header(header::HOST, "community.example")
+                .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
+                .body(Body::empty())
+                .expect("request"),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::FORBIDDEN);
+    }
+
+    #[tokio::test]
+    async fn a_valid_credential_with_a_mismatched_origin_is_forbidden() {
+        let response = status_for(
+            test_state().await,
+            status_request(
+                authorized(&format!("/reports/{}", Uuid::nil()))
+                    .header(header::ORIGIN, "https://attacker.example"),
+            ),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::FORBIDDEN);
+    }
+
+    #[tokio::test]
+    async fn a_valid_credential_on_the_admin_host_without_an_origin_is_served() {
+        let response = status_for(test_state().await, status_request(authorized("/reports"))).await;
+        assert_eq!(response.status(), StatusCode::OK);
+    }
+
+    #[tokio::test]
+    async fn an_unauthenticated_request_on_the_wrong_host_reveals_no_host_oracle() {
+        let state = test_state().await;
+        let wrong_host = status_for(
+            state.clone(),
+            Request::builder()
+                .uri("/reports")
+                .header(header::HOST, "community.example")
+                .body(Body::empty())
+                .expect("request"),
+        )
+        .await;
+        let right_host = status_for(
+            state,
+            Request::builder()
+                .uri("/reports")
+                .header(header::HOST, "admin.example")
+                .body(Body::empty())
+                .expect("request"),
+        )
+        .await;
+        assert_eq!(wrong_host.status(), StatusCode::UNAUTHORIZED);
+        assert_eq!(right_host.status(), StatusCode::UNAUTHORIZED);
+    }
+
+    #[tokio::test]
+    async fn report_detail_rejects_unknown_report() {
+        let response = status_for(
+            test_state().await,
+            status_request(authorized(&format!("/reports/{}", Uuid::nil()))),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::NOT_FOUND);
     }
 
     #[tokio::test]
     async fn feedback_attachment_rejects_unknown_feedback() {
-        let response = router(test_state().await)
-            .oneshot(
-                Request::builder()
-                    .uri(format!("/feedback/{}/attachments/{HASH}", Uuid::nil()))
-                    .header(header::HOST, "admin.example")
-                    .body(Body::empty())
-                    .expect("request"),
-            )
-            .await
-            .expect("response");
-        assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
+        let response = status_for(
+            test_state().await,
+            status_request(authorized(&format!(
+                "/feedback/{}/attachments/{HASH}",
+                Uuid::nil()
+            ))),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::NOT_FOUND);
     }
 
     #[tokio::test]
     async fn feedback_attachment_rejects_write_methods() {
         let state = test_state().await;
         for method in ["POST", "PUT", "PATCH", "DELETE"] {
-            let response = router(state.clone())
-                .oneshot(
-                    Request::builder()
-                        .method(method)
-                        .uri(format!("/feedback/{}/attachments/{HASH}", Uuid::nil()))
-                        .header(header::HOST, "admin.example")
-                        .body(Body::empty())
-                        .expect("request"),
-                )
-                .await
-                .expect("response");
+            let response = status_for(
+                state.clone(),
+                status_request(
+                    authorized(&format!("/feedback/{}/attachments/{HASH}", Uuid::nil()))
+                        .method(method),
+                ),
+            )
+            .await;
             assert_eq!(
                 response.status(),
-                axum::http::StatusCode::METHOD_NOT_ALLOWED,
+                StatusCode::METHOD_NOT_ALLOWED,
                 "{method}"
             );
         }
@@ -544,4 +744,66 @@ mod tests {
         assert!(!is_sha256(&HASH[..63]));
         assert!(!is_sha256(&format!("{HASH}.png")));
     }
+
+    #[tokio::test]
+    async fn insecure_no_auth_mode_allows_unauthenticated_requests_on_the_admin_host() {
+        let state = insecure_no_auth_state().await;
+        for uri in mounted_routes() {
+            let response = status_for(
+                state.clone(),
+                Request::builder()
+                    .uri(&uri)
+                    .header(header::HOST, "admin.example")
+                    .body(Body::empty())
+                    .expect("request"),
+            )
+            .await;
+            // The routes return 200 (or 404 for unknown resources) — never 401.
+            // 404 is fine here: there is no real DB, so the row lookups fail.
+            assert_ne!(
+                response.status(),
+                StatusCode::UNAUTHORIZED,
+                "{uri} must not return 401 in insecure_no_auth mode"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn insecure_no_auth_mode_still_requires_the_correct_host() {
+        let state = insecure_no_auth_state().await;
+        let response = status_for(
+            state,
+            Request::builder()
+                .uri("/reports")
+                .header(header::HOST, "community.example")
+                .body(Body::empty())
+                .expect("request"),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "wrong host must still be rejected in insecure_no_auth mode"
+        );
+    }
+
+    #[tokio::test]
+    async fn insecure_no_auth_mode_still_requires_a_matching_origin() {
+        let state = insecure_no_auth_state().await;
+        let response = status_for(
+            state,
+            Request::builder()
+                .uri("/reports")
+                .header(header::HOST, "admin.example")
+                .header(header::ORIGIN, "https://attacker.example")
+                .body(Body::empty())
+                .expect("request"),
+        )
+        .await;
+        assert_eq!(
+            response.status(),
+            StatusCode::FORBIDDEN,
+            "mismatched origin must still be rejected in insecure_no_auth mode"
+        );
+    }
 }
diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs
index 85a0ca2efe..dca6ff9f80 100644
--- a/crates/buzz-relay/src/config.rs
+++ b/crates/buzz-relay/src/config.rs
@@ -24,15 +24,85 @@ pub enum ConfigError {
     InvalidValue(String),
 }
 
+/// Authentication mode for the deployment-admin API.
+///
+/// Exactly one variant is active; the invalid states (`token: None` +
+/// `insecure_no_auth: false`, or `token: Some` + `insecure_no_auth: true`)
+/// are not representable.
+#[derive(Debug, Clone)]
+pub enum AdminAuth {
+    /// Operator bearer credential required on every request.
+    Token(AdminToken),
+    /// Bearer authentication disabled. The operator has explicitly asserted
+    /// that the admin API is protected at the network layer (reverse proxy,
+    /// VPN, firewall). `Host`/`Origin` checks remain active as defense-in-depth.
+    InsecureNoAuth,
+}
+
 /// Deny-by-default read-only deployment-admin configuration.
 #[derive(Debug, Clone)]
 pub struct AdminConfig {
     /// Exact admin HTTP authority.
     pub host: String,
+    /// Authentication mode selected at startup.
+    pub auth: AdminAuth,
     /// Optional admin SPA bundle directory.
     pub web_dir: Option<std::path::PathBuf>,
 }
 
+/// Operator bearer credential for the deployment-admin API, held decoded.
+///
+/// `Debug` is redacted by hand so the secret cannot escape through a derived
+/// `Debug` on [`AdminConfig`] or [`Config`].
+#[derive(Clone)]
+pub struct AdminToken([u8; 32]);
+
+impl AdminToken {
+    /// Constant-time comparison against a presented credential.
+    pub fn matches(&self, presented: &[u8; 32]) -> bool {
+        use subtle::ConstantTimeEq;
+        self.0.ct_eq(presented).into()
+    }
+
+    /// Build a token from raw bytes. Test-only: production tokens come from
+    /// [`parse_admin_token`] so the operator-facing validation is never bypassed.
+    #[cfg(test)]
+    pub fn from_bytes(bytes: [u8; 32]) -> Self {
+        Self(bytes)
+    }
+}
+
+impl std::fmt::Debug for AdminToken {
+    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        formatter.write_str("AdminToken(redacted)")
+    }
+}
+
+/// Guidance repeated in every `BUZZ_ADMIN_TOKEN` rejection.
+const ADMIN_TOKEN_GUIDANCE: &str = "BUZZ_ADMIN_TOKEN must be 64 hexadecimal characters \
+     (surrounding whitespace is trimmed); generate one with `openssl rand -hex 32`";
+
+/// Read and validate the admin bearer credential. Fail-closed: the admin API is
+/// only ever mounted alongside a token that passed this check.
+fn parse_admin_token() -> Result<AdminToken, ConfigError> {
+    let raw = match std::env::var("BUZZ_ADMIN_TOKEN") {
+        Ok(value) => value,
+        Err(std::env::VarError::NotPresent) => {
+            return Err(ConfigError::InvalidValue(format!(
+                "BUZZ_ADMIN_TOKEN is required whenever BUZZ_ADMIN_HOST is set — \
+                 {ADMIN_TOKEN_GUIDANCE}"
+            )))
+        }
+        Err(std::env::VarError::NotUnicode(_)) => {
+            return Err(ConfigError::InvalidValue(ADMIN_TOKEN_GUIDANCE.to_string()))
+        }
+    };
+    let mut token = [0u8; 32];
+    hex::decode_to_slice(raw.trim(), &mut token)
+        .map_err(|_| ConfigError::InvalidValue(ADMIN_TOKEN_GUIDANCE.to_string()))?;
+    Ok(AdminToken(token))
+}
+
 /// Relay-hosted policy content presented on join surfaces.
 #[derive(Debug, Clone)]
 pub struct JoinPolicyConfig {
@@ -876,13 +946,64 @@ impl Config {
             .map(|value| value.trim().to_owned())
             .filter(|value| !value.is_empty())
         {
-            None => None,
+            None => {
+                if std::env::var_os("BUZZ_ADMIN_TOKEN").is_some() {
+                    tracing::warn!(
+                        "BUZZ_ADMIN_TOKEN is set without BUZZ_ADMIN_HOST — the admin \
+                         dashboard and API stay disabled and the token is ignored"
+                    );
+                }
+                if std::env::var_os("BUZZ_ADMIN_INSECURE_NO_AUTH").is_some() {
+                    tracing::warn!(
+                        "BUZZ_ADMIN_INSECURE_NO_AUTH is set without BUZZ_ADMIN_HOST — \
+                         the admin dashboard and API stay disabled and the flag is ignored"
+                    );
+                }
+                None
+            }
             Some(host) => {
                 if host.contains(['/', '\\', '@']) {
                     return Err(ConfigError::InvalidValue(
                         "BUZZ_ADMIN_HOST must be an exact authority".to_string(),
                     ));
                 }
+
+                // Parse BUZZ_ADMIN_INSECURE_NO_AUTH. Only the exact value
+                // "true" enables disabled mode; any other non-empty value is a
+                // startup error to prevent silent typo-coercion.
+                let insecure_no_auth =
+                    match std::env::var("BUZZ_ADMIN_INSECURE_NO_AUTH").ok().as_deref() {
+                        None | Some("") => false,
+                        Some("true") => true,
+                        Some(other) => {
+                            return Err(ConfigError::InvalidValue(format!(
+                                "BUZZ_ADMIN_INSECURE_NO_AUTH must be exactly \"true\" or unset; \
+                             got \"{other}\""
+                            )))
+                        }
+                    };
+
+                // Both BUZZ_ADMIN_TOKEN and BUZZ_ADMIN_INSECURE_NO_AUTH=true
+                // is ambiguous intent; fail closed.
+                if insecure_no_auth && std::env::var_os("BUZZ_ADMIN_TOKEN").is_some() {
+                    return Err(ConfigError::InvalidValue(
+                        "BUZZ_ADMIN_TOKEN and BUZZ_ADMIN_INSECURE_NO_AUTH=true are mutually \
+                         exclusive — set one or the other, not both"
+                            .to_string(),
+                    ));
+                }
+
+                let auth = if insecure_no_auth {
+                    tracing::warn!(
+                        "BUZZ_ADMIN_INSECURE_NO_AUTH=true — the admin API is \
+                         unauthenticated; the operator has asserted that access is \
+                         controlled at the network layer (reverse proxy, VPN, firewall)"
+                    );
+                    AdminAuth::InsecureNoAuth
+                } else {
+                    AdminAuth::Token(parse_admin_token()?)
+                };
+
                 let web_dir = std::env::var("BUZZ_ADMIN_WEB_DIR")
                     .ok()
                     .map(|value| std::path::PathBuf::from(value.trim()))
@@ -895,7 +1016,11 @@ impl Config {
                         )));
                     }
                 }
-                Some(AdminConfig { host, web_dir })
+                Some(AdminConfig {
+                    host,
+                    auth,
+                    web_dir,
+                })
             }
         };
 
@@ -1053,6 +1178,216 @@ mod tests {
         );
     }
 
+    /// Run `Config::from_env()` with the admin variables forced to `values`,
+    /// restoring the ambient environment afterwards.
+    fn config_with_admin_env(values: &[(&str, Option<&str>)]) -> Result<Config, ConfigError> {
+        const KEYS: [&str; 3] = [
+            "BUZZ_ADMIN_HOST",
+            "BUZZ_ADMIN_TOKEN",
+            "BUZZ_ADMIN_INSECURE_NO_AUTH",
+        ];
+        let previous: Vec<_> = KEYS
+            .iter()
+            .map(|key| (*key, std::env::var_os(key)))
+            .collect();
+        for key in KEYS {
+            std::env::remove_var(key);
+        }
+        for (key, value) in values {
+            match value {
+                Some(value) => std::env::set_var(key, value),
+                None => std::env::remove_var(key),
+            }
+        }
+        let config = Config::from_env();
+        for (key, value) in previous {
+            match value {
+                Some(value) => std::env::set_var(key, value),
+                None => std::env::remove_var(key),
+            }
+        }
+        config
+    }
+
+    const VALID_ADMIN_TOKEN: &str =
+        "5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
+
+    #[test]
+    fn admin_host_without_a_usable_token_fails_closed_at_startup() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        for token in [
+            None,
+            Some(""),
+            Some("   "),
+            Some("not-hex-not-hex-not-hex-not-hex-not-hex-not-hex-not-hex-not-hex1"),
+            Some(&VALID_ADMIN_TOKEN[..62]),
+            Some("5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef0100"),
+        ] {
+            let result = config_with_admin_env(&[
+                ("BUZZ_ADMIN_HOST", Some("admin.example")),
+                ("BUZZ_ADMIN_TOKEN", token),
+            ]);
+            assert!(
+                matches!(
+                    result,
+                    Err(ConfigError::InvalidValue(ref message))
+                        if message.contains("BUZZ_ADMIN_TOKEN")
+                ),
+                "{token:?} must be rejected"
+            );
+        }
+    }
+
+    #[test]
+    fn admin_surface_activates_with_a_valid_token_and_tolerates_surrounding_whitespace() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        for token in [
+            VALID_ADMIN_TOKEN.to_string(),
+            format!("  {VALID_ADMIN_TOKEN}\n"),
+        ] {
+            let admin = config_with_admin_env(&[
+                ("BUZZ_ADMIN_HOST", Some("admin.example")),
+                ("BUZZ_ADMIN_TOKEN", Some(&token)),
+            ])
+            .expect("config with a valid admin token")
+            .admin
+            .expect("admin surface is configured");
+            assert_eq!(admin.host, "admin.example");
+            assert!(
+                matches!(admin.auth, crate::config::AdminAuth::Token(ref t) if {
+                    let mut expected = [0u8; 32];
+                    hex::decode_to_slice(VALID_ADMIN_TOKEN, &mut expected).expect("hex fixture");
+                    t.matches(&expected) && !t.matches(&[0u8; 32])
+                })
+            );
+        }
+    }
+
+    #[test]
+    fn admin_token_without_a_host_leaves_the_surface_disabled() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let config = config_with_admin_env(&[
+            ("BUZZ_ADMIN_HOST", None),
+            ("BUZZ_ADMIN_TOKEN", Some(VALID_ADMIN_TOKEN)),
+        ])
+        .expect("a token alone is not a startup error");
+        assert!(config.admin.is_none());
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn admin_token_rejects_non_unicode_values() {
+        use std::os::unix::ffi::OsStringExt;
+
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let previous_host = std::env::var_os("BUZZ_ADMIN_HOST");
+        let previous_token = std::env::var_os("BUZZ_ADMIN_TOKEN");
+        std::env::set_var("BUZZ_ADMIN_HOST", "admin.example");
+        std::env::set_var("BUZZ_ADMIN_TOKEN", std::ffi::OsString::from_vec(vec![0xff]));
+
+        let invalid = Config::from_env();
+
+        for (key, value) in [
+            ("BUZZ_ADMIN_HOST", previous_host),
+            ("BUZZ_ADMIN_TOKEN", previous_token),
+        ] {
+            match value {
+                Some(value) => std::env::set_var(key, value),
+                None => std::env::remove_var(key),
+            }
+        }
+
+        assert!(matches!(
+            invalid,
+            Err(ConfigError::InvalidValue(ref message))
+                if message.contains("BUZZ_ADMIN_TOKEN")
+        ));
+    }
+
+    #[test]
+    fn admin_token_debug_output_is_redacted() {
+        let token = AdminToken::from_bytes([7u8; 32]);
+        let rendered = format!("{token:?}");
+        assert_eq!(rendered, "AdminToken(redacted)");
+        assert!(!rendered.contains("07"));
+    }
+
+    #[test]
+    fn insecure_no_auth_activates_without_a_token() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let admin = config_with_admin_env(&[
+            ("BUZZ_ADMIN_HOST", Some("admin.example")),
+            ("BUZZ_ADMIN_TOKEN", None),
+            ("BUZZ_ADMIN_INSECURE_NO_AUTH", Some("true")),
+        ])
+        .expect("insecure_no_auth without a token is valid")
+        .admin
+        .expect("admin surface is configured");
+        assert_eq!(admin.host, "admin.example");
+        assert!(matches!(
+            admin.auth,
+            crate::config::AdminAuth::InsecureNoAuth
+        ));
+    }
+
+    #[test]
+    fn insecure_no_auth_and_token_both_set_fails_closed() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let result = config_with_admin_env(&[
+            ("BUZZ_ADMIN_HOST", Some("admin.example")),
+            ("BUZZ_ADMIN_TOKEN", Some(VALID_ADMIN_TOKEN)),
+            ("BUZZ_ADMIN_INSECURE_NO_AUTH", Some("true")),
+        ]);
+        assert!(
+            matches!(
+                result,
+                Err(ConfigError::InvalidValue(ref message))
+                    if message.contains("mutually exclusive")
+            ),
+            "both set must be a startup error: {result:?}"
+        );
+    }
+
+    #[test]
+    fn insecure_no_auth_junk_values_all_fail_closed() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        for junk in ["1", "yes", "TRUE", "True", "false", "0", "on"] {
+            let result = config_with_admin_env(&[
+                ("BUZZ_ADMIN_HOST", Some("admin.example")),
+                ("BUZZ_ADMIN_TOKEN", None),
+                ("BUZZ_ADMIN_INSECURE_NO_AUTH", Some(junk)),
+            ]);
+            assert!(
+                matches!(
+                    result,
+                    Err(ConfigError::InvalidValue(ref message))
+                        if message.contains("BUZZ_ADMIN_INSECURE_NO_AUTH")
+                ),
+                "{junk:?} must be rejected: {result:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn insecure_no_auth_empty_string_is_treated_as_unset() {
+        // An empty value (e.g. `BUZZ_ADMIN_INSECURE_NO_AUTH=`) behaves exactly
+        // like absence — the operator must set a token or the relay fails closed.
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let result = config_with_admin_env(&[
+            ("BUZZ_ADMIN_HOST", Some("admin.example")),
+            ("BUZZ_ADMIN_TOKEN", None),
+            ("BUZZ_ADMIN_INSECURE_NO_AUTH", Some("")),
+        ]);
+        assert!(
+            matches!(
+                result,
+                Err(ConfigError::InvalidValue(ref message))
+                    if message.contains("BUZZ_ADMIN_TOKEN")
+            ),
+            "empty BUZZ_ADMIN_INSECURE_NO_AUTH should fall through to the token requirement"
+        );
+    }
+
     #[test]
     fn s3_addressing_style_env_accepts_virtual_and_rejects_invalid_values() {
         let _guard = ENV_MUTEX.lock().unwrap();
diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs
index 400ed1dfe3..e9b7de8aac 100644
--- a/crates/buzz-relay/src/router.rs
+++ b/crates/buzz-relay/src/router.rs
@@ -6,7 +6,7 @@ use std::sync::Arc;
 use axum::{
     body::Body,
     extract::{ConnectInfo, FromRequest, State, WebSocketUpgrade},
-    http::{HeaderMap, Request, StatusCode},
+    http::{header, HeaderMap, HeaderValue, Request, StatusCode},
     middleware,
     response::{IntoResponse, Json},
     routing::{get, post, put},
@@ -162,14 +162,17 @@ pub fn build_router(state: Arc<AppState>) -> Router {
                 let admin_host = api::admin::is_admin_host(&state, req.headers());
                 if admin_host {
                     if let (Some(index), Some(files)) = (admin_index, admin_files) {
-                        if path.starts_with("/assets/") {
-                            return files.oneshot(req).await.map(IntoResponse::into_response);
+                        if is_admin_static_path(path) {
+                            return files
+                                .oneshot(req)
+                                .await
+                                .map(|response| with_admin_csp(response.into_response()));
                         }
                         if is_admin_spa_path(path) {
-                            return Ok(read_spa_index(&index).await);
+                            return Ok(with_admin_csp(read_spa_index(&index).await));
                         }
                     }
-                    return Ok(StatusCode::NOT_FOUND.into_response());
+                    return Ok(with_admin_csp(StatusCode::NOT_FOUND.into_response()));
                 }
 
                 if let (Some(index), Some(files)) = (web_index, web_files) {
@@ -213,6 +216,14 @@ fn is_admin_spa_path(path: &str) -> bool {
         || path.starts_with("/feedback/")
 }
 
+/// Files served from the admin bundle directory verbatim. `/assets/*` is the
+/// hashed Vite output; `/favicon.svg` is the one root-level file the bundle
+/// emits and the document links. Everything else on the admin host is a 404 —
+/// the directory is not browsable.
+fn is_admin_static_path(path: &str) -> bool {
+    path.starts_with("/assets/") || path == "/favicon.svg"
+}
+
 fn is_invite_landing_path(path: &str) -> bool {
     path.strip_prefix("/invite/")
         .is_some_and(|code| !code.is_empty() && !code.contains('/'))
@@ -233,6 +244,39 @@ async fn read_spa_index(index: &std::path::Path) -> axum::response::Response {
     }
 }
 
+/// The admin dashboard holds the operator token in `sessionStorage`, so its
+/// documents and assets are locked to same-origin code with no framing. `blob:`
+/// images are required: attachments are fetched with the token and rendered
+/// from object URLs. Applied only to the admin host — the public bundle keeps
+/// its own headers.
+#[rustfmt::skip]
+const ADMIN_CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'";
+
+fn with_admin_csp(mut response: axum::response::Response) -> axum::response::Response {
+    response.headers_mut().insert(
+        header::CONTENT_SECURITY_POLICY,
+        HeaderValue::from_static(ADMIN_CSP),
+    );
+    response
+}
+
+/// Serve the admin bundle's `index.html` for a browser request to `/`. Any
+/// non-HTML request to the admin authority is a 404: the relay protocol is not
+/// exposed there.
+async fn admin_spa_document(state: &AppState, accept: &str) -> axum::response::Response {
+    let index = state
+        .config
+        .admin
+        .as_ref()
+        .and_then(|config| config.web_dir.as_ref())
+        .filter(|_| accept.contains("text/html"))
+        .map(|dir| dir.join("index.html"));
+    match index {
+        Some(index) => read_spa_index(&index).await,
+        None => StatusCode::NOT_FOUND.into_response(),
+    }
+}
+
 /// Build the health-only router for K8s probes (port 8080 in CAKE).
 ///
 /// No metrics middleware, no auth, no CORS, no body limit.
@@ -271,19 +315,7 @@ async fn nip11_or_ws_handler(
     // Short-circuit the exact admin authority here and never let it serve the
     // public web bundle, NIP-11 document, or WebSocket endpoint.
     if api::admin::is_admin_host(&state, &headers) {
-        if !accept.contains("text/html") {
-            return StatusCode::NOT_FOUND.into_response();
-        }
-        let Some(index) = state
-            .config
-            .admin
-            .as_ref()
-            .and_then(|config| config.web_dir.as_ref())
-            .map(|dir| dir.join("index.html"))
-        else {
-            return StatusCode::NOT_FOUND.into_response();
-        };
-        return read_spa_index(&index).await;
+        return with_admin_csp(admin_spa_document(&state, accept).await);
     }
 
     if accept.contains("application/nostr+json") {
@@ -490,6 +522,152 @@ mod tests {
         assert!(!should_serve_spa("/arbitrary", true));
     }
 
+    /// Relay state serving both bundles: the admin SPA on `admin.example` and
+    /// the public SPA on any other host.
+    async fn spa_state(admin_dir: &std::path::Path, web_dir: &std::path::Path) -> Arc<AppState> {
+        let mut config = crate::config::Config::from_env().expect("default config loads");
+        config.require_relay_membership = false;
+        config.redis_url = "redis://127.0.0.1:1".to_string();
+        config.web_dir = Some(web_dir.to_path_buf());
+        config.admin = Some(crate::config::AdminConfig {
+            host: "admin.example".to_string(),
+            auth: crate::config::AdminAuth::Token(crate::config::AdminToken::from_bytes([7u8; 32])),
+            web_dir: Some(admin_dir.to_path_buf()),
+        });
+        let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool");
+        let db = buzz_db::Db::from_pool(pool.clone());
+        let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
+            .create_pool(Some(deadpool_redis::Runtime::Tokio1))
+            .expect("redis pool");
+        let pubsub = Arc::new(
+            buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
+                .await
+                .expect("pubsub manager"),
+        );
+        let audit = buzz_audit::AuditService::new(pool.clone());
+        let auth = buzz_auth::AuthService::new(config.auth.clone());
+        let search = buzz_search::SearchService::new(pool.clone());
+        let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
+            db.clone(),
+            buzz_workflow::WorkflowConfig::default(),
+        ));
+        let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage");
+        let (state, _audit_shutdown) = AppState::new(
+            config,
+            db,
+            redis_pool,
+            audit,
+            pubsub,
+            auth,
+            search,
+            workflow_engine,
+            nostr::Keys::generate(),
+            media_storage,
+        );
+        Arc::new(state)
+    }
+
+    /// A minimal built SPA: an index document, one hashed asset, and the
+    /// root-level favicon Vite copies out of `public/`.
+    fn write_bundle(dir: &std::path::Path) {
+        std::fs::create_dir_all(dir.join("assets")).expect("assets dir");
+        std::fs::write(dir.join("index.html"), "<!doctype html>").expect("index.html");
+        std::fs::write(dir.join("assets/app.js"), "export {};").expect("bundle asset");
+        std::fs::write(dir.join("favicon.svg"), "<svg/>").expect("favicon");
+    }
+
+    async fn spa_response(
+        state: Arc<AppState>,
+        host: &str,
+        path: &str,
+    ) -> axum::response::Response {
+        build_router(state)
+            .oneshot(
+                Request::get(path)
+                    .header(axum::http::header::HOST, host)
+                    .header(axum::http::header::ACCEPT, "text/html")
+                    .body(Body::empty())
+                    .expect("request"),
+            )
+            .await
+            .expect("response")
+    }
+
+    #[tokio::test]
+    async fn admin_spa_documents_and_assets_carry_the_admin_csp() {
+        let admin_dir = tempfile::tempdir().expect("admin bundle dir");
+        let web_dir = tempfile::tempdir().expect("public bundle dir");
+        write_bundle(admin_dir.path());
+        write_bundle(web_dir.path());
+        let state = spa_state(admin_dir.path(), web_dir.path()).await;
+
+        for path in [
+            "/",
+            "/reports",
+            "/feedback/abc",
+            "/assets/app.js",
+            "/favicon.svg",
+        ] {
+            let response = spa_response(state.clone(), "admin.example", path).await;
+            assert_eq!(
+                response
+                    .headers()
+                    .get(header::CONTENT_SECURITY_POLICY)
+                    .and_then(|value| value.to_str().ok()),
+                Some(ADMIN_CSP),
+                "{path} must carry the admin CSP"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn the_admin_host_serves_the_favicon_the_document_links() {
+        let admin_dir = tempfile::tempdir().expect("admin bundle dir");
+        let web_dir = tempfile::tempdir().expect("public bundle dir");
+        write_bundle(admin_dir.path());
+        write_bundle(web_dir.path());
+        let state = spa_state(admin_dir.path(), web_dir.path()).await;
+
+        let response = spa_response(state.clone(), "admin.example", "/favicon.svg").await;
+        assert_eq!(response.status(), StatusCode::OK);
+
+        // The bundle directory is not browsable: only the assets Vite emits at
+        // the root are reachable, never arbitrary files beside them.
+        for path in ["/index.html", "/nope.svg"] {
+            let response = spa_response(state.clone(), "admin.example", path).await;
+            assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
+        }
+    }
+
+    #[test]
+    fn the_admin_csp_never_allows_inline_or_eval() {
+        assert!(
+            !ADMIN_CSP.contains("unsafe-inline") && !ADMIN_CSP.contains("unsafe-eval"),
+            "the dashboard holds a bearer token — inline script or style must stay blocked"
+        );
+    }
+
+    #[tokio::test]
+    async fn the_public_spa_is_untouched_by_the_admin_csp() {
+        let admin_dir = tempfile::tempdir().expect("admin bundle dir");
+        let web_dir = tempfile::tempdir().expect("public bundle dir");
+        write_bundle(admin_dir.path());
+        write_bundle(web_dir.path());
+        let state = spa_state(admin_dir.path(), web_dir.path()).await;
+
+        for path in ["/invite/payload.mac", "/assets/app.js"] {
+            let response = spa_response(state.clone(), "public.example", path).await;
+            assert_eq!(response.status(), StatusCode::OK, "{path}");
+            assert!(
+                response
+                    .headers()
+                    .get(header::CONTENT_SECURITY_POLICY)
+                    .is_none(),
+                "{path} on the public host must keep its own headers"
+            );
+        }
+    }
+
     #[tokio::test(flavor = "current_thread")]
     async fn http_and_datastore_spans_are_exported_in_the_same_trace() {
         let exporter = InMemorySpanExporter::default();
diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example
index f6ab4fcab9..b27eed552a 100644
--- a/deploy/compose/.env.example
+++ b/deploy/compose/.env.example
@@ -36,6 +36,17 @@ BUZZ_S3_BUCKET=buzz-media
 # Bundled MinIO uses path-style URLs; deploy/compose/compose.yml pins this.
 BUZZ_S3_ADDRESSING_STYLE=path
 
+# Optional private moderation dashboard. Set BUZZ_ADMIN_HOST plus one of:
+#   Option A — Bearer token (recommended for public-facing deployments):
+#     BUZZ_ADMIN_TOKEN must be exactly 64 hex characters (openssl rand -hex 32).
+#     Every API request requires Authorization: Bearer.
+#   Option B — Network-layer auth (VPN / private ingress only):
+#     BUZZ_ADMIN_INSECURE_NO_AUTH=true (exact value; relay logs a WARN on boot).
+# Setting BUZZ_ADMIN_HOST with neither, or with both, aborts relay startup.
+# BUZZ_ADMIN_HOST=admin.buzz.example.com
+# BUZZ_ADMIN_TOKEN=CHANGE_ME_RANDOM_64_HEX
+# BUZZ_ADMIN_INSECURE_NO_AUTH=true
+
 # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT.
 BUZZ_HTTP_PORT=3000
 
diff --git a/docs/admin/README.md b/docs/admin/README.md
index e51566fb29..41e1acc0c4 100644
--- a/docs/admin/README.md
+++ b/docs/admin/README.md
@@ -13,15 +13,161 @@ BUZZ_ADMIN_HOST=admin.example.com
 BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
 ```
 
-The relay requires the configured admin host and matching browser origin.
-Requests and responses are bounded and uncached. The deployment routes admin
-traffic through the private ingress.
+Plus one of the authentication modes below.
+
+## Authentication
+
+The admin API requires explicit authentication configuration. Setting only
+`BUZZ_ADMIN_HOST` is a startup error — there is no insecure default.
+
+### Token mode (recommended for public-facing or OSS deployments)
+
+Every `/api/admin/v1` request must carry the operator token as a bearer
+credential.
+
+```text
+BUZZ_ADMIN_TOKEN=<64 hex characters>
+```
+
+The relay fails closed if `BUZZ_ADMIN_TOKEN` is missing or invalid when
+`BUZZ_ADMIN_HOST` is set:
+
+- `BUZZ_ADMIN_TOKEN` must be exactly 64 hexadecimal characters (32 bytes).
+  Surrounding whitespace is trimmed; anything else — empty, non-hex, wrong
+  length, non-Unicode — is a startup error.
+- `BUZZ_ADMIN_TOKEN` set without `BUZZ_ADMIN_HOST` is ignored: the admin surface
+  stays absent and the relay logs a warning at startup.
+
+Generate a token once per deployment and store it with your other secrets:
+
+```bash
+openssl rand -hex 32
+```
+
+Call the API with it:
+
+```bash
+curl -H "Host: admin.example.com" \
+     -H "Authorization: Bearer $BUZZ_ADMIN_TOKEN" \
+     https://admin.example.com/api/admin/v1/reports
+```
+
+A missing, malformed, duplicated, or incorrect credential returns `401` with
+`WWW-Authenticate: Bearer` and reveals nothing about the expected `Host`. The
+scheme is matched case-insensitively per RFC 9110, and the credential is
+compared in constant time. The token never appears in URLs, logs, or traces.
+
+The dashboard probes for auth mode on first load: if the relay returns `401` to
+an unauthenticated request, the dashboard prompts for the token and keeps it in
+`sessionStorage` for that browser session; a rejected token is discarded and
+re-prompted. Attachment bytes are fetched through the authenticated API and
+rendered from object URLs, because `<img src>` and `<a href>` cannot carry an
+`Authorization` header.
+
+In token mode, each new browser session issues one unauthenticated probe that
+returns `401` by design before the token is entered. If you alert on admin-API
+`401`s, exclude these single-probe sequences (one `401` immediately followed by
+authenticated requests from the same session) to avoid false positives.
+
+The shared token authenticates the deployment operator role, not a person. It
+carries no per-operator identity, attribution, or individual revocation —
+rotating it revokes access for everyone at once.
+
+### Network-layer auth mode (for deployments behind a VPN or private ingress)
+
+Operators whose admin API is already protected at the network layer — for
+example by a corporate VPN such as WARP+Okta — can disable bearer authentication
+entirely:
+
+```text
+BUZZ_ADMIN_INSECURE_NO_AUTH=true
+```
+
+Only the exact value `true` is accepted; any other non-empty value is a startup
+error. `BUZZ_ADMIN_TOKEN` and `BUZZ_ADMIN_INSECURE_NO_AUTH=true` set at the same
+time is also a startup error (ambiguous intent).
+
+In this mode the relay logs a `WARN` on every startup:
+
+```
+BUZZ_ADMIN_INSECURE_NO_AUTH=true — the admin API is unauthenticated; the
+operator has asserted that access is controlled at the network layer
+```
+
+The `Host`/`Origin` checks remain active as defense-in-depth. The dashboard
+detects that no token is needed on first load (probe returns `200`) and skips
+the token prompt, rendering the dashboard directly.
+
+**This mode relies entirely on the operator's network controls.** If the admin
+API is reachable by untrusted clients, the entire moderation and feedback dataset
+is exposed. Use token mode instead.
+
+When using a reverse proxy in this mode, document the requirement and consider a
+proxy-injected shared secret or signed identity header for additional assurance.
+
+## Content Security Policy
+
+Every admin-host response that carries the dashboard itself — the SPA document
+on each admin route, the hashed `/assets/*` bundle, and admin-host `404`s — is
+served with a Content Security Policy response header, `ADMIN_CSP` in
+`crates/buzz-relay/src/router.rs`:
+
+```text
+default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
+```
+
+It blocks inline and third-party script and restricts subresource and request
+destinations to the same origin, which closes the direct paths an injected
+script would use to exfiltrate credentials or data. It does not constrain
+top-level navigation, so it is a containment layer, not a substitute for
+keeping script off the origin. `blob:` is permitted for images only, for
+attachment previews. It is a response header rather than a `<meta>` tag because
+`frame-ancestors` is ignored in meta — that directive is the dashboard's
+authoritative frame protection, superseding the `X-Frame-Options: DENY` the JSON
+API sends. The policy applies to the admin host only; the public web bundle
+keeps its own headers.
+
+The exact admin `Host` and matching browser `Origin` are still required in both
+auth modes, but they are defense-in-depth, not the primary access control. HTTPS
+and a private ingress remain required: in token mode the token is a bearer
+credential in transit; in network-layer mode the VPN/firewall boundary is the
+only access control.
 
 When the UI runs in a separate pod, proxy `/api/admin/v1/*` to the relay while
-preserving the admin `Host` header. A `NetworkPolicy` grants the admin pod access
-to that relay path.
+preserving the admin `Host` header and (in token mode) the client's
+`Authorization` header. A `NetworkPolicy` grants the admin pod access to that
+relay path.
+
+## Operator migration
+
+**Upgrading from a pre-auth release (Buzz prior to the introduction of this
+`BUZZ_ADMIN_HOST` requirement):** any deployed relay with `BUZZ_ADMIN_HOST` set
+refuses to start after upgrade unless one of the two auth variables is also set.
+Choose the mode that fits your deployment:
+
+- **Token mode:** mint a token with `openssl rand -hex 32`, set `BUZZ_ADMIN_TOKEN`
+  in your deploy config, then roll the new version.
+- **Network-layer mode (e.g. Block's `bb-public` behind WARP+Okta):** set
+  `BUZZ_ADMIN_INSECURE_NO_AUTH=true` in your deploy config (a static non-secret),
+  then roll the new version.
 
-Read routes:
+Relays without `BUZZ_ADMIN_HOST` are completely unaffected.
+
+Any non-browser client of `/api/admin/v1` using the token mode (monitoring
+probes, scripts, cron jobs) must add `Authorization: Bearer` to their requests
+after the upgrade. The dashboard handles itself. If a reverse proxy strips or
+rewrites `Authorization` headers, the dashboard breaks post-upgrade even with the
+token set — check proxy configuration before rolling.
+
+## Local development
+
+For local review, run `just admin-seed` before `just admin`. `just admin` mints a
+throwaway token for that run and prints it — paste it into the dashboard prompt.
+The seed command also uploads real image and diagnostic fixtures to local MinIO.
+Feedback search and filters run over the bounded browser result set; the
+**Acted on** checkbox is stored in that browser's local storage.
+
+## Read routes
 
 - `GET /api/admin/v1/reports`
 - `GET /api/admin/v1/reports/:id`
@@ -32,11 +178,6 @@ Report reads accept optional `communityId`, `status`, `reportType`, `targetKind`
 `after`, `before`, and `limit` parameters. Limits are capped at 200. Feedback is
 a bounded newest-first summary from the existing product-feedback repository.
 
-For local review, run `just admin-seed` before `just admin`. The seed command
-also uploads real image and diagnostic fixtures to local MinIO. Feedback search
-and filters run over the bounded browser result set; the **Acted on** checkbox is
-stored in that browser's local storage.
-
 ## Feedback attachment boundary
 
 Feedback attachment bytes are available only through the feedback-scoped read
@@ -44,7 +185,8 @@ route:
 
 - `GET /api/admin/v1/feedback/:id/attachments/:sha256`
 
-The route uses the same private-ingress, exact admin `Host`, and same-origin
+The route uses the same bearer credential (or network-layer boundary in
+insecure_no_auth mode), private-ingress, exact admin `Host`, and same-origin
 boundary as the JSON API. It is not a generic media endpoint. The relay loads
 the feedback row, derives its community from server-owned provenance, verifies
 that host resolution still maps to the row's `community_id`, and requires the
@@ -62,8 +204,10 @@ content retains attachment disposition. Successful reads produce a structured
 trace containing feedback ID, community ID, and attachment hash, but no feedback
 body or attachment URL.
 
-The human trust boundary remains the private admin ingress. VPN/source-IP
-admission is not per-operator identity. Anyone admitted to the dashboard can
-read attachments for feedback records they can access. Per-person attribution
-or revocation requires authenticated operator identity at ingress/application
-level; this endpoint deliberately does not claim to provide it.
+The human trust boundary is the chosen auth mode plus the private admin ingress.
+Neither token mode nor network-layer mode provides per-operator identity. Anyone
+admitted to the dashboard can read attachments for feedback records they can
+access. Per-person attribution or revocation requires authenticated operator
+identity at ingress/application level (for example an Okta-injected identity
+header or a NIP-98 pubkey allowlist); this endpoint deliberately does not claim
+to provide it.
