diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c218933a45ee37e99d3b9d197da3b0662d8e2c1f..38478417152bfb056f1120d14f0627e83ac70f12 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,7 +30,7 @@
           persist-credentials: false
       - run: cargo --version
       - run: cargo test --locked --all-targets --all-features
-      - run: cargo test --locked --release --test auth --test ssh
+      - run: cargo test --locked --release --test auth --test ssh --test account_lifecycle
       - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes --test serve
       - run: cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
       - run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
diff --git a/README.md b/README.md
index 5122da99df10b56a3bca876f21dffb81d84decae..70b93ea7e44625785b66971c1114b9e4d4157504 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,16 @@
 host key during subsequent starts. Keep this file with the instance data.
 
 The server owns the instance lock until it receives SIGINT or SIGTERM. Stop the
-server before you run an offline administrator command. A subsequent milestone
-routes necessary online administrator commands through the control socket.
+server before you run an offline administrator command. Create a signup code
+through the control socket while the server runs:
+
+```text
+tit --config /srv/tit/config.toml invite-code
+```
+
+The code is valid for one signup during the next 24 hours. Open `/signup` to
+create the account. Store the recovery credential offline when the Web UI shows
+it. Open `/recover` to replace all account keys with a new key.
 
 ## Quality gate
 
@@ -138,3 +146,16 @@
 and tests the public routes with SHA-1 and SHA-256 repositories. Read the
 [source search architectural decision record](docs/adr/0008-bounded-source-search.md)
 for the search limits and current measurement.
+
+## Milestone 3.1 gate
+
+Install stock Git and OpenSSH. Then, run the account lifecycle gate:
+
+```text
+./scripts/check-m3-1
+```
+
+This command tests invitation, signup, recovery, key revocation, account
+suspension, and the owner-only control socket. Read the
+[account lifecycle architectural decision record](docs/adr/0010-account-lifecycle.md)
+for the credential and failure behavior.
diff --git a/docs/adr/0009-operational-server.md b/docs/adr/0009-operational-server.md
index d7f230bcee5cae3653c5b9cc41013b5db1c5704a..52be603875375a61eba138ed5aeb8156c65932b1 100644
--- a/docs/adr/0009-operational-server.md
+++ b/docs/adr/0009-operational-server.md
@@ -18,7 +18,8 @@
 the listener that already started and return an error.
 
 At startup, read the active SSH public keys and the active public repositories
-from SQLite. Give the SSH transport a fixed map from each owner and repository
+from SQLite. Reload the key map after a successful account signup or recovery.
+Give the SSH transport a fixed map from each owner and repository
 slug to its immutable repository ID. Resolve the final path below the canonical
 repository directory. The instance lock prevents an offline command from
 changing this map during the server run.
