diff --git a/modules/caddyhttp/caddyauth/basicauth.go b/modules/caddyhttp/caddyauth/basicauth.go
index d709f94e0..92e1683ad 100644
--- a/modules/caddyhttp/caddyauth/basicauth.go
+++ b/modules/caddyhttp/caddyauth/basicauth.go
@@ -16,15 +16,21 @@ package caddyauth
 
 import (
 	"encoding/base64"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
+	weakrand "math/rand"
 	"net/http"
+	"sync"
+	"time"
 
 	"github.com/caddyserver/caddy/v2"
 )
 
 func init() {
 	caddy.RegisterModule(HTTPBasicAuth{})
+
+	weakrand.Seed(time.Now().UnixNano())
 }
 
 // HTTPBasicAuth facilitates HTTP basic authentication.
@@ -38,6 +44,17 @@ type HTTPBasicAuth struct {
 	// The name of the realm. Default: restricted
 	Realm string `json:"realm,omitempty"`
 
+	// If non-nil, a mapping of plaintext passwords to their
+	// hashes will be cached in memory (with random eviction).
+	// This can greatly improve the performance of traffic-heavy
+	// servers that use secure password hashing algorithms, with
+	// the downside that plaintext passwords will be stored in
+	// memory for a longer time (this should not be a problem
+	// as long as your machine is not compromised, at which point
+	// all bets are off, since basicauth necessitates plaintext
+	// passwords being received over the wire anyway).
+	HashCache *Cache `json:"hash_cache,omitempty"`
+
 	Accounts map[string]Account `json:"-"`
 	Hash     Comparer           `json:"-"`
 }
@@ -99,6 +116,11 @@ func (hba *HTTPBasicAuth) Provision(ctx caddy.Context) error {
 	}
 	hba.AccountList = nil // allow GC to deallocate
 
+	if hba.HashCache != nil {
+		hba.HashCache.cache = make(map[string]bool)
+		hba.HashCache.mu = new(sync.Mutex)
+	}
+
 	return nil
 }
 
@@ -109,13 +131,11 @@ func (hba HTTPBasicAuth) Authenticate(w http.ResponseWriter, req *http.Request)
 		return hba.promptForCredentials(w, nil)
 	}
 
-	plaintextPassword := []byte(plaintextPasswordStr)
-
 	account, accountExists := hba.Accounts[username]
 	// don't return early if account does not exist; we want
 	// to try to avoid side-channels that leak existence
 
-	same, err := hba.Hash.Compare(account.password, plaintextPassword, account.salt)
+	same, err := hba.correctPassword(account, []byte(plaintextPasswordStr))
 	if err != nil {
 		return hba.promptForCredentials(w, err)
 	}
@@ -126,6 +146,43 @@ func (hba HTTPBasicAuth) Authenticate(w http.ResponseWriter, req *http.Request)
 	return User{ID: username}, true, nil
 }
 
+func (hba HTTPBasicAuth) correctPassword(account Account, plaintextPassword []byte) (bool, error) {
+	compare := func() (bool, error) {
+		return hba.Hash.Compare(account.password, plaintextPassword, account.salt)
+	}
+
+	// if no caching is enabled, simply return the result of hashing + comparing
+	if hba.HashCache == nil {
+		return compare()
+	}
+
+	// compute a cache key that is unique for these input parameters
+	cacheKey := hex.EncodeToString(append(append(account.password, account.salt...), plaintextPassword...))
+
+	// fast track: if the result of the input is already cached, use it
+	hba.HashCache.mu.Lock()
+	same, ok := hba.HashCache.cache[cacheKey]
+	if ok {
+		hba.HashCache.mu.Unlock()
+		return same, nil
+	}
+	hba.HashCache.mu.Unlock()
+
+	// slow track: do the expensive op, then add it to the cache
+	same, err := compare()
+	if err != nil {
+		return false, err
+	}
+	hba.HashCache.mu.Lock()
+	if len(hba.HashCache.cache) >= 1000 {
+		hba.HashCache.makeRoom() // keep cache size under control
+	}
+	hba.HashCache.cache[cacheKey] = same
+	hba.HashCache.mu.Unlock()
+
+	return same, nil
+}
+
 func (hba HTTPBasicAuth) promptForCredentials(w http.ResponseWriter, err error) (User, bool, error) {
 	// browsers show a message that says something like:
 	// "The website says: <realm>"
@@ -138,6 +195,47 @@ func (hba HTTPBasicAuth) promptForCredentials(w http.ResponseWriter, err error)
 	return User{}, false, err
 }
 
+// Cache enables caching of basic auth results. This is especially
+// helpful for secure password hashes which can be expensive to
+// compute on every HTTP request.
+type Cache struct {
+	mu *sync.Mutex
+
+	// map of concatenated hashed password + plaintext password + salt, to result
+	cache map[string]bool
+}
+
+// makeRoom deletes about 1/10 of the items in the cache
+// in order to keep its size under control. It must not be
+// called without a lock on c.mu.
+func (c *Cache) makeRoom() {
+	// we delete more than just 1 entry so that we don't have
+	// to do this on every request; assuming the capacity of
+	// the cache is on a long tail, we can save a lot of CPU
+	// time by doing a whole bunch of deletions now and then
+	// we won't have to do them again for a while
+	numToDelete := len(c.cache) / 10
+	if numToDelete < 1 {
+		numToDelete = 1
+	}
+	for deleted := 0; deleted <= numToDelete; deleted++ {
+		// Go maps are "nondeterministic" not actually random,
+		// so although we could just chop off the "front" of the
+		// map with less code, this is a heavily skewed eviction
+		// strategy; generating random numbers is cheap and
+		// ensures a much better distribution.
+		rnd := weakrand.Intn(len(c.cache))
+		i := 0
+		for key := range c.cache {
+			if i == rnd {
+				delete(c.cache, key)
+				break
+			}
+			i++
+		}
+	}
+}
+
 // Comparer is a type that can securely compare
 // a plaintext password with a hashed password
 // in constant-time. Comparers should hash the
diff --git a/modules/caddyhttp/caddyauth/caddyfile.go b/modules/caddyhttp/caddyauth/caddyfile.go
index 9fe8a80e6..13e78fc09 100644
--- a/modules/caddyhttp/caddyauth/caddyfile.go
+++ b/modules/caddyhttp/caddyauth/caddyfile.go
@@ -35,6 +35,7 @@ func init() {
 // If no hash algorithm is supplied, bcrypt will be assumed.
 func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
 	var ba HTTPBasicAuth
+	ba.HashCache = new(Cache)
 
 	for h.Next() {
 		var cmp Comparer