diff --git a/distgit/auth.py b/distgit/auth.py
new file mode 100644
index 0000000..456ad18
--- /dev/null
+++ b/distgit/auth.py
@@ -0,0 +1,73 @@
+"""
+Root auth provider: base settings and interface common to all auth methods.
+"""
+from __future__ import annotations
+
+import hmac
+import secrets
+import time
+from datetime import timedelta
+
+
+class RootAuthProvider:
+    """
+    Base auth provider with admin credentials, session timeout, and session create/validate.
+    Subclass or use as-is for simple admin user/password auth.
+    """
+
+    def __init__(
+        self,
+        *,
+        admin_user: bytes | None = None,
+        admin_password: bytes | None = None,
+        session_timeout: timedelta | float | None = None,  # duration; None = no expiry
+    ) -> None:
+        """
+        admin_user: optional admin username (bytes). If None, no admin login is accepted.
+        admin_password: optional admin password (bytes). If None, no admin login is accepted.
+        session_timeout: optional duration in seconds (float) or timedelta. If None, sessions do not expire.
+        """
+        self.admin_user = admin_user
+        self.admin_password = admin_password
+        if session_timeout is not None and hasattr(session_timeout, "total_seconds"):
+            self._timeout_seconds = session_timeout.total_seconds()
+        else:
+            self._timeout_seconds = session_timeout  # float or None
+        self._sessions: dict[str, float] = {}  # token -> created_at (monotonic or epoch)
+
+    def create_session(
+        self,
+        *,
+        user: bytes | None = None,
+        password: bytes | None = None,
+    ) -> str | None:
+        """
+        Authenticate with user/password and create a session if valid.
+        Returns a session token or None if credentials are missing or invalid.
+        """
+        if self.admin_user is None or self.admin_password is None:
+            return None
+        if user is None or password is None:
+            return None
+        if not hmac.compare_digest(user, self.admin_user) or not hmac.compare_digest(
+            password, self.admin_password
+        ):
+            return None
+        token = secrets.token_urlsafe(32)
+        self._sessions[token] = time.monotonic()
+        return token
+
+    def validate_session(self, session_token: str) -> bool:
+        """
+        Return True if the session token exists and (when session_timeout is set) is not expired.
+        """
+        if not session_token:
+            return False
+        created = self._sessions.get(session_token)
+        if created is None:
+            return False
+        if self._timeout_seconds is not None:
+            if time.monotonic() - created > self._timeout_seconds:
+                del self._sessions[session_token]
+                return False
+        return True
diff --git a/pygitweb/config.py b/pygitweb/config.py
index a9604ae..31c76ef 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -24,6 +24,22 @@ GIT_BINDIR = os.environ.get("GIT_BINDIR", "")
 GIT = (GIT_BINDIR + "/git") if GIT_BINDIR else "git"
 MAXLOAD: float | None = None  # 300 in perl; None = disabled
 
+"""
+The auth provider to use. "None" disables authentication entirely, bypassing distgit. This is useful for testing, development, or as a local admin panel - but USE WITH CAUTION.
+RootProvider: Mostly a stub but provides admin login with an admin user/password. All other providers inherit from this.
+SSHProvider: TODO - Will authenticate against a local SSH server using existing keys. Easiest coming from raw git-daemon.
+OAuth2Provider: TODO - Will authenticate against an OAuth2 server.
+OIDCProvider: TODO - Will authenticate against an OIDC server.
+MatrixProvider: TODO - Will authenticate against the Matrix network.
+LDAPProvider: TODO - Will authenticate against a local LDAP server.
+"""
+DISTGIT_AUTH: str = os.environ.get("DISTGIT_AUTH", "distgit.auth.RootProvider")
+if DISTGIT_AUTH == "None":
+    print("WARNING: Authentication is disabled. PROCEED WITH CAUTION.")
+DISTGIT_ADMIN_USER: str = os.environ.get("DISTGIT_ADMIN_USER", None)
+DISTGIT_ADMIN_PASSWORD: str = os.environ.get("DISTGIT_ADMIN_PASSWORD", None)
+DISTGIT_SESSION_TIMEOUT: str = os.environ.get("DISTGIT_SESSION_TIMEOUT", 3600 * 24 * 7)
+
 # Config file paths (can be overridden by env)
 GITWEB_CONFIG = os.environ.get("GITWEB_CONFIG", "")
 GITWEB_CONFIG_SYSTEM = os.environ.get("GITWEB_CONFIG_SYSTEM", "")
diff --git a/pygitweb/main.py b/pygitweb/main.py
index b3b22e3..ce1768e 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -5,18 +5,24 @@ Ported from gitweb/gitweb.perl dispatch and action handlers.
 from __future__ import annotations
 
 import mimetypes