diff --git a/docs/adr/0010-account-lifecycle.md b/docs/adr/0010-account-lifecycle.md
new file mode 100644
index 0000000000000000000000000000000000000000..792cf2221faa0c88bbc24a27907cb2ceab917e08
--- /dev/null
+++ b/docs/adr/0010-account-lifecycle.md
@@ -1,0 +1,65 @@
+# Architectural decision record 0010: account lifecycle
+
+Status: Accepted
+
+Date: 2026-07-22
+
+## Context
+
+Account creation must use an invite code. An operator must create the code while
+the server owns the instance lock. Recovery must remove access from keys that a
+user can no longer control. The database must not contain an invite code or a
+recovery credential in plain text.
+
+## Decision
+
+Add the owner-only `control.sock` below the instance directory. The server
+refuses a symbolic link, a non-socket path, and an existing socket. The server
+creates the socket with mode 600. `tit invite-code` sends one bounded request to
+this socket. An invite code is valid for 24 hours and one signup. The database
+stores its SHA-256 hash.
+
+Signup requires an invite code, a valid username, and a supported SSH public
+key. One SQLite transaction consumes the invitation and creates the account,
+the first key, and the recovery credential hash. A failed transaction does not
+consume the invitation. The Web UI shows the recovery credential one time.
+
+Recovery requires the username, the current recovery credential, and a new SSH
+public key. One transaction revokes all old keys, activates the new key, and
+stores the hash of a new recovery credential. The Web UI shows the new
+credential one time. The old credential cannot be used again. The running SSH
+server reloads active keys after a successful signup or recovery.
+
+An administrator can add and revoke keys, suspend accounts, and restore
+accounts with offline `tit admin account` commands. The last active key cannot
+be revoked through the key-revocation command. An account row is never deleted.
+Thus, its unique username stays reserved after suspension.
+
+## Failure and threat cases
+
+The control protocol has a small fixed request and a five-second time limit.
+The client refuses a symbolic link, a non-socket path, and group or other
+permissions. Shutdown removes the socket only when its device and inode still
+identify the socket that this process created. Thus, shutdown does not remove a
+replacement path.
+
+Signup and recovery errors do not put a credential in a log or an error page.
+Malformed input has a fixed size limit. The account service parses SSH keys
+before a transaction starts. SQLite constraints preserve the invitation,
+username, key, and account-state invariants.
+
+## Evidence
+
+Storage tests cover one-time and expired invitations, transaction rollback,
+username reservation, key addition and revocation, recovery rotation, and
+account suspension. Control-socket tests cover permissions, cleanup, files, and
+symbolic links. The executable test uses `tit invite-code`, creates an account
+through the Web UI, uses its key through stock Git and OpenSSH, recovers the
+account, and confirms that the old key stops before the server restarts.
+
+## Consequences
+
+Open signup is not available. An operator must remove a stale `control.sock`
+after an unclean server stop, but the server does not automatically remove a
+filesystem entry that it did not create. This choice makes the safe owner
+action explicit.
diff --git a/scripts/check-m3-1 b/scripts/check-m3-1
new file mode 100755
index 0000000000000000000000000000000000000000..38ebe9cb44f860aa0c28b7cbe247eab3eb08717a
--- /dev/null
+++ b/scripts/check-m3-1
@@ -1,0 +1,6 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test account_lifecycle
+cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh
diff --git a/src/account.rs b/src/account.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1f2cbb147a5d33b3a5782f767e476792e79a5e65
--- /dev/null
+++ b/src/account.rs
@@ -1,0 +1,182 @@
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use rand::TryRng;
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+
+use crate::auth::{AuthError, SshPublicKey, validate_username};
+use crate::store::{AccountRecovery, InvitedAccount, NewSshKey, Store, StoreError};
+
+const INVITATION_PREFIX: &str = "tit-invite-v1:";
+const RECOVERY_PREFIX: &str = "tit-recovery-v1:";
+const SECRET_BYTES: usize = 32;
+const INVITATION_LIFETIME_SECONDS: i64 = 24 * 60 * 60;
+
+#[derive(Clone)]
+pub(crate) struct AccountService {
+    database: PathBuf,
+}
+
+impl AccountService {
+    pub(crate) fn new(database: PathBuf) -> Self {
+        Self { database }
+    }
+
+    pub(crate) fn issue_invitation(&self) -> Result<String, AccountError> {
+        let now = now()?;
+        let expires_at = now
+            .checked_add(INVITATION_LIFETIME_SECONDS)
+            .ok_or(AccountError::Clock)?;
+        let code = random_secret(INVITATION_PREFIX)?;
+        Store::open(&self.database)?.create_signup_invitation(&hash(&code), now, expires_at)?;
+        Ok(code)
+    }
+
+    pub(crate) fn signup(
+        &self,
+        invitation: &str,
+        username: &str,
+        public_key: &str,
+    ) -> Result<String, AccountError> {
+        validate_username(username)?;
+        require_secret(invitation, INVITATION_PREFIX)?;
+        let key = SshPublicKey::parse(public_key)?;
+        let recovery = random_secret(RECOVERY_PREFIX)?;
+        let mut store = Store::open(&self.database)?;
+        store.create_account_with_invitation(&InvitedAccount {
+            invitation_hash: &hash(invitation),
+            username,
+            key: new_key(&key, "initial"),
+            recovery_hash: &hash(&recovery),
+            created_at: now()?,
+        })?;
+        Ok(recovery)
+    }
+
+    pub(crate) fn recover(
+        &self,
+        username: &str,
+        recovery: &str,
+        public_key: &str,
+    ) -> Result<String, AccountError> {
+        validate_username(username)?;
+        require_secret(recovery, RECOVERY_PREFIX)?;
+        let key = SshPublicKey::parse(public_key)?;
+        let replacement = random_secret(RECOVERY_PREFIX)?;
+        let mut store = Store::open(&self.database)?;
+        store.recover_account(&AccountRecovery {
+            username,
+            old_recovery_hash: &hash(recovery),
+            key: new_key(&key, "recovery"),
+            new_recovery_hash: &hash(&replacement),
+            created_at: now()?,
+        })?;
+        Ok(replacement)
+    }
+
+    pub(crate) fn add_key(
+        &self,
+        username: &str,
+        label: &str,
+        public_key: &str,
+    ) -> Result<String, AccountError> {
+        validate_username(username)?;
+        validate_label(label)?;
+        let key = SshPublicKey::parse(public_key)?;
+        Store::open(&self.database)?.add_account_key(username, &new_key(&key, label), now()?)?;
+        Ok(key.fingerprint().to_owned())
+    }
+
+    pub(crate) fn revoke_key(&self, username: &str, fingerprint: &str) -> Result<(), AccountError> {
+        validate_username(username)?;
+        Store::open(&self.database)?.revoke_account_key(username, fingerprint, now()?)?;
+        Ok(())
+    }
+
+    pub(crate) fn suspend(&self, username: &str, suspended: bool) -> Result<(), AccountError> {
+        validate_username(username)?;
+        Store::open(&self.database)?.suspend_account(username, suspended)?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "integration tests compile accounts without the server"
+    )]
+    pub(crate) fn database(&self) -> &Path {
+        &self.database
+    }
+}
+
+fn new_key<'a>(key: &'a SshPublicKey, label: &'a str) -> NewSshKey<'a> {
+    NewSshKey {
+        canonical_key: key.canonical(),
+        fingerprint: key.fingerprint(),
+        label,
+    }
+}
+
+fn validate_label(label: &str) -> Result<(), AccountError> {
+    if label.is_empty()
+        || label.len() > 80
+        || label.trim() != label
+        || label.chars().any(char::is_control)
+    {
+        return Err(AccountError::InvalidLabel);
+    }
+    Ok(())
+}
+
+fn require_secret(secret: &str, prefix: &'static str) -> Result<(), AccountError> {
+    let encoded = secret
+        .strip_prefix(prefix)
+        .ok_or(AccountError::InvalidSecret)?;
+    if encoded.len() != SECRET_BYTES * 2 || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+        return Err(AccountError::InvalidSecret);
+    }
+    Ok(())
+}
+
+fn random_secret(prefix: &'static str) -> Result<String, AccountError> {
+    let mut bytes = [0_u8; SECRET_BYTES];
+    rand::rngs::SysRng
+        .try_fill_bytes(&mut bytes)
+        .map_err(|_| AccountError::Random)?;
+    let mut value = String::with_capacity(prefix.len() + SECRET_BYTES * 2);
+    value.push_str(prefix);
+    for byte in bytes {
+        use std::fmt::Write as _;
+        write!(value, "{byte:02x}").expect("writing to a string cannot fail");
+    }
+    Ok(value)
+}
+
+fn hash(secret: &str) -> [u8; 32] {
+    Sha256::digest(secret.as_bytes()).into()
+}
+
+fn now() -> Result<i64, AccountError> {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_err(|_| AccountError::Clock)?
+        .as_secs()
+        .try_into()
+        .map_err(|_| AccountError::Clock)
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum AccountError {
+    #[error(transparent)]
+    Auth(#[from] AuthError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error("key label is not valid")]
+    InvalidLabel,
+    #[error("credential format is not valid")]
+    InvalidSecret,
+    #[error("cannot create a random credential")]
+    Random,
+    #[error("system clock is before the Unix epoch")]
+    Clock,
+}
diff --git a/src/cli.rs b/src/cli.rs
index 4d442cbc6629fde2f860a36de08a640906b7f57a..dd4807fee74bd0d5a154ff3b31c6d45fc8788e43 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -48,6 +48,8 @@
 pub(crate) enum Command {
     /// Start the HTTP and SSH servers
     Serve,
+    /// Create a single-use signup invitation
+    InviteCode,
     /// Check the instance database
     Doctor,
     /// Set up an uninitialized instance
@@ -69,6 +71,30 @@
         #[command(subcommand)]
         command: RepositoryCommand,
     },
+    /// Administer accounts
+    Account {
+        #[command(subcommand)]
+        command: AccountCommand,
+    },
+}
+
+#[derive(Clone, Debug, Subcommand)]
+pub(crate) enum AccountCommand {
+    /// Add an SSH public key
+    KeyAdd {
+        username: String,
+        label: String,
+        ssh_public_key: String,
+    },
+    /// Revoke an SSH public key
+    KeyRevoke {
+        username: String,
+        fingerprint: String,
+    },
+    /// Suspend an account
+    Suspend { username: String },
+    /// Restore a suspended account
+    Resume { username: String },
 }
 
 #[derive(Clone, Debug, Subcommand)]
diff --git a/src/control.rs b/src/control.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f46eb087423966a6059a7e650da842420da784bc
--- /dev/null
+++ b/src/control.rs
@@ -1,0 +1,279 @@
+use std::fs;
+use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use thiserror::Error;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::{UnixListener, UnixStream};
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+
+use crate::account::{AccountError, AccountService};
+
+pub(crate) const CONTROL_SOCKET_FILE: &str = "control.sock";
+const REQUEST: &[u8] = b"invite-code\n";
+const MAX_RESPONSE_BYTES: usize = 256;
+const IO_TIMEOUT: Duration = Duration::from_secs(5);
+
+pub(crate) struct RunningControlServer {
+    shutdown: oneshot::Sender<()>,
+    task: JoinHandle<Result<(), ControlError>>,
+}
+
+impl RunningControlServer {
+    pub(crate) fn start(
+        instance_dir: &Path,
+        accounts: AccountService,
+    ) -> Result<Self, ControlError> {
+        let path = instance_dir.join(CONTROL_SOCKET_FILE);
+        refuse_existing_path(&path)?;
+        let listener = UnixListener::bind(&path).map_err(|source| ControlError::Io {
+            path: path.clone(),
+            source,
+        })?;
+        let created = fs::symlink_metadata(&path).map_err(|source| ControlError::Io {
+            path: path.clone(),
+            source,
+        })?;
+        if !created.file_type().is_socket() {
+            return Err(ControlError::UnsafePath(path));
+        }
+        let cleanup = SocketCleanup {
+            path: path.clone(),
+            identity: SocketIdentity {
+                device: created.dev(),
+                inode: created.ino(),
+            },
+        };
+        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).map_err(|source| {
+            ControlError::Io {
+                path: path.clone(),
+                source,
+            }
+        })?;
+        let metadata = fs::symlink_metadata(&path).map_err(|source| ControlError::Io {
+            path: path.clone(),
+            source,
+        })?;
+        if !metadata.file_type().is_socket()
+            || metadata.dev() != cleanup.identity.device
+            || metadata.ino() != cleanup.identity.inode
+            || metadata.permissions().mode() & 0o777 != 0o600
+        {
+            return Err(ControlError::UnsafePath(path));
+        }
+        let (shutdown, mut receiver) = oneshot::channel();
+        let task = tokio::spawn(async move {
+            let _cleanup = cleanup;
+            loop {
+                tokio::select! {
+                    _ = &mut receiver => return Ok(()),
+                    accepted = listener.accept() => {
+                        let (stream, _) = accepted.map_err(ControlError::Accept)?;
+                        let service = accounts.clone();
+                        tokio::spawn(async move {
+                            let _ = handle(stream, service).await;
+                        });
+                    }
+                }
+            }
+        });
+        Ok(Self { shutdown, task })
+    }
+
+    pub(crate) async fn shutdown(self) -> Result<(), ControlError> {
+        let _ = self.shutdown.send(());
+        self.task.await.map_err(|_| ControlError::Join)??;
+        Ok(())
+    }
+}
+
+pub(crate) async fn request_invitation(instance_dir: &Path) -> Result<String, ControlError> {
+    let path = instance_dir.join(CONTROL_SOCKET_FILE);
+    let metadata = fs::symlink_metadata(&path).map_err(|source| ControlError::Io {
+        path: path.clone(),
+        source,
+    })?;
+    if metadata.file_type().is_symlink()
+        || !metadata.file_type().is_socket()
+        || metadata.permissions().mode() & 0o077 != 0
+    {
+        return Err(ControlError::UnsafePath(path));
+    }
+    let operation = async {
+        let mut stream = UnixStream::connect(&path).await?;
+        stream.write_all(REQUEST).await?;
+        stream.shutdown().await?;
+        let mut response = Vec::new();
+        stream
+            .take((MAX_RESPONSE_BYTES + 1) as u64)
+            .read_to_end(&mut response)
+            .await?;
+        if response.len() > MAX_RESPONSE_BYTES {
+            return Err(ControlError::InvalidResponse);
+        }
+        let response = String::from_utf8(response).map_err(|_| ControlError::InvalidResponse)?;
+        response
+            .strip_prefix("ok ")
+            .and_then(|value| value.strip_suffix('\n'))
+            .map(str::to_owned)
+            .ok_or(ControlError::InvalidResponse)
+    };
+    tokio::time::timeout(IO_TIMEOUT, operation)
+        .await
+        .map_err(|_| ControlError::Timeout)?
+}
+
+async fn handle(mut stream: UnixStream, accounts: AccountService) -> Result<(), ControlError> {
+    let mut request = Vec::new();
+    tokio::time::timeout(
+        IO_TIMEOUT,
+        (&mut stream)
+            .take((REQUEST.len() + 1) as u64)
+            .read_to_end(&mut request),
+    )
+    .await
+    .map_err(|_| ControlError::Timeout)??;
+    if request != REQUEST {
+        stream.write_all(b"error invalid-request\n").await?;
+        return Ok(());
+    }
+    let invitation = tokio::task::spawn_blocking(move || accounts.issue_invitation())
+        .await
+        .map_err(|_| ControlError::Join)??;
+    stream
+        .write_all(format!("ok {invitation}\n").as_bytes())
+        .await?;
+    stream.shutdown().await?;
+    Ok(())
+}
+
+fn refuse_existing_path(path: &Path) -> Result<(), ControlError> {
+    match fs::symlink_metadata(path) {
+        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() => {
+            Err(ControlError::UnsafePath(path.to_owned()))
+        }
+        Ok(_) => Err(ControlError::SocketExists(path.to_owned())),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+        Err(source) => Err(ControlError::Io {
+            path: path.to_owned(),
+            source,
+        }),
+    }
+}
+
+struct SocketIdentity {
+    device: u64,
+    inode: u64,
+}
+
+struct SocketCleanup {
+    path: PathBuf,
+    identity: SocketIdentity,
+}
+
+impl Drop for SocketCleanup {
+    fn drop(&mut self) {
+        let Ok(metadata) = fs::symlink_metadata(&self.path) else {
+            return;
+        };
+        if metadata.file_type().is_socket()
+            && metadata.dev() == self.identity.device
+            && metadata.ino() == self.identity.inode
+        {
+            let _ = fs::remove_file(&self.path);
+        }
+    }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum ControlError {
+    #[error("control socket path is unsafe: {0}")]
+    UnsafePath(PathBuf),
+    #[error("control socket already exists: {0}")]
+    SocketExists(PathBuf),
+    #[error("control socket error for {path}: {source}")]
+    Io {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("control socket accept failed: {0}")]
+    Accept(std::io::Error),
+    #[error("control socket I/O failed: {0}")]
+    ProtocolIo(#[from] std::io::Error),
+    #[error("control request timed out")]
+    Timeout,
+    #[error("control response is invalid")]
+    InvalidResponse,
+    #[error("control task failed")]
+    Join,
+    #[error(transparent)]
+    Account(#[from] AccountError),
+}
+
+#[cfg(test)]
+mod tests {
+    use std::os::unix::fs::symlink;
+
+    use tempfile::TempDir;
+
+    use crate::store::Store;
+
+    use super::*;
+
+    #[tokio::test]
+    async fn creates_a_private_socket_and_removes_it_after_shutdown() {
+        let directory = TempDir::new().expect("create a control directory");
+        let database = directory.path().join("tit.sqlite3");
+        Store::open(&database).expect("create the database");
+        let server =
+            RunningControlServer::start(directory.path(), AccountService::new(database.clone()))
+                .expect("start the control server");
+        let path = directory.path().join(CONTROL_SOCKET_FILE);
+        assert_eq!(
+            fs::symlink_metadata(&path)
+                .expect("inspect the socket")
+                .permissions()
+                .mode()
+                & 0o777,
+            0o600
+        );
+        let invitation = request_invitation(directory.path())
+            .await
+            .expect("request an invitation");
+        assert!(invitation.starts_with("tit-invite-v1:"));
+        server.shutdown().await.expect("stop the control server");
+        assert!(!path.exists());
+
+        let _socket = std::os::unix::net::UnixListener::bind(&path)
+            .expect("create an existing control socket");
+        assert!(matches!(
+            RunningControlServer::start(directory.path(), AccountService::new(database)),
+            Err(ControlError::SocketExists(candidate)) if candidate == path
+        ));
+    }
+
+    #[test]
+    fn refuses_file_and_symlink_replacements() {
+        let directory = TempDir::new().expect("create a control directory");
+        let path = directory.path().join(CONTROL_SOCKET_FILE);
+        fs::write(&path, b"replacement").expect("write a replacement");
+        assert!(matches!(
+            RunningControlServer::start(
+                directory.path(),
+                AccountService::new(directory.path().join("tit.sqlite3"))
+            ),
+            Err(ControlError::UnsafePath(candidate)) if candidate == path
+        ));
+        fs::remove_file(&path).expect("remove the replacement");
+        symlink(directory.path().join("target"), &path).expect("create a replacement link");
+        assert!(matches!(
+            RunningControlServer::start(
+                directory.path(),
+                AccountService::new(directory.path().join("tit.sqlite3"))
+            ),
+            Err(ControlError::UnsafePath(candidate)) if candidate == path
+        ));
+    }
+}
diff --git a/src/http/mod.rs b/src/http/mod.rs
index f51553ff5475378da7873b2d3072358024b74a60..86a77cb649a280e6d9f9dbd9d413527d22b9aea5 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -6,9 +6,9 @@
 
 use askama::Template;
 use axum::Router;
-use axum::body::{Body, HttpBody};
-use axum::extract::{Extension, RawQuery, Request};
-use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header};
+use axum::body::{Body, Bytes, HttpBody};
+use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, RawQuery, Request, State};
+use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
 use axum::middleware::{self, Next};
 use axum::response::Response;
 use axum::routing::get;
@@ -17,8 +17,10 @@
 use tokio::sync::{Semaphore, oneshot};
 use tokio::task::JoinHandle;
 
+use crate::account::{AccountError, AccountService};
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
+use crate::store::StoreError;
 
 use self::public::PublicWeb;
 
@@ -30,7 +32,12 @@
 #[derive(Clone)]
 struct WebState {
     public: Option<PublicWeb>,
+    accounts: Option<AccountService>,
+    jobs: Arc<Semaphore>,
+    key_reloader: Option<AccountKeyReloader>,
 }
+
+type AccountKeyReloader = Arc<dyn Fn(&AccountService) -> Result<(), AccountError> + Send + Sync>;
 
 #[derive(Clone, Debug)]
 pub(crate) struct PublicWebConfig {
@@ -47,18 +54,48 @@
 
 impl RunningWebServer {
     pub(crate) async fn start(address: SocketAddr) -> Result<Self, WebError> {
-        Self::start_with_state(address, WebState { public: None }).await
+        Self::start_with_state(
+            address,
+            WebState {
+                public: None,
+                accounts: None,
+                jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
+                key_reloader: None,
+            },
+        )
+        .await
     }
 
     pub(crate) async fn start_public(
         address: SocketAddr,
         config: PublicWebConfig,
     ) -> Result<Self, WebError> {
-        let public = PublicWeb::open(config, Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)))?;
+        Self::start_public_inner(address, config, None).await
+    }
+
+    pub(crate) async fn start_public_with_key_reload(
+        address: SocketAddr,
+        config: PublicWebConfig,
+        key_reloader: AccountKeyReloader,
+    ) -> Result<Self, WebError> {
+        Self::start_public_inner(address, config, Some(key_reloader)).await
+    }
+
+    async fn start_public_inner(
+        address: SocketAddr,
+        config: PublicWebConfig,
+        key_reloader: Option<AccountKeyReloader>,
+    ) -> Result<Self, WebError> {
+        let jobs = Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS));
+        let accounts = AccountService::new(config.instance_dir.join(crate::store::DATABASE_FILE));
+        let public = PublicWeb::open(config, Arc::clone(&jobs))?;
         Self::start_with_state(
             address,
             WebState {
                 public: Some(public),
+                accounts: Some(accounts),
+                jobs,
+                key_reloader,
             },
         )
         .await
