diff --git a/integration/__init__.py b/integration/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/integration/__init__.py
diff --git a/integration/harness.py b/integration/harness.py
new file mode 100644
index 0000000..145ba25
--- /dev/null
+++ b/integration/harness.py
@@ -0,0 +1,71 @@
+"""Shared helpers for Playwright integration tests against an in-process uvicorn server."""
+
+from __future__ import annotations
+
+import socket
+import threading
+import time
+from collections.abc import Iterator
+from contextlib import ExitStack, contextmanager
+from pathlib import Path
+from unittest.mock import patch
+
+import uvicorn
+
+
+def free_port() -> int:
+    with socket.socket() as sock:
+        sock.bind(("127.0.0.1", 0))
+        return int(sock.getsockname()[1])
+
+
+@contextmanager
+def running_server(
+    *,
+    tmp_path: Path,
+    projects: Path,
+    auth: bool = False,
+    auth_config_file: Path | None = None,
+) -> Iterator[str]:
+    """Serve ``pygitweb.main:app`` on a free localhost port in a daemon thread."""
+    from pygitweb.auth_config import init_auth_config
+    from pygitweb.config import settings
+    from pygitweb.main import app
+
+    if auth and auth_config_file is None:
+        raise ValueError("auth_config_file is required when auth is enabled")
+
+    port = free_port()
+    base = f"http://127.0.0.1:{port}/"
+    log_path = tmp_path / "smart_http.log"
+
+    with ExitStack() as stack:
+        stack.enter_context(patch.object(settings, "AUTH", auth))
+        stack.enter_context(patch.object(settings, "PROJECTROOT", str(projects)))
+        stack.enter_context(patch.object(settings, "PROJECTS_LIST", str(projects)))
+        stack.enter_context(patch("pygitweb.smart_http.smart_http_log_path", return_value=log_path))
+        if auth_config_file is not None:
+            stack.enter_context(patch.object(settings, "AUTH_CONFIG", str(auth_config_file)))
+        if auth:
+            cfg = init_auth_config(settings)
+            stack.enter_context(patch("pygitweb.auth_config.auth_config", cfg))
+            stack.enter_context(patch("pygitweb.auth.auth_config", cfg))
+            stack.enter_context(patch("pygitweb.main.auth_config", cfg))
+            stack.enter_context(patch("pygitweb.config.auth_config", cfg))
+
+        config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error", ws="none")
+        server = uvicorn.Server(config)
+        thread = threading.Thread(target=server.run, daemon=True)
+        thread.start()
+        deadline = time.monotonic() + 5.0
+        while not server.started and time.monotonic() < deadline:
+            time.sleep(0.01)
+        if not server.started:
+            server.should_exit = True
+            thread.join(timeout=5.0)
+            raise RuntimeError("integration server did not start")
+        try:
+            yield base
+        finally:
+            server.should_exit = True
+            thread.join(timeout=5.0)
diff --git a/integration/local_auth_push_test.py b/integration/local_auth_push_test.py
new file mode 100644
index 0000000..2bec619
--- /dev/null
+++ b/integration/local_auth_push_test.py
@@ -0,0 +1,122 @@
+"""Local account → browser-issued HTTP key → git push to an empty project."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from integration.harness import running_server
+
+sync_api = pytest.importorskip("playwright.sync_api")
+
+_USERNAME = "alice"
+_PASSWORD = "secretsecret"
+_KEY_ID = "laptop"
+_PROJECT = "emptyproj"
+
+
+def _write_local_auth_config(path: Path) -> None:
+    path.write_text(
+        json.dumps(
+            {
+                "salt": "0123456789abcdef",
+                "auth_mode": "local",
+                "oauth_permissions": {
+                    "*": [],
+                    "pgw.read.*": ["*"],
+                    "pgw.write.*": ["*"],
+                },
+                "local_users": [
+                    {
+                        "user": _USERNAME,
+                        "pass": _PASSWORD,
+                        "email": "alice@example.com",
+                    }
+                ],
+            },
+            indent=2,
+        )
+        + "\n",
+        encoding="utf-8",
+    )
+    if os.name != "nt":
+        path.chmod(0o600)
+
+
+def _run_git(repo_dir: Path, *args: str, git_config: tuple[str, ...] = ()) -> str:
+    completed = subprocess.run(
+        ["git", *git_config, *args],
+        cwd=repo_dir,
+        capture_output=True,
+        text=True,
+        check=True,
+        env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
+    )
+    return completed.stdout.strip()
+
+
+def _key_git_config(username: str, secret: str) -> tuple[str, ...]:
+    helper = f"!f() {{ echo username={username}; echo password={secret}; }}; f"
+    return ("-c", "credential.helper=", "-c", f"credential.helper={helper}")
+
+
+def _issue_key_via_browser(base: str) -> str:
+    with sync_api.sync_playwright() as playwright:
+        browser = playwright.chromium.launch(headless=True)
+        try:
+            page = browser.new_page()
+            page.goto(f"{base}login?next=/user", wait_until="domcontentloaded")
+            page.fill("#login_username", _USERNAME)
+            page.fill("#login_password", _PASSWORD)
+            page.get_by_role("button", name="Sign in").click()
+            page.wait_for_url("**/user")
+            sync_api.expect(page.get_by_role("heading", name="Account")).to_be_visible()
+            page.fill("#api-key-id", _KEY_ID)
+            page.get_by_role("button", name="Create key").click()
+            page.locator("#api-key-secret-panel").wait_for(state="visible")
+            secret = page.locator("#api-key-secret").input_value()
+            assert secret, "expected a plaintext API key after Create key"
+            return secret
+        finally:
+            browser.close()
+
+
+@pytest.mark.playwright
+def test_local_account_issues_key_and_pushes(tmp_path: Path) -> None:
+    projects = tmp_path / "projects"
+    projects.mkdir()
+    bare = projects / _PROJECT
+    subprocess.run(["git", "init", "--bare", "-b", "main", str(bare)], check=True, capture_output=True)
+
+    auth_path = tmp_path / "auth.json"
+    _write_local_auth_config(auth_path)
+
+    with running_server(
+        tmp_path=tmp_path,
+        projects=projects,
+        auth=True,
+        auth_config_file=auth_path,
+    ) as base:
+        secret = _issue_key_via_browser(base)
+
+        work = tmp_path / "work"
+        work.mkdir()
+        _run_git(work, "init", "-b", "main")
+        _run_git(work, "config", "user.name", "tester")
+        _run_git(work, "config", "user.email", "tester@example.com")
+        (work / "README.md").write_text("from key\n", encoding="utf-8")
+        _run_git(work, "add", "README.md")
+        _run_git(work, "commit", "-m", "initial via http key")
+        remote = f"{base}http/{_PROJECT}.git"
+        _run_git(work, "remote", "add", "origin", remote)
+        git_config = _key_git_config(_USERNAME, secret)
+        _run_git(work, "push", "-u", "origin", "main", git_config=git_config)
+
+        head = _run_git(bare, "rev-parse", "refs/heads/main")
+        assert head
+        subject = _run_git(bare, "log", "-1", "--format=%s", "refs/heads/main")
+        assert subject == "initial via http key"
diff --git a/integration/projects_page_test.py b/integration/projects_page_test.py
index ddfd976..cb74cc4 100644
--- a/integration/projects_page_test.py
+++ b/integration/projects_page_test.py
@@ -1,70 +1,15 @@
-"""Install the workspace into a clean venv and open the projects page in a browser."""
+"""Open the projects page in a browser against an in-process server."""
 
 from __future__ import annotations
 
-import os
-import socket
 import subprocess
-import sys
-import time
-import urllib.error
-import urllib.request
-from collections.abc import Mapping
 from pathlib import Path
 
 import pytest
 
-sync_api = pytest.importorskip("playwright.sync_api")
-
-_REPO_ROOT = Path(__file__).resolve().parents[1]
-_KEPT_ENV = (
-    "PATH",
-    "HOME",
-    "USER",
-    "LOGNAME",
-    "LANG",
-    "LC_ALL",
-    "LC_CTYPE",
-    "TMPDIR",
-    "TMP",
-    "TEMP",
-    "SYSTEMROOT",
-)
-
-
-def _fresh_env(overrides: Mapping[str, str] | None = None) -> dict[str, str]:
-    env = {key: os.environ[key] for key in _KEPT_ENV if key in os.environ}
-    if overrides:
-        env.update(overrides)
-    return env
-
-
-def _venv_python(venv: Path) -> Path:
-    if sys.platform == "win32":
-        return venv / "Scripts" / "python.exe"
-    return venv / "bin" / "python"
-
+from integration.harness import running_server
 
-def _free_port() -> int:
-    with socket.socket() as sock:
-        sock.bind(("127.0.0.1", 0))
-        return int(sock.getsockname()[1])
-
-
-def _wait_until_ready(url: str, proc: subprocess.Popen[str], log_path: Path, timeout: float) -> None:
-    deadline = time.monotonic() + timeout
-    while time.monotonic() < deadline:
-        if proc.poll() is not None:
-            log = log_path.read_text(encoding="utf-8") if log_path.is_file() else ""
-            raise RuntimeError(f"server exited with {proc.returncode}\n{log}")
-        try:
-            with urllib.request.urlopen(url, timeout=1) as response:
-                if response.status == 200:
-                    return
-        except (urllib.error.URLError, TimeoutError, OSError):
-            time.sleep(0.1)
-    log = log_path.read_text(encoding="utf-8") if log_path.is_file() else ""
-    raise TimeoutError(f"server did not become ready: {url}\n{log}")
+sync_api = pytest.importorskip("playwright.sync_api")
 
 
 @pytest.mark.playwright
@@ -74,68 +19,16 @@ def test_projects_page_loads_without_auth(tmp_path: Path) -> None:
     repo.mkdir(parents=True)
     subprocess.run(["git", "init", "-b", "main", str(repo)], check=True, capture_output=True)
 
-    venv = tmp_path / "venv"
-    subprocess.run(
-        ["uv", "sync", "--all-packages", "--frozen", "--no-dev"],
-        cwd=_REPO_ROOT,
-        env=_fresh_env({"UV_PROJECT_ENVIRONMENT": str(venv)}),
-        check=True,
-    )
-
-    port = _free_port()
-    base = f"http://127.0.0.1:{port}/"
-    home = tmp_path / "home"
-    home.mkdir()
-    # Windows Path.home() reads USERPROFILE.
-    server_env = _fresh_env({
-        "HOME": str(home),
-        "USERPROFILE": str(home),
-        "PYGITWEB_AUTH": "0",
-        "PYGITWEB_PROJECTROOT": str(projects),
-        "PYGITWEB_PROJECTS_LIST": str(projects),
-        "PYGITWEB_SETTINGS_CONFIG": str(tmp_path / "settings.json"),
-        "PYGITWEB_AUTH_CONFIG": str(tmp_path / "auth.json"),
-        "PYTHONUNBUFFERED": "1",
-    })
-    log_out_path = tmp_path / "server.out"
-    log_err_path = tmp_path / "server.err"
-    log_out = log_out_path.open("w", encoding="utf-8")
-    log_err = log_err_path.open("w", encoding="utf-8")
-    proc = subprocess.Popen(
-        [
-            str(_venv_python(venv)),
-            "-m",
-            "uvicorn",
-            "pygitweb.main:app",
-            "--host",
-            "127.0.0.1",
-            "--port",
-            str(port),
-        ],
-        cwd=tmp_path,
-        env=server_env,
-        stdout=log_out,
-        stderr=log_err,
-    )
-    try:
-        _wait_until_ready(base, proc, log_err_path, timeout=30)
-        with sync_api.sync_playwright() as playwright:
-            browser = playwright.chromium.launch(headless=True)
-            try:
-                page = browser.new_page()
-                page.goto(base, wait_until="domcontentloaded")
-                sync_api.expect(page).to_have_title("PyGitWeb - Projects")
-                sync_api.expect(page.get_by_role("heading", name="Projects")).to_be_visible()
-                sync_api.expect(page.get_by_role("link", name="demo", exact=True)).to_be_visible()
-            finally:
-                browser.close()
-    finally:
-        if proc.poll() is None:
-            proc.terminate()
-            try:
-                proc.wait(timeout=10)
-            except subprocess.TimeoutExpired:
-                proc.kill()
-                proc.wait(timeout=10)
-        log_out.close()
-        log_err.close()
+    with (
+        running_server(tmp_path=tmp_path, projects=projects, auth=False) as base,
+        sync_api.sync_playwright() as playwright,
+    ):
+        browser = playwright.chromium.launch(headless=True)
+        try:
+            page = browser.new_page()
+            page.goto(base, wait_until="domcontentloaded")
+            sync_api.expect(page).to_have_title("PyGitWeb - Projects")
+            sync_api.expect(page.get_by_role("heading", name="Projects")).to_be_visible()
+            sync_api.expect(page.get_by_role("link", name="demo", exact=True)).to_be_visible()
+        finally:
+            browser.close()
