diff --git a/db.go b/db.go
index 5b67654c02d1320d1a3db1f292ab09a53e9fa66d..de2c9d40e7e21d8eed3e659dd16cf3db85ffee76 100644
--- a/db.go
+++ b/db.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"crypto/rand"
 	"embed"
 	"fmt"
 	"io/fs"
@@ -15,7 +16,7 @@ var migrationFS embed.FS
 
 func initDB(db *sqlx.DB) error {
 	// Create table tracking migration state
-	const query = `CREATE TABLE IF NOT EXISTS migrations (name TEXT UNIQUE);`
+	query := `CREATE TABLE IF NOT EXISTS migrations (name TEXT UNIQUE);`
 
 	_, err := db.Exec(query)
 	if err != nil {
@@ -88,5 +89,18 @@ func initDB(db *sqlx.DB) error {
 		log.Println("it has", len(data), "bytes")
 	}
 
+	// Initialize persistent state like password salt
+	buf := make([]byte, 16)
+	_, err = rand.Read(buf)
+	if err != nil {
+		return err
+	}
+
+	query = `INSERT OR IGNORE INTO state VALUES ('pwsalt', ?)`
+	_, err = db.Exec(query, buf)
+	if err != nil {
+		return err
+	}
+
 	return nil
 }
diff --git a/main.go b/main.go
index 9e4f47f38570b38c59de6c1fe830740cb78a07df..ea6c73b0d82723f47861c3a7f42e846118e61c40 100644
--- a/main.go
+++ b/main.go
@@ -2,13 +2,12 @@ package main
 
 import (
 	"context"
-	"database/sql"
+	"crypto/sha256"
 	"embed"
-	"errors"
+	"fmt"
 	"log"
 	"net/http"
 
-	"github.com/Masterminds/squirrel"
 	"github.com/jmoiron/sqlx"
 	_ "modernc.org/sqlite" // Imported for side effects: registers DB driver
 
@@ -35,31 +34,59 @@ func httpError(w http.ResponseWriter, msg string, err error, status int) {
 	http.Error(w, msg, status)
 }
 
+func hashPassword(ctx context.Context, db *sqlx.DB, pass string) (string, error) {
+	// Look up password salt from DB and hash password with it
+	query := `SELECT val FROM state WHERE key = 'pwsalt'`
+
+	var salt []byte
+	err := db.GetContext(ctx, &salt, query)
+	if err != nil {
+		return "", err
+	}
+
+	h := sha256.New()
+
+	_, err = h.Write(salt)
+	if err != nil {
+		return "", err
+	}
+
+	fmt.Fprint(h, pass)
+
+	return fmt.Sprintf("%02x", h.Sum(nil)), nil
+}
+
 type authProvider struct {
 	db *sqlx.DB
 }
 
 func (a authProvider) Valid(ctx context.Context, user, pass string) (bool, error) {
-	query, args, err := squirrel.Select("password").
-		From("users").
-		Where(squirrel.Eq{"name": user}).
-		ToSql()
+	query := `SELECT count(*) FROM users;`
+
+	var count int
+	err := a.db.GetContext(ctx, &count, query)
 	if err != nil {
 		return false, err
 	}
 
-	var dbPass string
-	err = a.db.GetContext(ctx, &dbPass, query, args...)
-	if errors.Is(err, sql.ErrNoRows) {
-		// User not found isn't an error, it's just an invalid auth.
-		return false, nil
+	// If no entry in user DB, just allow everything, so that initial user creation works.
+	if count == 0 {
+		log.Println("user database empty, allowing access by", user, "regardless of password")
+		return true, nil
 	}
 
+	hashedPw, err := hashPassword(ctx, a.db, pass)
+
+	log.Printf("hashed pw for %s: %s", user, hashedPw)
+
+	query = `SELECT count(*) FROM users WHERE name = ? AND password = ?`
+
+	err = a.db.GetContext(ctx, &count, query, user, hashedPw)
 	if err != nil {
 		return false, err
 	}
 
-	if dbPass == pass {
+	if count == 1 {
 		return true, nil
 	}
 
diff --git a/migrations/0004-state.sql b/migrations/0004-state.sql
new file mode 100644
index 0000000000000000000000000000000000000000..a80f7b0219b48315e33ebf3f2e1b54272ee0a0bf
--- /dev/null
+++ b/migrations/0004-state.sql
@@ -0,0 +1,5 @@
+CREATE TABLE state (
+	key TEXT,
+	val TEXT,
+	UNIQUE(key)
+);
\ No newline at end of file