@@ -94,13 +131,30 @@
 }
 
 pub(crate) fn router() -> Router {
-    router_with_state(WebState { public: None })
+    router_with_state(WebState {
+        public: None,
+        accounts: None,
+        jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
+        key_reloader: None,
+    })
 }
 
 fn router_with_state(state: WebState) -> Router {
     Router::new()
         .route("/", get(home))
         .route("/go", get(go_to_repository))
+        .route(
+            "/signup",
+            get(signup_form)
+                .post(signup)
+                .layer(DefaultBodyLimit::max(20 * 1024)),
+        )
+        .route(
+            "/recover",
+            get(recovery_form)
+                .post(recover)
+                .layer(DefaultBodyLimit::max(20 * 1024)),
+        )
         .route("/assets/style.css", get(style))
         .merge(public::routes())
         .fallback(not_found)
@@ -137,6 +191,210 @@
     }
 }
 
+async fn signup_form(Extension(request_id): Extension<RequestId>) -> Response {
+    render_account_form(&request_id.0, AccountFormKind::Signup, "", "")
+}
+
+async fn recovery_form(Extension(request_id): Extension<RequestId>) -> Response {
+    render_account_form(&request_id.0, AccountFormKind::Recovery, "", "")
+}
+
+async fn signup(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_account_form(&headers, &body, "invitation") {
+        Ok(fields) => fields,
+        Err(()) => {
+            return render_account_error(
+                &request_id.0,
+                AccountFormKind::Signup,
+                "",
+                "The signup request is not valid.",
+                StatusCode::BAD_REQUEST,
+            );
+        }
+    };
+    let username = fields.username.clone();
+    let result = account_job(state, move |accounts| {
+        accounts.signup(&fields.credential, &fields.username, &fields.public_key)
+    })
+    .await;
+    account_result(result, &request_id.0, AccountFormKind::Signup, &username)
+}
+
+async fn recover(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_account_form(&headers, &body, "recovery") {
+        Ok(fields) => fields,
+        Err(()) => {
+            return render_account_error(
+                &request_id.0,
+                AccountFormKind::Recovery,
+                "",
+                "The recovery request is not valid.",
+                StatusCode::BAD_REQUEST,
+            );
+        }
+    };
+    let username = fields.username.clone();
+    let result = account_job(state, move |accounts| {
+        accounts.recover(&fields.username, &fields.credential, &fields.public_key)
+    })
+    .await;
+    account_result(result, &request_id.0, AccountFormKind::Recovery, &username)
+}
+
+async fn account_job<T: Send + 'static>(
+    state: WebState,
+    operation: impl FnOnce(AccountService) -> Result<T, AccountError> + Send + 'static,
+) -> Result<T, AccountError> {
+    let accounts = state.accounts.ok_or_else(|| {
+        AccountError::Store(StoreError::Integrity(
+            "account service is unavailable".to_owned(),
+        ))
+    })?;
+    let permit = state.jobs.acquire_owned().await.map_err(|_| {
+        AccountError::Store(StoreError::Integrity(
+            "account worker pool is unavailable".to_owned(),
+        ))
+    })?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        let result = operation(accounts.clone());
+        if result.is_ok()
+            && let Some(reload) = state.key_reloader
+        {
+            reload(&accounts)?;
+        }
+        result
+    })
+    .await
+    .map_err(|_| AccountError::Store(StoreError::Integrity("account worker failed".to_owned())))?
+}
+
+fn account_result(
+    result: Result<String, AccountError>,
+    request_id: &str,
+    kind: AccountFormKind,
+    username: &str,
+) -> Response {
+    match result {
+        Ok(recovery) => render(
+            StatusCode::OK,
+            &RecoveryCodeTemplate {
+                request_id,
+                recovery: &recovery,
+            },
+        ),
+        Err(AccountError::Store(StoreError::UsernameUnavailable(_))) => render_account_error(
+            request_id,
+            kind,
+            username,
+            "That username is not available.",
+            StatusCode::CONFLICT,
+        ),
+        Err(
+            AccountError::Auth(_)
+            | AccountError::InvalidSecret
+            | AccountError::Store(StoreError::InvalidInvitation)
+            | AccountError::Store(StoreError::InvalidRecovery),
+        ) => render_account_error(
+            request_id,
+            kind,
+            username,
+            "The credential, username, or SSH public key is not valid.",
+            StatusCode::BAD_REQUEST,
+        ),
+        Err(_) => render_error(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            request_id,
+            "Internal server error",
+            "The account request could not be completed.",
+        ),
+    }
+}
+
+fn parse_account_form(
+    headers: &HeaderMap,
+    body: &[u8],
+    credential_name: &str,
+) -> Result<AccountForm, ()> {
+    if headers
+        .get(header::CONTENT_TYPE)
+        .and_then(|value| value.to_str().ok())
+        .and_then(|value| value.split(';').next())
+        != Some("application/x-www-form-urlencoded")
+        || !valid_percent_encoding(body)
+    {
+        return Err(());
+    }
+    let mut username = None;
+    let mut credential = None;
+    let mut public_key = None;
+    for (name, value) in url::form_urlencoded::parse(body) {
+        match name.as_ref() {
+            "username" if username.is_none() => username = Some(value.into_owned()),
+            name if name == credential_name && credential.is_none() => {
+                credential = Some(value.into_owned());
+            }
+            "public-key" if public_key.is_none() => public_key = Some(value.into_owned()),
+            _ => return Err(()),
+        }
+    }
+    Ok(AccountForm {
+        username: username.ok_or(())?,
+        credential: credential.ok_or(())?,
+        public_key: public_key.ok_or(())?,
+    })
+}
+
+fn render_account_form(
+    request_id: &str,
+    kind: AccountFormKind,
+    username: &str,
+    error: &str,
+) -> Response {
+    render_account_error(request_id, kind, username, error, StatusCode::OK)
+}
+
+fn render_account_error(
+    request_id: &str,
+    kind: AccountFormKind,
+    username: &str,
+    error: &str,
+    status: StatusCode,
+) -> Response {
+    render(
+        status,
+        &AccountFormTemplate {
+            request_id,
+            username,
+            error,
+            has_error: !error.is_empty(),
+            recovery: matches!(kind, AccountFormKind::Recovery),
+        },
+    )
+}
+
+struct AccountForm {
+    username: String,
+    credential: String,
+    public_key: String,
+}
+
+#[derive(Clone, Copy)]
+enum AccountFormKind {
+    Signup,
+    Recovery,
+}
+
 async fn style() -> Response {
     Response::builder()
         .status(StatusCode::OK)
@@ -155,16 +413,25 @@
     )
 }
 
