Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision fa33214eb34752fafee25fc95942f470701006f8)
+++ src/services/ActivityPubService.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
@@ -1238,4 +1238,21 @@
   const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
   if (!pem) return null;
+  // Bind the key to the actor it speaks for. Without this we hand back whatever
+  // `id` the fetched document claims, so anyone could host a document carrying a
+  // VICTIM's id next to their OWN public key, sign with their own private half,
+  // and be believed: the victim's server is never contacted. The caller decides on
+  // `verified.id`, so the identity has to come from where the key was FETCHED,
+  // never from what the document says about itself.
+  // Adds conditions only, and there is no exemption list on purpose: an
+  // "unless it's a known peer" escape hatch is exactly the door this closes.
+  // Note this does not narrow what we accept in practice, since the line above
+  // already requires the embedded publicKey object (an array or a bare URI
+  // reference never worked here).
+  const key = actor.publicKey;
+  try {
+    if (new URL(p.keyId).host !== new URL(actor.id).host) return null;   // same origin as the key
+    if (key.id && key.id !== p.keyId) return null;                       // this key, not a neighbour's
+    if (key.owner && key.owner !== actor.id) return null;                // and it belongs to this actor
+  } catch { return null; }                                               // unparseable id or keyId
   const hs = (p.headers || '(request-target) host date').split(/\s+/);
   // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
