diff --git a/pkg/database/accounts.go b/pkg/database/accounts.go
index bb99cc4..66e23ad 100644
--- a/pkg/database/accounts.go
+++ b/pkg/database/accounts.go
@@ -215,6 +215,27 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
 	return &account, nil
 }
 
+func (db *DB) SaveAccount(account *model.Account) error {
+	_, err := db.Exec(
+		`UPDATE accounts
+           SET username = ?,
+               salt = ?,
+               password_hash = ?,
+               is_admin = ?,
+               password_reset = ?
+           WHERE id = ?`,
+		account.Username,
+		account.Salt,
+		account.PasswordHash,
+		account.IsAdmin,
+		account.PasswordReset,
+		account.Id)
+	if err != nil {
+		return fmt.Errorf("failed to update user id %s: %w", account.Id, err)
+	}
+	return nil
+}
+
 func (db *DB) SaveAccountSettings(account *model.Account, settings *model.Settings) error {
 	data, err := json.Marshal(settings)
 	if err != nil {
diff --git a/pkg/model/account.go b/pkg/model/account.go
index c1ea958..df7ea8a 100644
--- a/pkg/model/account.go
+++ b/pkg/model/account.go
@@ -27,3 +27,9 @@ func (account *Account) CheckPassword(password string) bool {
 	hash := helpers.HashPassword(password, account.Salt)
 	return subtle.ConstantTimeCompare(hash, account.PasswordHash) == 1
 }
+
+func (account *Account) SetPassword(password string) {
+	account.Salt = helpers.GenerateSalt()
+	account.PasswordHash = helpers.HashPassword(password, account.Salt)
+	account.PasswordReset = nil
+}
diff --git a/pkg/webui/accountsIdResetPassword.go b/pkg/webui/accountsIdResetPassword.go
new file mode 100644
index 0000000..f8e341e
--- /dev/null
+++ b/pkg/webui/accountsIdResetPassword.go
@@ -0,0 +1,87 @@
+package webui
+
+import (
+	"html/template"
+	"net/http"
+
+	"git.adyxax.org/adyxax/tfstated/pkg/database"
+	"git.adyxax.org/adyxax/tfstated/pkg/model"
+	"go.n16f.net/uuid"
+)
+
+type AccountsIdResetPasswordPage struct {
+	Account         *model.Account
+	Page            *Page
+	PasswordInvalid bool
+	PasswordChanged bool
+	Token           string
+}
+
+var accountsIdResetPasswordTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accountsIdResetPassword.html"))
+
+func processAccountsIdResetPasswordPathValues(db *database.DB, w http.ResponseWriter, r *http.Request) (*model.Account, bool) {
+	var accountId uuid.UUID
+	if err := accountId.Parse(r.PathValue("id")); err != nil {
+		errorResponse(w, r, http.StatusBadRequest, err)
+		return nil, false
+	}
+	var token uuid.UUID
+	if err := token.Parse(r.PathValue("token")); err != nil {
+		errorResponse(w, r, http.StatusBadRequest, err)
+		return nil, false
+	}
+	account, err := db.LoadAccountById(accountId)
+	if err != nil {
+		errorResponse(w, r, http.StatusInternalServerError, err)
+		return nil, false
+	}
+	if account == nil || account.PasswordReset == nil {
+		errorResponse(w, r, http.StatusBadRequest, err)
+		return nil, false
+	}
+	if !account.PasswordReset.Equal(token) {
+		errorResponse(w, r, http.StatusBadRequest, err)
+		return nil, false
+	}
+	return account, true
+}
+
+func handleAccountsIdResetPasswordGET(db *database.DB) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		account, valid := processAccountsIdResetPasswordPathValues(db, w, r)
+		if !valid {
+			return
+		}
+		render(w, accountsIdResetPasswordTemplates, http.StatusOK,
+			AccountsIdResetPasswordPage{
+				Account: account,
+				Page:    &Page{Title: "Password Reset", Section: "reset"},
+				Token:   r.PathValue("token"),
+			})
+	})
+}
+
+func handleAccountsIdResetPasswordPOST(db *database.DB) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		account, valid := processAccountsIdResetPasswordPathValues(db, w, r)
+		if !valid {
+			return
+		}
+		password := r.FormValue("password")
+		if len(password) < 8 {
+			errorResponse(w, r, http.StatusBadRequest, nil)
+			return
+		}
+		account.SetPassword(password)
+		if err := db.SaveAccount(account); err != nil {
+			errorResponse(w, r, http.StatusInternalServerError, err)
+			return
+		}
+		render(w, accountsIdResetPasswordTemplates, http.StatusOK,
+			AccountsIdResetPasswordPage{
+				Account:         account,
+				Page:            &Page{Title: "Password Reset", Section: "reset"},
+				PasswordChanged: true,
+			})
+	})
+}
diff --git a/pkg/webui/html/accountsIdResetPassword.html b/pkg/webui/html/accountsIdResetPassword.html
new file mode 100644
index 0000000..c1f348f
--- /dev/null
+++ b/pkg/webui/html/accountsIdResetPassword.html
@@ -0,0 +1,57 @@
+{{ define "main" }}
+{{ if .PasswordChanged }}
+<h2>Password Reset Successful</h2>
+<p>
+  Your password has been set successfully. You can now try to <a href="/login">log in</a>!
+</p>
+{{ else }}
+<h1>User Account</h1>
+<h2>Password Reset</h2>
+<form action="/accounts/{{ .Account.Id }}/reset/{{ .Token }}" enctype="multipart/form-data" method="post">
+  <fieldset>
+    <legend>Set Password</legend>
+    <p>
+      You have been invited to set a password for this account. Please choose a
+      strong password or passphrase that you haven't used before. Think about a
+      combination of words that would be memorable yet complex
+      like <a href="https://xkcd.com/936/">Correct-Horse-Battery-Staple</a>.
+    </p>
+    <div class="flex-row">
+      <label for="password">Password</label>
+      <input autofocus
+             class="flex-stretch{{ if .PasswordInvalid }} error{{ end }}"
+             id="password"
+             minlength="8"
+             name="password"
+             type="password"
+             required>
+      <button type="submit" value="edit">Set Password</button>
+    </div>
+    {{ if .PasswordInvalid }}
+    <span class="error">
+      <span class="tooltip">
+        Invalid password.
+        <span class="tooltip-text">
+          Passwords must be at least 8 characters long.
+        </span>
+      </span>
+    </span>
+    {{ end }}
+  </fieldset>
+</form>
+<h2>Status</h2>
+<p>
+  The account
+  <strong>{{ .Account.Username }}</strong>
+  was created on
+  <strong>{{ .Account.Created }}</strong>
+  and
+  {{ if eq .Account.Created .Account.LastLogin }}
+  <strong>never logged in</strong>.
+  {{ else }}
+  last logged in on
+  <strong>{{ .Account.LastLogin }}</strong>.
+  {{ end }}
+</p>
+{{ end }}
+{{ end }}
diff --git a/pkg/webui/html/base.html b/pkg/webui/html/base.html
index 81e39a6..2a5b5a9 100644
--- a/pkg/webui/html/base.html
+++ b/pkg/webui/html/base.html
@@ -19,6 +19,11 @@
           <i class="material-symbols-outlined">login</i>
           <span>Login</span>
         </a>