-async fn method_not_allowed(Extension(request_id): Extension<RequestId>) -> Response {
+async fn method_not_allowed(
+    Extension(request_id): Extension<RequestId>,
+    OriginalUri(uri): OriginalUri,
+) -> Response {
     let mut response = render_error(
         StatusCode::METHOD_NOT_ALLOWED,
         &request_id.0,
         "Method not allowed",
         "This page does not accept the request method.",
     );
-    response
-        .headers_mut()
-        .insert(header::ALLOW, HeaderValue::from_static("GET, HEAD"));
+    let allow = match uri.path() {
+        "/signup" | "/recover" => "GET, HEAD, POST",
+        path if path.ends_with("/git-upload-pack") => "POST",
+        _ => "GET, HEAD",
+    };
+    response.headers_mut().insert(
+        header::ALLOW,
+        HeaderValue::from_str(allow).expect("the method list is a header value"),
+    );
     response
 }
 
@@ -331,6 +598,23 @@
     request_id: &'a str,
     status: &'a str,
     message: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "account.html")]
+struct AccountFormTemplate<'a> {
+    request_id: &'a str,
+    username: &'a str,
+    error: &'a str,
+    has_error: bool,
+    recovery: bool,
+}
+
+#[derive(Template)]
+#[template(path = "recovery-code.html")]
+struct RecoveryCodeTemplate<'a> {
+    request_id: &'a str,
+    recovery: &'a str,
 }
 
 #[derive(Debug, Error)]
diff --git a/src/main.rs b/src/main.rs
index 45180fd5587342460f1df28d15699afe14016c0e..0906c51d68c55237e5be86bfdc30443c57e78753 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
+mod account;
 mod admin;
 #[allow(
     dead_code,
@@ -7,6 +8,7 @@
 mod bootstrap;
 mod cli;
 mod config;
+mod control;
 mod domain;
 mod feed;
 #[allow(dead_code, reason = "the server uses only part of the shared Git API")]
@@ -25,7 +27,9 @@
 
 use clap::Parser;
 
-use crate::cli::{AdminCommand, Cli, Command, ObjectFormat, RepositoryCommand, SetupCommand};
+use crate::cli::{
+    AccountCommand, AdminCommand, Cli, Command, ObjectFormat, RepositoryCommand, SetupCommand,
+};
 
 #[tokio::main]
 async fn main() -> ExitCode {
@@ -48,6 +52,21 @@
                     ExitCode::FAILURE
                 }
             },