+import os
+import subprocess
+import tempfile
+import zipfile
 from datetime import datetime, timedelta, timezone
 from pathlib import Path
 from typing import Annotated
 
-from fastapi import FastAPI, HTTPException, Query, Request
-from fastapi.responses import HTMLResponse, PlainTextResponse, Response
+import pygit2
+from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
+from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
 from fastapi.staticfiles import StaticFiles
 
 from jinja2 import Template, Environment, PackageLoader
 
 from pygitweb import config
 from pygitweb.config import (
+    DISTGIT_AUTH,
     EXPORT_OK,
     PROJECTROOT,
     PROJECTS_LIST,
@@ -104,7 +110,7 @@ if _static_dir.is_dir():
 
 def _project_in_list(project: str) -> bool:
     lst = git_get_projects_list(
-        project_filter="",
+        filter_path="",
         paranoid=STRICT_EXPORT,
         export_ok=EXPORT_OK,
     )
@@ -116,6 +122,51 @@ def _get_project_config(project: str, key: str):
     return git_get_project_config(project, key)
 
 
+_auth_provider = None
+
+
+def _get_auth_provider():
+    """Return auth provider instance if DISTGIT_AUTH is set; None if auth disabled."""
+    global _auth_provider
+    if DISTGIT_AUTH == "None":
+        return None
+    if _auth_provider is not None:
+        return _auth_provider
+    try:
+        mod_name, _, cls_name = DISTGIT_AUTH.rpartition(".")
+        mod = __import__(mod_name, fromlist=[cls_name])
+        cls = getattr(mod, cls_name)
+        # RootAuthProvider accepts admin_user, admin_password, session_timeout from env
+        admin_user = DISTGIT_ADMIN_USER.encode("utf-8") or None
+        admin_password = DISTGIT_ADMIN_PASSWORD.encode("utf-8") or None
+        timeout = DISTGIT_SESSION_TIMEOUT
+        _auth_provider = cls(
+            admin_user=admin_user,
+            admin_password=admin_password,
+            session_timeout=timeout,
+        )
+    except Exception:
+        _auth_provider = None
+    return _auth_provider
+
+
+def _request_can_add_project(request: Request) -> bool:
+    """True if auth is disabled or request has a valid session (header X-Session-Token or param/cookie session)."""
+    if DISTGIT_AUTH == "None":
+        return True
+    provider = _get_auth_provider()
+    if provider is None:
+        return False
+    token = (
+        request.headers.get("X-Session-Token")
+        or request.query_params.get("session")
+        or request.cookies.get("session")
+    )
+    if not token:
+        return False
+    return getattr(provider, "validate_session", lambda _: False)(token)
+
+
 @app.on_event("startup")
 def startup():
     evaluate_gitweb_config()
@@ -224,6 +275,128 @@ def git_opml():
     return PlainTextResponse("\n".join(lines), media_type="text/xml; charset=utf-8")
 
 
+# ---------- Add project ----------
+
+
+@app.get("/projectnamevalid", response_class=HTMLResponse)
+def addproject_namevalid(request: Request):
+    """Check if project name is valid."""
+    project_name = request.query_params.get("name")
+    if not project_name:
+        raise HTTPException(status_code=400, detail="Param 'name' required")
+    if not is_valid_pathname(project_name):
+        raise HTTPException(status_code=400, detail="Invalid project name (no path segments)")
+    if _project_in_list(project_name):
+        raise HTTPException(status_code=409, detail="Project already exists")
+    return HTMLResponse(status_code=200, content=f"Project name '{project_name}' is valid")
+
+@app.get("/addproject", response_class=HTMLResponse)
+def addproject_page(request: Request):
+    """Add project form page."""
+    pre = PREAMBLE.render(
+        title=f"{config.SITE_NAME} - Add Project",
+        theme="dark",
+        site_name=config.SITE_NAME,
+    )
+    tpl = env.get_template("addproject.html")
+    body = tpl.render(site_name=config.SITE_NAME)
+    return HTMLResponse(status_code=200, content=f"{pre}{body}{POSTAMBLE}")
+
+
+@app.post("/addproject", response_class=HTMLResponse)
+async def addproject_submit(
+    request: Request,
+    project_name: Annotated[str, Form()] = "",
+    pull_from_remote: Annotated[str, Form()] = "",
+    perform_maintenance: Annotated[str, Form()] = "off",
+    create_task_board: Annotated[str, Form()] = "off",
+    repo_zip: UploadFile | None = File(default=None),
+):
+    """
+    Create a new project. Allowed only if project name does not exist,
+    and valid session in headers / session param (or auth is disabled).
+    """
+    if not _request_can_add_project(request):
+        raise HTTPException(status_code=401, detail="Authentication required to add projects")
+
+    project_name = (project_name or "").strip()
+    if not project_name:
+        raise HTTPException(status_code=400, detail="Project name is required")
+    if not is_valid_pathname(project_name):
+        raise HTTPException(status_code=400, detail="Invalid project name")
+    if _project_in_list(project_name):
+        raise HTTPException(status_code=409, detail="Project already exists")
+
+    remote_url = (pull_from_remote or "").strip()
+    do_maintenance = perform_maintenance.lower() in ("on", "1", "true", "yes")
+    do_task_board = create_task_board.lower() in ("on", "1", "true", "yes")
+
+    dest_path = os.path.join(PROJECTROOT, project_name)
+    os.makedirs(PROJECTROOT, exist_ok=True)
+
+    try:
+        if remote_url and remote_url != "":
+            pygit2.clone_repository(remote_url, dest_path, bare=True)
+        elif repo_zip and repo_zip.filename and repo_zip.filename.lower().endswith(".zip"):
+            with tempfile.TemporaryDirectory() as tmpdir:
+                zip_path = os.path.join(tmpdir, "repo.zip")
+                content = await repo_zip.read()
+                with open(zip_path, "wb") as f:
+                    f.write(content)
+                with zipfile.ZipFile(zip_path, "r") as zf:
+                    zf.extractall(tmpdir)
+                # Find .git: either at root or inside a single top-level dir
+                repo_root = None
+                for name in os.listdir(tmpdir):
+                    if name == "repo.zip":
+                        continue
+                    p = os.path.join(tmpdir, name)
+                    if os.path.isdir(p):
+                        if os.path.isdir(os.path.join(p, ".git")):
+                            repo_root = p
+                            break
+                        if name == ".git":
+                            repo_root = tmpdir
+                            break
+                if repo_root is None:
+                    if os.path.isdir(os.path.join(tmpdir, ".git")):
+                        repo_root = tmpdir
+                    else:
+                        # Single subdir that might be the repo
+                        subs = [x for x in os.listdir(tmpdir) if x != "repo.zip" and os.path.isdir(os.path.join(tmpdir, x))]
+                        if len(subs) == 1:
+                            repo_root = os.path.join(tmpdir, subs[0])
+                if repo_root is None or not pygit2.discover_repository(repo_root):
+                    raise HTTPException(
+                        status_code=400,
+                        detail="ZIP must contain a git repository (directory with .git)",
+                    )
+                pygit2.clone_repository(repo_root, dest_path, bare=True)
+        else:
+            pygit2.init_repository(dest_path, bare=True)
+
+        if do_maintenance:
+            try:
+                subprocess.run(
+                    [config.GIT, "-C", dest_path, "maintenance", "start"],
+                    capture_output=True,
+                    timeout=60,
+                )
+            except (subprocess.SubprocessError, FileNotFoundError):
+                pass  # best-effort
+
+        if do_task_board:
+            pass  # stub
+    except pygit2.GitError as e:
+        raise HTTPException(status_code=400, detail=f"Git error: {e}")
+    except HTTPException:
+        raise
+    except OSError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+    return RedirectResponse(url=f"/{project_name}", status_code=303)
+
+
 # ---------- Routes (project required) ----------
 
 
diff --git a/pygitweb/templates/addproject.html b/pygitweb/templates/addproject.html
new file mode 100644
index 0000000..6fc5040
--- /dev/null
+++ b/pygitweb/templates/addproject.html
@@ -0,0 +1,136 @@
+<h1 class="page-title">Add project</h1>
+<div class="card">
+  <div class="card-body">
+    <form action="/addproject" method="post" enctype="multipart/form-data" class="needs-validation" novalidate>
+      <div class="mb-3" id="project_name_wrapper">
+        <label class="form-label required" for="project_name">Project name</label>
+        <input type="text" class="form-control" id="project_name" name="project_name" required
+               placeholder="my-repo" autocomplete="off">
+        <small class="form-hint">Unique name for the project (no path segments).</small>
+      </div>
+
+      <div class="mb-3">
+        <label class="form-label" for="pull_from_remote">Pull from remote</label>
+        <input type="url" class="form-control" id="pull_from_remote" name="pull_from_remote"
+               placeholder="https://example.com/repo.git or ssh://git@host/repo.git or git://host/repo.git">
+        <small class="form-hint">Optional. Use an <code>ssh://</code>, <code>git://</code>, or <code>https://</code> URL to clone an existing repository.</small>
+      </div>
+
+      <div class="mb-3">
+        <label class="form-label" for="repo_zip">Upload .zip (git repo)</label>
+        <div class="dropzone border rounded p-4 text-center" id="dropzone" style="min-height: 120px;">
+          <input type="file" class="form-control d-none" id="repo_zip" name="repo_zip" accept=".zip"
+                 data-dropzone-target>
+          <div class="dropzone-placeholder text-muted" data-dropzone-placeholder>
+            Drag a .zip file here or click to choose. The archive should contain a git repository (e.g. a directory with a <code>.git</code> folder).
+          </div>
+          <div class="dropzone-filename d-none text-start small mt-2" data-dropzone-filename></div>
+        </div>
+      </div>
+
+      <div class="mb-3">
+        <div class="form-check">
+          <input type="checkbox" class="form-check-input" id="perform_maintenance" name="perform_maintenance" value="on">
+          <label class="form-check-label" for="perform_maintenance">Perform maintenance</label>
+        </div>
+        <small class="form-hint">Run <code>git maintenance start</code> to schedule gc and other maintenance at a specified interval.</small>
+      </div>
+
+      <div class="mb-3">
+        <div class="form-check">
+          <input type="checkbox" class="form-check-input" id="create_task_board" name="create_task_board" value="on">
+          <label class="form-check-label" for="create_task_board">Create task board</label>
+        </div>
+        <small class="form-hint">Create a task board for this project; tasks are stored in the object database.</small>
+      </div>
+
+      <div class="form-footer">
+        <button type="submit" class="btn btn-primary">Add project</button>
+        <a href="/" class="btn btn-ghost-secondary">Cancel</a>
+      </div>
+    </form>
+  </div>
+</div>
+
+<script>
+(function() {
+  var projectNameInput = document.getElementById('project_name');
+  var nameValidTimeout = null;
+
+  function setProjectNameBorder(valid) {
+    projectNameInput.classList.remove('border', 'border-success', 'border-danger');
+    projectNameInput.classList.add('border');
+    if (valid === true) {
+      projectNameInput.classList.add('border-success');
+    } else if (valid === false) {
+      projectNameInput.classList.add('border-danger');
+    } else {
+      projectNameInput.classList.remove('border');
+    }
+  }
+
+  function checkProjectName() {
+    var name = (projectNameInput.value || '').trim();
+    if (!name) {
+      setProjectNameBorder(null);
+      return;
+    }
+    var url = '/projectnamevalid?name=' + encodeURIComponent(name);
+    fetch(url)
+      .then(function(res) {
+        setProjectNameBorder(res.ok);
+      })
+      .catch(function() {
+        setProjectNameBorder(null);
+      });
+  }
+
+  projectNameInput.addEventListener('blur', function() {
+    checkProjectName();
+  });
+  projectNameInput.addEventListener('input', function() {
+    clearTimeout(nameValidTimeout);
+    var name = (projectNameInput.value || '').trim();
+    if (!name) {
+      setProjectNameBorder(null);
+      return;
+    }
+    nameValidTimeout = setTimeout(checkProjectName, 400);
+  });
+})();
+
+(function() {
+  var dropzone = document.getElementById('dropzone');
+  var input = document.getElementById('repo_zip');
+  var placeholder = dropzone.querySelector('[data-dropzone-placeholder]');
+  var filenameEl = dropzone.querySelector('[data-dropzone-filename]');
+
+  function showFile(name) {
+    if (name) {
+      placeholder.classList.add('d-none');
+      filenameEl.classList.remove('d-none');
+      filenameEl.textContent = 'Selected: ' + name;
+    } else {
+      placeholder.classList.remove('d-none');
+      filenameEl.classList.add('d-none');
+      filenameEl.textContent = '';
+    }
+  }
+
+  dropzone.addEventListener('click', function() { input.click(); });
+  dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.classList.add('border-primary'); });
+  dropzone.addEventListener('dragleave', function() { dropzone.classList.remove('border-primary'); });
+  dropzone.addEventListener('drop', function(e) {
+    e.preventDefault();
+    dropzone.classList.remove('border-primary');
+    var files = e.dataTransfer.files;
+    if (files.length && files[0].name.toLowerCase().endsWith('.zip')) {
+      input.files = files;
+      showFile(files[0].name);
+    }
+  });
+  input.addEventListener('change', function() {
+    showFile(input.files.length ? input.files[0].name : null);
+  });
+})();
+</script>
diff --git a/pygitweb/templates/preamble.html b/pygitweb/templates/preamble.html
index 74e9894..a67c2c0 100644
--- a/pygitweb/templates/preamble.html
+++ b/pygitweb/templates/preamble.html
@@ -30,6 +30,12 @@
             </a>
           </li>
           <li class="nav-item">
+            <a class="nav-link" href="/addproject">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-plus" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg></span>
+              <span class="nav-link-title">Add project</span>
+            </a>
+          </li>
+          <li class="nav-item">
             <a class="nav-link" href="#">
               <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-activity"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l3 8l4 -16l3 8h4" /></svg></span>
               <span class="nav-link-title">Activity</span>