+        {{ else if eq .Page.Section "reset" }}
+        <a href="/login">
+          <i class="material-symbols-outlined">login</i>
+          <span>Login</span>
+        </a>
         {{ else if eq .Page.Section "error" }}
         <a href="/">
           <i class="material-symbols-outlined">mountain_flag</i>
@@ -50,5 +55,6 @@
     </div>
     <footer>
     </footer>
+    <script type="module" src="/static/main.js"></script>
   </body>
 </html>
diff --git a/pkg/webui/login.go b/pkg/webui/login.go
index f6762e7..467b305 100644
--- a/pkg/webui/login.go
+++ b/pkg/webui/login.go
@@ -3,7 +3,6 @@ package webui
 import (
 	"context"
 	"encoding/json"
-	"fmt"
 	"html/template"
 	"log/slog"
 	"net/http"
@@ -55,8 +54,8 @@ func handleLoginPOST(db *database.DB) http.Handler {
 		username := r.FormValue("username")
 		password := r.FormValue("password")
 
-		if username == "" || password == "" { // the webui cannot issue this
-			errorResponse(w, r, http.StatusBadRequest, fmt.Errorf("Forbidden"))
+		if username == "" || password == "" {
+			errorResponse(w, r, http.StatusBadRequest, nil)
 			return
 		}
 		if ok := validUsername.MatchString(username); !ok {
diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go
index ee52fa6..a3a8a1a 100644
--- a/pkg/webui/routes.go
+++ b/pkg/webui/routes.go
@@ -15,6 +15,8 @@ func addRoutes(
 	requireAdmin := adminMiddleware(db, requireLogin)
 	mux.Handle("GET /accounts", requireLogin(handleAccountsGET(db)))
 	mux.Handle("GET /accounts/{id}", requireLogin(handleAccountsIdGET(db)))
+	mux.Handle("GET /accounts/{id}/reset/{token}", handleAccountsIdResetPasswordGET(db))
+	mux.Handle("POST /accounts/{id}/reset/{token}", handleAccountsIdResetPasswordPOST(db))
 	mux.Handle("POST /accounts", requireAdmin(handleAccountsPOST(db)))
 	mux.Handle("GET /healthz", handleHealthz())
 	mux.Handle("GET /login", requireSession(handleLoginGET()))