+            Some(Command::InviteCode) => {
+                match control::request_invitation(&config.instance_dir).await {
+                    Ok(code) => match writeln!(io::stdout().lock(), "Signup code: {code}") {
+                        Ok(()) => ExitCode::SUCCESS,
+                        Err(error) => {
+                            eprintln!("tit: cannot write the signup code: {error}");
+                            ExitCode::FAILURE
+                        }
+                    },
+                    Err(error) => {
+                        eprintln!("tit: {error}");
+                        ExitCode::FAILURE
+                    }
+                }
+            }
             Some(Command::Doctor) => match store::doctor(&config.instance_dir) {
                 Ok(()) => ExitCode::SUCCESS,
                 Err(error) => {
@@ -84,7 +103,57 @@
             Some(Command::Admin {
                 command: AdminCommand::Repository { command },
             }) => run_repository_command(&config.instance_dir, command),
+            Some(Command::Admin {
+                command: AdminCommand::Account { command },
+            }) => run_account_command(&config.instance_dir, command),
         },
+        Err(error) => {
+            eprintln!("tit: {error}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+fn run_account_command(instance_dir: &std::path::Path, command: AccountCommand) -> ExitCode {
+    let result = (|| -> Result<Option<String>, Box<dyn std::error::Error>> {
+        let _lock = instance::InstanceLock::acquire(instance_dir)?;
+        let database = instance::prepare_database(instance_dir)?;
+        let accounts = account::AccountService::new(database);
+        match command {
+            AccountCommand::KeyAdd {
+                username,
+                label,
+                ssh_public_key,
+            } => {
+                let fingerprint = accounts.add_key(&username, &label, &ssh_public_key)?;
+                Ok(Some(fingerprint))
+            }
+            AccountCommand::KeyRevoke {
+                username,
+                fingerprint,
+            } => {
+                accounts.revoke_key(&username, &fingerprint)?;
+                Ok(None)
+            }
+            AccountCommand::Suspend { username } => {
+                accounts.suspend(&username, true)?;
+                Ok(None)
+            }
+            AccountCommand::Resume { username } => {
+                accounts.suspend(&username, false)?;
+                Ok(None)
+            }
+        }
+    })();
+    match result {
+        Ok(Some(fingerprint)) => match writeln!(io::stdout().lock(), "fingerprint={fingerprint}") {
+            Ok(()) => ExitCode::SUCCESS,
+            Err(error) => {
+                eprintln!("tit: cannot write account information: {error}");
+                ExitCode::FAILURE
+            }
+        },
+        Ok(None) => ExitCode::SUCCESS,
         Err(error) => {
             eprintln!("tit: {error}");
             ExitCode::FAILURE
diff --git a/src/serve.rs b/src/serve.rs
index 34a8ef64f4b7ecefd5ba03a57c28369cda4efac6..5103d1c4a61e8ee76bc8c03eb31ca4ba29907136 100644
--- a/src/serve.rs
+++ b/src/serve.rs
@@ -7,12 +7,14 @@
 use ssh_key::{Algorithm, LineEnding, PrivateKey};
 use thiserror::Error;
 
+use crate::account::AccountService;
 use crate::auth::{AuthError, SshPublicKey};
 use crate::config::{Config, ConfigError};
+use crate::control::{ControlError, RunningControlServer};
 use crate::git::transport::{GitRepositories, RepositoryPathError};
 use crate::http::{PublicWebConfig, RunningWebServer, WebError};
 use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
-use crate::ssh::{RunningSshServer, SshServerError};
+use crate::ssh::{AuthorizedSshKeys, RunningSshServer, SshServerError};
 use crate::store::{Store, StoreError};
 
 pub(crate) async fn run(config: &Config) -> Result<(), ServeError> {
@@ -32,20 +34,37 @@
     let git = GitRepositories::new_managed_public(&repository_root, repositories)?;
     drop(store);
 
+    let accounts = AccountService::new(database);
+    let control = RunningControlServer::start(&config.instance_dir, accounts.clone())?;
+    let authorized_keys = AuthorizedSshKeys::new(&keys);
+
     let (http_clone_base, ssh_clone_base) = clone_bases(config)?;
     let host_key = load_or_create_host_key(&config.instance_dir)?;
-    let web = RunningWebServer::start_public(
+    let reload_keys = {
+        let authorized_keys = authorized_keys.clone();
+        std::sync::Arc::new(move |accounts: &AccountService| {
+            let active = Store::open(accounts.database())?
+                .active_ssh_public_keys()?
+                .into_iter()
+                .map(|key| SshPublicKey::parse(&key))
+                .collect::<Result<Vec<_>, _>>()?;
+            authorized_keys.replace(&active);
+            Ok(())
+        })
+    };
+    let web = RunningWebServer::start_public_with_key_reload(
         config.http_listen,
         PublicWebConfig {
             instance_dir: config.instance_dir.clone(),
             http_clone_base,
             ssh_clone_base,
         },
+        reload_keys,
     )
     .await?;
-    let ssh = match RunningSshServer::start_with_git_and_host_key(
+    let ssh = match RunningSshServer::start_with_dynamic_keys(
         config.ssh_listen,
-        &keys,
+        authorized_keys,
         git,
         host_key,
     )
@@ -53,15 +72,18 @@
     {
         Ok(ssh) => ssh,
         Err(error) => {
+            control.shutdown().await?;
             web.shutdown().await?;
             return Err(error.into());
         }
     };
 
     let signal = shutdown_signal().await;
-    let (ssh_result, web_result) = tokio::join!(ssh.shutdown(), web.shutdown());
+    let (ssh_result, web_result, control_result) =
+        tokio::join!(ssh.shutdown(), web.shutdown(), control.shutdown());
     ssh_result?;
     web_result?;
+    control_result?;
     signal.map_err(ServeError::Signal)
 }
 
@@ -191,6 +213,8 @@
     Web(#[from] WebError),
     #[error(transparent)]
     Ssh(#[from] SshServerError),
+    #[error(transparent)]
+    Control(#[from] ControlError),
     #[error("cannot wait for a shutdown signal: {0}")]
     Signal(std::io::Error),
     #[error("cannot read or write SSH host key {path}: {source}")]
diff --git a/src/ssh.rs b/src/ssh.rs
index 07a97aa26bc90660f94f41e6b23638436dd57523..56285496ae13b89a313fff6e92bb6bd3043859be 100644
--- a/src/ssh.rs
+++ b/src/ssh.rs
@@ -1,8 +1,8 @@
 use std::borrow::Cow;
 use std::collections::{HashMap, HashSet};
 use std::net::SocketAddr;
-use std::sync::Arc;
 use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{Arc, RwLock};
 use std::time::Duration;
 
 use rand::rng;
@@ -32,6 +32,39 @@
     audit: Arc<RequestAudit>,
 }
 
+#[derive(Clone)]
+pub(crate) struct AuthorizedSshKeys {
+    keys: Arc<RwLock<HashMap<PublicKey, String>>>,
+}
+
+impl AuthorizedSshKeys {
+    pub(crate) fn new(keys: &[SshPublicKey]) -> Self {
+        Self {
+            keys: Arc::new(RwLock::new(key_map(keys))),
+        }
+    }
+
+    pub(crate) fn replace(&self, keys: &[SshPublicKey]) {
+        if let Ok(mut current) = self.keys.write() {
+            *current = key_map(keys);
+        }
+    }
+
+    fn actor(&self, key: &PublicKey) -> Option<String> {
+        self.keys.read().ok()?.get(key).cloned()
+    }
+
+    fn contains(&self, key: &PublicKey) -> bool {
+        self.keys.read().is_ok_and(|keys| keys.contains_key(key))
+    }
+}
+
+fn key_map(keys: &[SshPublicKey]) -> HashMap<PublicKey, String> {
+    keys.iter()
+        .map(|key| (key.public_key().clone(), key.fingerprint().to_owned()))
+        .collect()
+}
+
 impl RunningSshServer {
     pub(crate) async fn start(
         address: SocketAddr,
@@ -57,6 +90,16 @@
         host_key: PrivateKey,
     ) -> Result<Self, SshServerError> {
         Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
+    }
+
+    pub(crate) async fn start_with_dynamic_keys(
+        address: SocketAddr,
+        authorized_keys: AuthorizedSshKeys,
+        repositories: GitRepositories,
+        host_key: PrivateKey,
+    ) -> Result<Self, SshServerError> {
+        Self::start_inner_with_keys(address, authorized_keys, &[], Some(repositories), host_key)
+            .await
     }
 
     pub(crate) async fn start_with_git_writes(
@@ -93,6 +136,23 @@
         repositories: Option<GitRepositories>,
         host_key: PrivateKey,
     ) -> Result<Self, SshServerError> {
+        Self::start_inner_with_keys(
+            address,
+            AuthorizedSshKeys::new(authorized_keys),
+            writable_keys,
+            repositories,
+            host_key,
+        )
+        .await
+    }
+
+    async fn start_inner_with_keys(
+        address: SocketAddr,
+        authorized_keys: AuthorizedSshKeys,
+        writable_keys: &[SshPublicKey],
+        repositories: Option<GitRepositories>,
+        host_key: PrivateKey,
+    ) -> Result<Self, SshServerError> {
         let listener = TcpListener::bind(address).await?;
         let address = listener.local_addr()?;
         let mut methods = MethodSet::empty();
@@ -116,12 +176,6 @@
             nodelay: true,
             ..Default::default()
         });
-        let authorized_keys: Arc<HashMap<PublicKey, String>> = Arc::new(
-            authorized_keys
-                .iter()
-                .map(|key| (key.public_key().clone(), key.fingerprint().to_owned()))
-                .collect(),
-        );
         let writable_keys = Arc::new(
             writable_keys
                 .iter()
@@ -182,7 +236,7 @@
 
 #[derive(Clone)]
 struct SshServer {
-    authorized_keys: Arc<HashMap<PublicKey, String>>,
+    authorized_keys: AuthorizedSshKeys,
     writable_keys: Arc<HashSet<PublicKey>>,
     audit: Arc<RequestAudit>,
     repositories: Option<Arc<GitRepositories>>,
@@ -193,7 +247,7 @@
 
     fn new_client(&mut self, _peer_address: Option<SocketAddr>) -> Self::Handler {
         SshSession {
-            authorized_keys: Arc::clone(&self.authorized_keys),
+            authorized_keys: self.authorized_keys.clone(),
             writable_keys: Arc::clone(&self.writable_keys),
             audit: Arc::clone(&self.audit),
             repositories: self.repositories.clone(),
@@ -206,7 +260,7 @@
 }
 
 struct SshSession {
-    authorized_keys: Arc<HashMap<PublicKey, String>>,
+    authorized_keys: AuthorizedSshKeys,
     writable_keys: Arc<HashSet<PublicKey>>,
     audit: Arc<RequestAudit>,
     repositories: Option<Arc<GitRepositories>>,
@@ -250,8 +304,8 @@
         _user: &str,
         public_key: &PublicKey,
     ) -> Result<Auth, Self::Error> {
-        if let Some(actor) = self.authorized_keys.get(public_key) {
-            self.authenticated_actor = Some(actor.clone());
+        if let Some(actor) = self.authorized_keys.actor(public_key) {
+            self.authenticated_actor = Some(actor);
             self.authenticated_writer = self.writable_keys.contains(public_key);
             Ok(Auth::Accept)
         } else {
@@ -603,7 +657,7 @@
 
 impl SshSession {
     fn authorize(&self, public_key: &PublicKey) -> Auth {
-        if self.authorized_keys.contains_key(public_key) {
+        if self.authorized_keys.contains(public_key) {
             Auth::Accept
         } else {
             Auth::reject()
diff --git a/src/store/migrations/007_account_lifecycle.sql b/src/store/migrations/007_account_lifecycle.sql
new file mode 100644
index 0000000000000000000000000000000000000000..04f9f4b76b130ab446eacd0457e8533c4e77954d
--- /dev/null
+++ b/src/store/migrations/007_account_lifecycle.sql
@@ -1,0 +1,22 @@
+CREATE TABLE signup_invitation (
+    id INTEGER PRIMARY KEY,
+    code_hash BLOB NOT NULL UNIQUE CHECK (length(code_hash) = 32),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    expires_at INTEGER NOT NULL CHECK (expires_at > created_at),
+    consumed_at INTEGER CHECK (consumed_at IS NULL OR consumed_at >= created_at)
+) STRICT;
+
+CREATE INDEX signup_invitation_active
+ON signup_invitation (expires_at, consumed_at);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN label TEXT NOT NULL DEFAULT 'initial'
+    CHECK (length(label) BETWEEN 1 AND 80);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN last_used_at INTEGER
+    CHECK (last_used_at IS NULL OR last_used_at >= created_at);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN revoked_at INTEGER
+    CHECK (revoked_at IS NULL OR revoked_at >= created_at);
diff --git a/src/store/mod.rs b/src/store/mod.rs
index 596b011e44a4710cb21d45b9ae4d1284906cf037..adeecabcd438b3cc21453c89884b0e0ac6ab1203 100644
--- a/src/store/mod.rs
+++ b/src/store/mod.rs
@@ -9,7 +9,7 @@
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 6;
+const SCHEMA_VERSION: i64 = 7;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -19,15 +19,20 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 6] = [
+const MIGRATIONS: [&str; 7] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
     include_str!("migrations/004_identity.sql"),
     include_str!("migrations/005_repository.sql"),
     include_str!("migrations/006_repository_events.sql"),
+    include_str!("migrations/007_account_lifecycle.sql"),
 ];
 
+#[allow(
+    dead_code,
+    reason = "integration tests compile storage without every account operation"
+)]
 #[derive(Debug, Error)]
 pub(crate) enum StoreError {
     #[error("SQLite error: {0}")]
@@ -58,6 +63,18 @@
     AlreadyInitialized,
     #[error("account does not exist or is not active: {0}")]
     AccountNotFound(String),
+    #[error("username is not available: {0}")]
+    UsernameUnavailable(String),
+    #[error("signup invitation is invalid, expired, or already used")]
+    InvalidInvitation,
+    #[error("recovery credential is invalid")]
+    InvalidRecovery,
+    #[error("SSH public key already exists")]
+    KeyExists,
+    #[error("active SSH public key does not exist")]
+    KeyNotFound,
+    #[error("an account must have at least one active SSH public key")]
+    LastKey,
     #[error("repository does not exist: {0}/{1}")]
     RepositoryNotFound(String, String),
     #[error("repository already exists: {0}/{1}")]
@@ -373,6 +390,199 @@
         Ok(())
     }
 
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn create_signup_invitation(
+        &self,
+        code_hash: &[u8; 32],
+        created_at: i64,
+        expires_at: i64,
+    ) -> Result<(), StoreError> {
+        self.connection.execute(
+            "INSERT INTO signup_invitation (code_hash, created_at, expires_at, consumed_at)
+             VALUES (?1, ?2, ?3, NULL)",
+            rusqlite::params![code_hash, created_at, expires_at],
+        )?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn create_account_with_invitation(
+        &mut self,
+        account: &InvitedAccount<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let consumed = transaction.execute(
+            "UPDATE signup_invitation SET consumed_at = ?2
+             WHERE code_hash = ?1 AND consumed_at IS NULL AND expires_at >= ?2",
+            rusqlite::params![account.invitation_hash, account.created_at],
+        )?;
+        if consumed != 1 {
+            return Err(StoreError::InvalidInvitation);
+        }
+        let inserted = transaction.execute(
+            "INSERT INTO account (username, is_administrator, state, created_at)
+             VALUES (?1, 0, 'active', ?2)",
+            rusqlite::params![account.username, account.created_at],
+        );
+        let account_id = match inserted {
+            Ok(1) => transaction.last_insert_rowid(),
+            Err(error) if is_unique_constraint(&error) => {
+                return Err(StoreError::UsernameUnavailable(account.username.to_owned()));
+            }
+            Err(error) => return Err(error.into()),
+            Ok(_) => unreachable!("an INSERT changes one row"),
+        };
+        insert_ssh_key(&transaction, account_id, &account.key, account.created_at)?;
+        transaction.execute(
+            "INSERT INTO recovery_credential (account_id, credential_hash, created_at)
+             VALUES (?1, ?2, ?3)",
+            rusqlite::params![account_id, account.recovery_hash, account.created_at],
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn recover_account(
+        &mut self,
+        recovery: &AccountRecovery<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id = transaction
+            .query_row(
+                "SELECT account.id FROM account
+                 JOIN recovery_credential ON recovery_credential.account_id = account.id
+                 WHERE account.username = ?1 AND account.state = 'active'
+                   AND recovery_credential.credential_hash = ?2",
+                rusqlite::params![recovery.username, recovery.old_recovery_hash],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?
+            .ok_or(StoreError::InvalidRecovery)?;
+        transaction.execute(
+            "UPDATE ssh_public_key SET revoked_at = ?2
+             WHERE account_id = ?1 AND revoked_at IS NULL",
+            rusqlite::params![account_id, recovery.created_at],
+        )?;
+        let existing = transaction
+            .query_row(
+                "SELECT account_id FROM ssh_public_key WHERE fingerprint = ?1",
+                [recovery.key.fingerprint],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?;
+        match existing {
+            Some(owner) if owner == account_id => {
+                transaction.execute(
+                    "UPDATE ssh_public_key
+                     SET canonical_key = ?2, label = ?3, revoked_at = NULL
+                     WHERE account_id = ?1 AND fingerprint = ?4",
+                    rusqlite::params![
+                        account_id,
+                        recovery.key.canonical_key,
+                        recovery.key.label,
+                        recovery.key.fingerprint,
+                    ],
+                )?;
+            }
+            Some(_) => return Err(StoreError::KeyExists),
+            None => insert_ssh_key(&transaction, account_id, &recovery.key, recovery.created_at)?,
+        }
+        transaction.execute(
+            "UPDATE recovery_credential
+             SET credential_hash = ?2, created_at = ?3 WHERE account_id = ?1",
+            rusqlite::params![account_id, recovery.new_recovery_hash, recovery.created_at],
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn add_account_key(
+        &mut self,
+        username: &str,
+        key: &NewSshKey<'_>,
+        created_at: i64,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id = active_account_id(&transaction, username)?;
+        insert_ssh_key(&transaction, account_id, key, created_at)?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn revoke_account_key(
+        &mut self,
+        username: &str,
+        fingerprint: &str,
+        revoked_at: i64,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id = active_account_id(&transaction, username)?;
+        let active: i64 = transaction.query_row(
+            "SELECT count(*) FROM ssh_public_key WHERE account_id = ?1 AND revoked_at IS NULL",
+            [account_id],
+            |row| row.get(0),
+        )?;
+        if active <= 1 {
+            return Err(StoreError::LastKey);
+        }
+        let changed = transaction.execute(
+            "UPDATE ssh_public_key SET revoked_at = ?3
+             WHERE account_id = ?1 AND fingerprint = ?2 AND revoked_at IS NULL",
+            rusqlite::params![account_id, fingerprint, revoked_at],
+        )?;
+        if changed != 1 {
+            return Err(StoreError::KeyNotFound);
+        }
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn suspend_account(
+        &self,
+        username: &str,
+        suspended: bool,
+    ) -> Result<(), StoreError> {
+        let state = if suspended { "suspended" } else { "active" };
+        let changed = self.connection.execute(
+            "UPDATE account SET state = ?2 WHERE username = ?1",
+            rusqlite::params![username, state],
+        )?;
+        if changed != 1 {
+            return Err(StoreError::AccountNotFound(username.to_owned()));
+        }
+        Ok(())
+    }
+
     pub(crate) fn create_repository(
         &mut self,
         repository: &NewRepository<'_>,
@@ -574,7 +784,7 @@
             "SELECT ssh_public_key.canonical_key
              FROM ssh_public_key
              JOIN account ON account.id = ssh_public_key.account_id
-             WHERE account.state = 'active'
+             WHERE account.state = 'active' AND ssh_public_key.revoked_at IS NULL
              ORDER BY ssh_public_key.id",
         )?;
         statement
@@ -645,6 +855,40 @@
     pub(crate) canonical_key: &'a str,
     pub(crate) fingerprint: &'a str,
     pub(crate) recovery_hash: &'a [u8; 32],
+    pub(crate) created_at: i64,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without accounts"
+)]
+pub(crate) struct NewSshKey<'a> {
+    pub(crate) canonical_key: &'a str,
+    pub(crate) fingerprint: &'a str,
+    pub(crate) label: &'a str,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without accounts"
+)]
+pub(crate) struct InvitedAccount<'a> {
+    pub(crate) invitation_hash: &'a [u8; 32],
+    pub(crate) username: &'a str,
+    pub(crate) key: NewSshKey<'a>,
+    pub(crate) recovery_hash: &'a [u8; 32],
+    pub(crate) created_at: i64,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without accounts"
+)]
+pub(crate) struct AccountRecovery<'a> {
+    pub(crate) username: &'a str,
+    pub(crate) old_recovery_hash: &'a [u8; 32],
+    pub(crate) key: NewSshKey<'a>,
+    pub(crate) new_recovery_hash: &'a [u8; 32],
     pub(crate) created_at: i64,
 }
 
@@ -727,6 +971,35 @@
     pub(crate) quarantine_path: String,
     pub(crate) state: String,
     pub(crate) pack_name: Option<String>,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without accounts"
+)]
+fn insert_ssh_key(
+    transaction: &rusqlite::Transaction<'_>,
+    account_id: i64,
+    key: &NewSshKey<'_>,
+    created_at: i64,
+) -> Result<(), StoreError> {
+    match transaction.execute(
+        "INSERT INTO ssh_public_key
+         (account_id, canonical_key, fingerprint, created_at, label, last_used_at, revoked_at)
+         VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL)",
+        rusqlite::params![
+            account_id,
+            key.canonical_key,
+            key.fingerprint,
+            created_at,
+            key.label,
+        ],
+    ) {
+        Ok(1) => Ok(()),
+        Err(error) if is_unique_constraint(&error) => Err(StoreError::KeyExists),
+        Err(error) => Err(error.into()),
+        Ok(_) => unreachable!("an INSERT changes one row"),
+    }
 }
 
 fn insert_push_events(
diff --git a/templates/account.html b/templates/account.html
new file mode 100644
index 0000000000000000000000000000000000000000..6066c9882dd5c98e1178b9f536439ac555a9a4d4
--- /dev/null
+++ b/templates/account.html
@@ -1,0 +1,33 @@
+{% extends "base.html" %}
+{% block title %}{% if recovery %}Recover account{% else %}Create account{% endif %} · tit{% endblock %}
+{% block content %}
+  <h1>{% if recovery %}Recover account{% else %}Create account{% endif %}</h1>
+{% if recovery %}
+  <p>Use your recovery code to replace all SSH keys. You will get a new recovery code.</p>
+{% else %}
+  <p>You must have a valid signup code and an SSH public key.</p>
+{% endif %}
+{% if has_error %}
+  <p class="error" role="alert">{{ error }}</p>
+{% endif %}
+  <form action="{% if recovery %}/recover{% else %}/signup{% endif %}" method="post">
+    <div class="field">
+      <label for="username">Username</label>
+      <input id="username" name="username" value="{{ username }}" required autocomplete="username" autocapitalize="none" spellcheck="false">
+    </div>
+    <div class="field">
+{% if recovery %}
+      <label for="credential">Recovery code</label>
+      <input id="credential" name="recovery" type="password" required autocomplete="off">
+{% else %}
+      <label for="credential">Signup code</label>
+      <input id="credential" name="invitation" type="password" required autocomplete="off">
+{% endif %}
+    </div>
+    <div class="field">
+      <label for="public-key">New SSH public key</label>
+      <textarea id="public-key" name="public-key" required rows="5" autocomplete="off" autocapitalize="none" spellcheck="false"></textarea>
+    </div>
+    <button type="submit">{% if recovery %}Recover account{% else %}Create account{% endif %}</button>
+  </form>
+{% endblock %}
diff --git a/templates/base.html b/templates/base.html
index d5831452ce0396a46b6782fe3024afdbce3c136a..fa3cc9823fc02d703869f75990e9163baa689e9d 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">
diff --git a/templates/recovery-code.html b/templates/recovery-code.html
new file mode 100644
index 0000000000000000000000000000000000000000..b9eb1278be38d826aa78cd7ad20aff1c6628a9fe
--- /dev/null
+++ b/templates/recovery-code.html
@@ -1,0 +1,7 @@
+{% extends "base.html" %}
+{% block title %}Recovery code · tit{% endblock %}
+{% block content %}
+  <h1>Save your recovery code</h1>
+  <p>This code is shown one time. Store it offline. It replaces all SSH keys when you use it.</p>
+  <pre><code>{{ recovery }}</code></pre>
+{% endblock %}
diff --git a/tests/account_lifecycle.rs b/tests/account_lifecycle.rs
new file mode 100644
index 0000000000000000000000000000000000000000..de9934ee416df0d8d5ebe6940652c859a0b7b48f
--- /dev/null
+++ b/tests/account_lifecycle.rs
@@ -1,0 +1,197 @@
+#[path = "../src/account.rs"]
+mod account;
+#[allow(
+    dead_code,
+    reason = "the account test uses only SSH public-key parsing"
+)]
+#[path = "../src/auth.rs"]
+mod auth;
+#[allow(
+    dead_code,
+    reason = "the account test does not use every store operation"
+)]
+#[path = "../src/store/mod.rs"]
+mod store;
+
+use rand::rng;
+use rusqlite::OptionalExtension;
+use sha2::{Digest, Sha256};
+use ssh_key::{Algorithm, PrivateKey};
+use tempfile::TempDir;
+
+use account::{AccountError, AccountService};
+use store::{Store, StoreError};
+
+#[test]
+fn invitation_signup_key_and_recovery_lifecycle_is_atomic() {
+    let directory = TempDir::new().expect("create an account directory");
+    let database = directory.path().join("tit.sqlite3");
+    Store::open(&database).expect("create the account database");
+    let accounts = AccountService::new(database.clone());
+    let first_key = public_key();
+    let second_key = public_key();
+    let third_key = public_key();
+
+    let invitation = accounts.issue_invitation().expect("issue an invitation");
+    assert!(invitation.starts_with("tit-invite-v1:"));
+    let stored_invitation: Vec<u8> = Store::open(&database)
+        .expect("open the account database")
+        .connection()
+        .query_row("SELECT code_hash FROM signup_invitation", [], |row| {
+            row.get(0)
+        })
+        .expect("read the invitation hash");
+    assert_eq!(stored_invitation.len(), 32);
+    assert_ne!(stored_invitation, invitation.as_bytes());
+
+    let recovery = accounts
+        .signup(&invitation, "alice", &first_key)
+        .expect("create the account");
+    assert!(recovery.starts_with("tit-recovery-v1:"));
+    assert!(matches!(
+        accounts.signup(&invitation, "bob", &second_key),
+        Err(AccountError::Store(StoreError::InvalidInvitation))
+    ));
+    let stored_recovery: Vec<u8> = Store::open(&database)
+        .expect("open the account database")
+        .connection()
+        .query_row(
+            "SELECT credential_hash FROM recovery_credential",
+            [],
+            |row| row.get(0),
+        )
+        .expect("read the recovery hash");
+    assert_eq!(stored_recovery.len(), 32);
+    assert_ne!(stored_recovery, recovery.as_bytes());
+
+    let second_fingerprint = accounts
+        .add_key("alice", "workstation", &second_key)
+        .expect("add a second key");
+    let first_fingerprint: String = Store::open(&database)
+        .expect("open the account database")
+        .connection()
+        .query_row(
+            "SELECT fingerprint FROM ssh_public_key WHERE label = 'initial'",
+            [],
+            |row| row.get(0),
+        )
+        .expect("read the first fingerprint");
+    accounts
+        .revoke_key("alice", &first_fingerprint)
+        .expect("revoke the first key");
+    assert!(matches!(
+        accounts.revoke_key("alice", &second_fingerprint),
+        Err(AccountError::Store(StoreError::LastKey))
+    ));
+
+    let replacement = accounts
+        .recover("alice", &recovery, &third_key)
+        .expect("recover the account");
+    assert!(matches!(
+        accounts.recover("alice", &recovery, &first_key),
+        Err(AccountError::Store(StoreError::InvalidRecovery))
+    ));
+    accounts
+        .recover("alice", &replacement, &first_key)
+        .expect("use the rotated recovery code");
+    assert_eq!(
+        Store::open(&database)
+            .expect("open the account database")
+            .active_ssh_public_keys()
+            .expect("list active SSH keys"),
+        vec![
+            auth::SshPublicKey::parse(&first_key)
+                .expect("parse the first key")
+                .canonical()
+                .to_owned()
+        ]
+    );
+}
+
+#[test]
+fn failed_signup_preserves_the_invitation_and_username_reservation() {
+    let directory = TempDir::new().expect("create an account directory");
+    let database = directory.path().join("tit.sqlite3");
+    Store::open(&database).expect("create the account database");
+    let accounts = AccountService::new(database.clone());
+    let first = accounts.issue_invitation().expect("issue an invitation");
+    accounts
+        .signup(&first, "alice", &public_key())
+        .expect("create the first account");
+
+    let second = accounts.issue_invitation().expect("issue an invitation");
+    assert!(matches!(
+        accounts.signup(&second, "alice", &public_key()),
+        Err(AccountError::Store(StoreError::UsernameUnavailable(_)))
+    ));
+    accounts
+        .signup(&second, "bob", &public_key())
+        .expect("reuse the invitation after a rolled-back signup");
+    accounts
+        .suspend("alice", true)
+        .expect("suspend the account");
+    assert_eq!(
+        Store::open(&database)
+            .expect("open the account database")
+            .connection()
+            .query_row(
+                "SELECT state FROM account WHERE username = 'alice'",
+                [],
+                |row| row.get::<_, String>(0),
+            )
+            .expect("read the account state"),
+        "suspended"
+    );
+    let third = accounts.issue_invitation().expect("issue an invitation");
+    assert!(matches!(
+        accounts.signup(&third, "alice", &public_key()),
+        Err(AccountError::Store(StoreError::UsernameUnavailable(_)))
+    ));
+    let consumed: Option<i64> = Store::open(&database)
+        .expect("open the account database")
+        .connection()
+        .query_row(
+            "SELECT consumed_at FROM signup_invitation ORDER BY id DESC LIMIT 1",
+            [],
+            |row| row.get(0),
+        )
+        .optional()
+        .expect("read the invitation")
+        .flatten();
+    assert_eq!(consumed, None);
+}
+
+#[test]
+fn expired_invitations_do_not_create_accounts() {
+    let directory = TempDir::new().expect("create an account directory");
+    let database = directory.path().join("tit.sqlite3");
+    let store = Store::open(&database).expect("create the account database");
+    let invitation =
+        "tit-invite-v1:0000000000000000000000000000000000000000000000000000000000000000";
+    let invitation_hash: [u8; 32] = Sha256::digest(invitation.as_bytes()).into();
+    store
+        .create_signup_invitation(&invitation_hash, 1, 2)
+        .expect("store an expired invitation");
+    let accounts = AccountService::new(database.clone());
+    assert!(matches!(
+        accounts.signup(invitation, "alice", &public_key()),
+        Err(AccountError::Store(StoreError::InvalidInvitation))
+    ));
+    assert_eq!(
+        Store::open(&database)
+            .expect("open the account database")
+            .connection()
+            .query_row("SELECT count(*) FROM account", [], |row| row
+                .get::<_, i64>(0))
+            .expect("count accounts"),
+        0
+    );
+}
+
+fn public_key() -> String {
+    PrivateKey::random(&mut rng(), Algorithm::Ed25519)
+        .expect("create an SSH key")
+        .public_key()
+        .to_openssh()
+        .expect("encode an SSH public key")
+}
diff --git a/tests/cli.rs b/tests/cli.rs
index 91b9aaa1e01d708682fe2d4a828f44462c36a729..ee664552b5315b80e507acfbf632676ef8d5c518 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -12,10 +12,11 @@
 };
 use tempfile::TempDir;
 
-const V6_DATABASE: &str = concat!(
+const V7_DATABASE: &str = concat!(
     include_str!("fixtures/sqlite/v5.sql"),
     include_str!("../src/store/migrations/006_repository_events.sql"),
-    "PRAGMA user_version = 6;\n",
+    include_str!("../src/store/migrations/007_account_lifecycle.sql"),
+    "PRAGMA user_version = 7;\n",
 );
 
 #[test]
@@ -71,7 +72,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V6_DATABASE)
+        .execute_batch(V7_DATABASE)
         .expect("create the current database");
     drop(database);
 
@@ -123,7 +124,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V6_DATABASE)
+        .execute_batch(V7_DATABASE)
         .expect("create the current database");
     database
         .pragma_update(None, "foreign_keys", false)
diff --git a/tests/fixtures/sqlite/v6.sql b/tests/fixtures/sqlite/v6.sql
new file mode 100644
index 0000000000000000000000000000000000000000..ca9e5b0e6f59f428f83e2177817f78fe2a72e992
--- /dev/null
+++ b/tests/fixtures/sqlite/v6.sql
@@ -1,0 +1,165 @@
+CREATE TABLE m1a_parent (
+    id INTEGER PRIMARY KEY,
+    name TEXT NOT NULL UNIQUE CHECK (length(name) BETWEEN 1 AND 64),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX m1a_parent_created_at ON m1a_parent (created_at, id);
+
+CREATE TABLE m1a_child (
+    id INTEGER PRIMARY KEY,
+    parent_id INTEGER NOT NULL REFERENCES m1a_parent (id) ON DELETE RESTRICT,
+    sequence INTEGER NOT NULL CHECK (sequence >= 0),
+    body TEXT NOT NULL,
+    state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
+    UNIQUE (parent_id, sequence)
+) STRICT;
+
+CREATE INDEX m1a_child_state_parent
+ON m1a_child (state, parent_id, sequence);
+
+CREATE TABLE git_operation_intent (
+    id TEXT PRIMARY KEY CHECK (length(id) = 32),
+    repository_path TEXT NOT NULL CHECK (length(repository_path) BETWEEN 1 AND 4096),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    initial_refs BLOB NOT NULL,
+    proposed_refs BLOB NOT NULL,
+    event_payload BLOB NOT NULL,
+    quarantine_path TEXT NOT NULL CHECK (length(quarantine_path) BETWEEN 1 AND 4096),
+    state TEXT NOT NULL CHECK (state IN ('pending', 'promoted', 'completed', 'abandoned')),
+    pack_name TEXT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX git_operation_intent_incomplete
+ON git_operation_intent (state, created_at, id)
+WHERE state IN ('pending', 'promoted');
+
+CREATE TABLE git_operation_event (
+    id INTEGER PRIMARY KEY,
+    intent_id TEXT NOT NULL UNIQUE
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    payload BLOB NOT NULL
+) STRICT;
+
+CREATE TABLE account (
+    id INTEGER PRIMARY KEY,
+    username TEXT NOT NULL UNIQUE
+        CHECK (
+            length(username) BETWEEN 1 AND 40
+            AND username NOT GLOB '*[^a-z0-9-]*'
+            AND substr(username, 1, 1) != '-'
+            AND substr(username, -1, 1) != '-'
+        ),
+    is_administrator INTEGER NOT NULL
+        CHECK (is_administrator IN (0, 1)),
+    state TEXT NOT NULL
+        CHECK (state IN ('active', 'suspended')),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE TABLE ssh_public_key (
+    id INTEGER PRIMARY KEY,
+    account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    canonical_key TEXT NOT NULL
+        CHECK (length(canonical_key) BETWEEN 1 AND 16384),
+    fingerprint TEXT NOT NULL UNIQUE
+        CHECK (length(fingerprint) BETWEEN 1 AND 256),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (account_id, canonical_key)
+) STRICT;
+
+CREATE INDEX ssh_public_key_account
+ON ssh_public_key (account_id, id);
+
+CREATE TABLE recovery_credential (
+    account_id INTEGER PRIMARY KEY
+        REFERENCES account (id) ON DELETE RESTRICT,
+    credential_hash BLOB NOT NULL UNIQUE
+        CHECK (length(credential_hash) = 32),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE TABLE repository (
+    id TEXT PRIMARY KEY
+        CHECK (length(id) = 32 AND id NOT GLOB '*[^0-9a-f]*'),
+    owner_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    slug TEXT NOT NULL
+        CHECK (
+            length(slug) BETWEEN 1 AND 100
+            AND slug NOT GLOB '*[^a-z0-9._-]*'
+            AND substr(slug, 1, 1) GLOB '[a-z0-9]'
+            AND substr(slug, -1, 1) GLOB '[a-z0-9]'
+            AND substr(slug, -4) != '.git'
+            AND slug NOT IN ('admin', 'api', 'assets', 'feeds', 'issues', 'setup')
+        ),
+    visibility TEXT NOT NULL DEFAULT 'public'
+        CHECK (visibility IN ('public', 'private')),
+    state TEXT NOT NULL DEFAULT 'active'
+        CHECK (state IN ('active', 'archived')),
+    object_format TEXT NOT NULL
+        CHECK (object_format IN ('sha1', 'sha256')),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    archived_at INTEGER
+        CHECK (archived_at IS NULL OR archived_at >= created_at),
+    UNIQUE (owner_account_id, slug),
+    CHECK (
+        (state = 'active' AND archived_at IS NULL)
+        OR (state = 'archived' AND archived_at IS NOT NULL)
+    )
+) STRICT;
+
+INSERT INTO m1a_parent (id, name, created_at)
+VALUES (1, 'fixture', 1);
+
+INSERT INTO m1a_child (id, parent_id, sequence, body, state)
+VALUES (1, 1, 1, 'version five', 'closed');
+
+PRAGMA user_version = 5;
+
+CREATE TABLE repository_event (
+    id INTEGER PRIMARY KEY,
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    source_intent_id TEXT
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
+    kind TEXT NOT NULL
+        CHECK (kind IN (
+            'repository-created', 'repository-imported', 'push',
+            'ref-created', 'ref-updated', 'ref-deleted',
+            'tag-created', 'tag-updated', 'tag-deleted'
+        )),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    ref_name BLOB,
+    old_target TEXT,
+    new_target TEXT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (source_intent_id, source_ordinal),
+    CHECK (
+        (source_intent_id IS NULL AND source_ordinal IS NULL)
+        OR (source_intent_id IS NOT NULL AND source_ordinal IS NOT NULL)
+    ),
+    CHECK (
+        (kind IN ('repository-created', 'repository-imported', 'push')
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        (kind LIKE 'ref-%' OR kind LIKE 'tag-%')
+            AND ref_name IS NOT NULL
+            AND (old_target IS NOT NULL OR new_target IS NOT NULL)
+    )
+) STRICT;
+
+INSERT INTO repository_event (repository_id, kind, actor, created_at)
+SELECT repository.id, 'repository-created', account.username, repository.created_at
+FROM repository
+JOIN account ON account.id = repository.owner_account_id
+ORDER BY repository.created_at, repository.id;
+
+CREATE INDEX repository_event_feed
+ON repository_event (repository_id, id DESC);
+
+PRAGMA user_version = 6;
+
diff --git a/tests/public_routes.rs b/tests/public_routes.rs
index ed4e71af7c17a4451bb9986dc08c8ff136309cc0..e202c68a76e29a83106b1801873b1e795144ca49 100644
--- a/tests/public_routes.rs
+++ b/tests/public_routes.rs
@@ -1,5 +1,11 @@
 #[allow(
     dead_code,
+    reason = "the public-route test does not use account mutations"
+)]
+#[path = "../src/account.rs"]
+mod account;
+#[allow(
+    dead_code,
     reason = "the public-route test uses only username validation"
 )]
 #[path = "../src/auth.rs"]
diff --git a/tests/serve.rs b/tests/serve.rs
index e59a1887c84c86fbd7768c9dda90ba39d6005ba2..b86785ce74f5be0dde23dabe016325d5a76d72ec 100644
--- a/tests/serve.rs
+++ b/tests/serve.rs
@@ -64,6 +64,47 @@
     let mut server = spawn_server(&config);
     wait_for_listener(http, &mut server);
     wait_for_listener(ssh, &mut server);
+    let control_socket = instance.path().join("control.sock");
+    assert_eq!(
+        fs::symlink_metadata(&control_socket)
+            .expect("inspect the control socket")
+            .permissions()
+            .mode()
+            & 0o777,
+        0o600
+    );
+
+    let invitation_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .args([
+            "--config",
+            config.to_str().expect("a UTF-8 configuration path"),
+            "invite-code",
+        ])
+        .output()
+        .expect("request an invitation");
+    assert!(invitation_output.status.success());
+    let invitation = String::from_utf8(invitation_output.stdout)
+        .expect("read the invitation output")
+        .trim()
+        .strip_prefix("Signup code: ")
+        .expect("read the invitation code")
+        .to_owned();
+    let member_key = instance.path().join("member");
+    create_ssh_key_fixture(&member_key);
+    let member_public =
+        fs::read_to_string(member_key.with_extension("pub")).expect("read the member public key");
+    let signup = http_form(
+        http,
+        "/signup",
+        &[
+            ("invitation", invitation.as_str()),
+            ("username", "bob"),
+            ("public-key", member_public.trim()),
+        ],
+    );
+    assert!(signup.starts_with("HTTP/1.1 200"), "{signup}");
+    let recovery = between(&signup, "<pre><code>", "</code></pre>");
+    assert!(recovery.starts_with("tit-recovery-v1:"));
 
     let summary = http_get(http, "/alice/example");
     assert!(summary.starts_with("HTTP/1.1 200"));
@@ -83,6 +124,37 @@
         fs::read(http_clone.join("README.md")).expect("read the HTTP clone"),
         b"serve fixture\n"
     );
+
+    assert!(ssh_clone_succeeds(
+        ssh,
+        &member_key,
+        &instance.path().join("member-clone")
+    ));
+
+    let replacement_key = instance.path().join("replacement");
+    create_ssh_key_fixture(&replacement_key);
+    let replacement_public = fs::read_to_string(replacement_key.with_extension("pub"))
+        .expect("read the replacement public key");
+    let recovered = http_form(
+        http,
+        "/recover",
+        &[
+            ("recovery", recovery),
+            ("username", "bob"),
+            ("public-key", replacement_public.trim()),
+        ],
+    );
+    assert!(recovered.starts_with("HTTP/1.1 200"), "{recovered}");
+    assert!(!ssh_clone_succeeds(
+        ssh,
+        &member_key,
+        &instance.path().join("revoked-clone")
+    ));
+    assert!(ssh_clone_succeeds(
+        ssh,
+        &replacement_key,
+        &instance.path().join("replacement-clone")
+    ));
 
     let ssh_clone = instance.path().join("ssh-clone");
     let ssh_command = format!(
@@ -125,6 +197,7 @@
     assert!(String::from_utf8_lossy(&locked.stderr).contains("owns the instance lock"));
 
     server.terminate();
+    assert!(!control_socket.exists());
     let host_key = fs::read(instance.path().join("ssh_host_ed25519_key"))
         .expect("read the generated SSH host key");
     assert_eq!(
@@ -254,6 +327,54 @@
         .read_to_string(&mut response)
         .expect("read an HTTP response");
     response
+}
+
+fn http_form(address: SocketAddr, path: &str, fields: &[(&str, &str)]) -> String {
+    let body = url::form_urlencoded::Serializer::new(String::new())
+        .extend_pairs(fields.iter().copied())
+        .finish();
+    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
+    write!(
+        stream,
+        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+        body.len()
+    )
+    .expect("write an HTTP form");
+    let mut response = String::new();
+    stream
+        .read_to_string(&mut response)
+        .expect("read an HTTP response");
+    response
+}
+
+fn between<'a>(value: &'a str, start: &str, end: &str) -> &'a str {
+    value
+        .split_once(start)
+        .and_then(|(_, tail)| tail.split_once(end))
+        .map(|(value, _)| value)
+        .expect("find the response value")
+}
+
+fn ssh_clone_succeeds(address: SocketAddr, private_key: &Path, target: &Path) -> bool {
+    let ssh_command = format!(
+        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
+        private_key.display()
+    );
+    Command::new("git")
+        .args([
+            "clone",
+            "-q",
+            &format!(
+                "ssh://ignored@127.0.0.1:{}/alice/example.git",
+                address.port()
+            ),
+        ])
+        .arg(target)
+        .env("GIT_SSH_COMMAND", ssh_command)
+        .output()
+        .expect("clone through the tit SSH server")
+        .status
+        .success()
 }
 
 struct ChildGuard(Option<Child>);
diff --git a/tests/snapshots/web/bad-request.html b/tests/snapshots/web/bad-request.html
index acb84f9ae18f6eb8df326b21a2f8d23361e6a0f3..159711d92b81c4d251d82fe33ce61a923ce10a72 100644
--- a/tests/snapshots/web/bad-request.html
+++ b/tests/snapshots/web/bad-request.html
@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">
diff --git a/tests/snapshots/web/home.html b/tests/snapshots/web/home.html
index cf76f68a79f138037014bf8603ee173eef55993f..6500823ed0725d4b1edbe9f3bd110c78de4cb945 100644
--- a/tests/snapshots/web/home.html
+++ b/tests/snapshots/web/home.html
@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">
diff --git a/tests/snapshots/web/method-not-allowed.html b/tests/snapshots/web/method-not-allowed.html
index 581566f0464896f1e7e672f122f7fd9c87d5f188..c415a2c6edb758dda798885a1116c7683e997a95 100644
--- a/tests/snapshots/web/method-not-allowed.html
+++ b/tests/snapshots/web/method-not-allowed.html
@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">
diff --git a/tests/snapshots/web/not-found.html b/tests/snapshots/web/not-found.html
index f80436b71f826ac8c081443fb99c908c09530d73..89966cf7e55f8212504c84445cd79223ca7fbbc4 100644
--- a/tests/snapshots/web/not-found.html
+++ b/tests/snapshots/web/not-found.html
@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">
diff --git a/tests/sqlite.rs b/tests/sqlite.rs
index ec95cda5c1c7881a7f956f6222d79ffd0b06f3c7..fd5cfa7431189b74ffa2a6cff1066aa117a7f881 100644
--- a/tests/sqlite.rs
+++ b/tests/sqlite.rs
@@ -20,6 +20,7 @@
 const V3_FIXTURE: &str = include_str!("fixtures/sqlite/v3.sql");
 const V4_FIXTURE: &str = include_str!("fixtures/sqlite/v4.sql");
 const V5_FIXTURE: &str = include_str!("fixtures/sqlite/v5.sql");
+const V6_FIXTURE: &str = include_str!("fixtures/sqlite/v6.sql");
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
     directory.path().join(name)
@@ -137,7 +138,7 @@
     let directory = TempDir::new().expect("create a temporary directory");
     let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
 
-    assert_eq!(store.schema_version().expect("read the schema version"), 6);
+    assert_eq!(store.schema_version().expect("read the schema version"), 7);
     assert_eq!(
         store
             .connection()
@@ -772,13 +773,14 @@
         (V3_FIXTURE, 3),
         (V4_FIXTURE, 4),
         (V5_FIXTURE, 5),
+        (V6_FIXTURE, 6),
     ] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "tit.sqlite3");
         create_fixture(&path, fixture);
 
         let store = Store::open(&path).expect("migrate the fixture");
-        assert_eq!(store.schema_version().expect("read the schema version"), 6);
+        assert_eq!(store.schema_version().expect("read the schema version"), 7);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -844,7 +846,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 6)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 7)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);
diff --git a/tests/web_shell.rs b/tests/web_shell.rs
index abf37bf22e15f3e2afb3511a8f9449ee02dc4336..9093f88fdfbf626251a49e060cfa0e370e857f42 100644
--- a/tests/web_shell.rs
+++ b/tests/web_shell.rs
@@ -1,3 +1,9 @@
+#[allow(
+    dead_code,
+    reason = "the Web shell test does not use account mutations"
+)]
+#[path = "../src/account.rs"]
+mod account;
 #[allow(dead_code, reason = "the Web shell test uses only username validation")]
 #[path = "../src/auth.rs"]
 mod auth;
@@ -79,6 +85,27 @@
         css_head.header("content-length"),
         css.body.len().to_string()
     );
+
+    let signup = request(server.address(), "GET", "/signup", &[]);
+    assert_eq!(signup.status, 200);
+    assert!(
+        signup
+            .body
+            .contains("<form action=\"/signup\" method=\"post\">")
+    );
+    assert!(signup.body.contains("name=\"invitation\""));
+    let recovery = request(server.address(), "GET", "/recover", &[]);
+    assert_eq!(recovery.status, 200);
+    assert!(
+        recovery
+            .body
+            .contains("<form action=\"/recover\" method=\"post\">")
+    );
+    assert!(recovery.body.contains("name=\"recovery\""));
+
+    let wrong_signup_method = request(server.address(), "PUT", "/signup", &[]);
+    assert_eq!(wrong_signup_method.status, 405);
+    assert_eq!(wrong_signup_method.header("allow"), "GET, HEAD, POST");
 
     server.shutdown().await.expect("stop the Web server");
 }
