diff --git a/pygitweb/change_queue_routes_test.py b/pygitweb/change_queue_routes_test.py
index 1659e6b..cf6c691 100644
--- a/pygitweb/change_queue_routes_test.py
+++ b/pygitweb/change_queue_routes_test.py
@@ -12,6 +12,7 @@ from httpx import ASGITransport
 
 from pygitweb.change_queue import CHANGE_QUEUE
 from pygitweb.config import settings
+from pygitweb.dependencies import is_loopback_host
 from pygitweb.main import app
 
 
@@ -144,3 +145,24 @@ class TestUpdatesLongPoll:
 				return resp.status_code
 
 		assert asyncio.run(scenario()) == 404
+
+	def test_notify_rejects_non_loopback_client(self, updates_env: dict[str, str]) -> None:
+		del updates_env
+
+		async def scenario() -> int:
+			transport = ASGITransport(app=app, client=("203.0.113.1", 999))
+			async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+				resp = await client.post("/_internal/notify", params={"project": "alpha"})
+				return resp.status_code
+
+		assert asyncio.run(scenario()) == 403
+
+
+class TestLoopbackHost:
+	def test_is_loopback_host(self) -> None:
+		assert is_loopback_host("127.0.0.1")
+		assert is_loopback_host("::1")
+		assert is_loopback_host("localhost")
+		assert not is_loopback_host("203.0.113.1")
+		assert not is_loopback_host(None)
+		assert not is_loopback_host("")
diff --git a/pygitweb/dependencies.py b/pygitweb/dependencies.py
index 48de505..0aae681 100644
--- a/pygitweb/dependencies.py
+++ b/pygitweb/dependencies.py
@@ -4,9 +4,10 @@ FastAPI dependencies for shared request validation (exported repositories, etc.)
 
 from __future__ import annotations
 
+import ipaddress
 from typing import Annotated, Any
 
-from fastapi import Depends, Form, HTTPException, Query
+from fastapi import Depends, Form, HTTPException, Query, Request
 
 from pygitweb.auth import (
 	access_token_from_request,
@@ -100,6 +101,25 @@ def require_writable_project_name(
 	return validated
 
 
+def is_loopback_host(host: str | None) -> bool:
+	if not host:
+		return False
+	if host == "localhost":
+		return True
+	try:
+		return ipaddress.ip_address(host).is_loopback
+	except ValueError:
+		return False
+
+
+def require_loopback_client(request: Request) -> None:
+	"""Reject non-local callers (hooks should POST from the same host)."""
+	client = request.client
+	if client is not None and is_loopback_host(client.host):
+		return
+	raise HTTPException(status_code=403, detail="Forbidden")
+
+
 def require_notify_project(
 	project: Annotated[str | None, Query(alias="project", description="Repository path")] = None,
 ) -> str:
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 302e9e0..402eebd 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -68,6 +68,7 @@ from pygitweb.dependencies import (
 	ValidatedReadableProject,
 	filter_projects_by_read_access,
 	project_visible_in_list,
+	require_loopback_client,
 )
 from pygitweb.formatting import age_string
 from pygitweb.git_helpers import git_get_references, git_get_type
@@ -724,7 +725,10 @@ def project_hooks_list(project: ValidatedReadableProject) -> JSONResponse:
 
 
 @app.post("/_internal/notify", response_class=JSONResponse)
-async def internal_notify(project: ValidatedNotifyProject) -> JSONResponse:
+async def internal_notify(
+	_: Annotated[None, Depends(require_loopback_client)],
+	project: ValidatedNotifyProject,
+) -> JSONResponse:
 	"""Notify long-polling subscribers that a project's refs changed.
 
 	Intended for server-side hooks (pre-receive / post-receive) running on the same host.
