diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 44516b5e6..95dd8073a 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -378,6 +378,10 @@ INTERNAL_TOKEN=
 ;;
 ;; Validate against https://haveibeenpwned.com/Passwords to see if a password has been exposed
 ;PASSWORD_CHECK_PWN = false
+;;
+;; Cache successful token hashes. API tokens are stored in the DB as pbkdf2 hashes however, this means that there is a potentially significant hashing load when there are multiple API operations.
+;; This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security.
+;SUCCESSFUL_TOKENS_CACHE_SIZE = 20
 
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
index e94c3ece2..572e33af7 100644
--- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
@@ -441,6 +441,7 @@ relation to port exhaustion.
     - spec - use one or more special characters as ``!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~``
     - off - do not check password complexity
 - `PASSWORD_CHECK_PWN`: **false**: Check [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) to see if a password has been exposed.
+- `SUCCESSFUL_TOKENS_CACHE_SIZE`: **20**: Cache successful token hashes. API tokens are stored in the DB as pbkdf2 hashes however, this means that there is a potentially significant hashing load when there are multiple API operations. This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security. 
 
 ## OpenID (`openid`)
 
diff --git a/models/models.go b/models/models.go
index c84330229..4e1448241 100755
--- a/models/models.go
+++ b/models/models.go
@@ -17,6 +17,7 @@ import (
 
 	// Needed for the MySQL driver
 	_ "github.com/go-sql-driver/mysql"
+	lru "github.com/hashicorp/golang-lru"
 	"xorm.io/xorm"
 	"xorm.io/xorm/names"
 	"xorm.io/xorm/schemas"
@@ -234,6 +235,15 @@ func NewEngine(ctx context.Context, migrateFunc func(*xorm.Engine) error) (err e
 		return fmt.Errorf("sync database struct error: %v", err)
 	}
 
+	if setting.SuccessfulTokensCacheSize > 0 {
+		successfulAccessTokenCache, err = lru.New(setting.SuccessfulTokensCacheSize)
+		if err != nil {
+			return fmt.Errorf("unable to allocate AccessToken cache: %v", err)
+		}
+	} else {
+		successfulAccessTokenCache = nil
+	}
+
 	return nil
 }
 
diff --git a/models/token.go b/models/token.go
index 8e1f91d43..9baa763f1 100644
--- a/models/token.go
+++ b/models/token.go
@@ -14,8 +14,11 @@ import (
 	"code.gitea.io/gitea/modules/util"
 
 	gouuid "github.com/google/uuid"
+	lru "github.com/hashicorp/golang-lru"
 )
 
+var successfulAccessTokenCache *lru.Cache
+
 // AccessToken represents a personal access token.
 type AccessToken struct {
 	ID             int64 `xorm:"pk autoincr"`
@@ -52,6 +55,21 @@ func NewAccessToken(t *AccessToken) error {
 	return err
 }
 
+func getAccessTokenIDFromCache(token string) int64 {
+	if successfulAccessTokenCache == nil {
+		return 0
+	}
+	tInterface, ok := successfulAccessTokenCache.Get(token)
+	if !ok {
+		return 0
+	}
+	t, ok := tInterface.(int64)
+	if !ok {
+		return 0
+	}
+	return t
+}
+
 // GetAccessTokenBySHA returns access token by given token value
 func GetAccessTokenBySHA(token string) (*AccessToken, error) {
 	if token == "" {
@@ -66,17 +84,38 @@ func GetAccessTokenBySHA(token string) (*AccessToken, error) {
 			return nil, ErrAccessTokenNotExist{token}
 		}
 	}
-	var tokens []AccessToken
+
 	lastEight := token[len(token)-8:]
+
+	if id := getAccessTokenIDFromCache(token); id > 0 {
+		token := &AccessToken{
+			TokenLastEight: lastEight,
+		}
+		// Re-get the token from the db in case it has been deleted in the intervening period
+		has, err := x.ID(id).Get(token)
+		if err != nil {
+			return nil, err
+		}
+		if has {
+			return token, nil
+		}
+		successfulAccessTokenCache.Remove(token)
+	}
+
+	var tokens []AccessToken
 	err := x.Table(&AccessToken{}).Where("token_last_eight = ?", lastEight).Find(&tokens)
 	if err != nil {
 		return nil, err
 	} else if len(tokens) == 0 {
 		return nil, ErrAccessTokenNotExist{token}
 	}
+
 	for _, t := range tokens {
 		tempHash := hashToken(token, t.TokenSalt)
 		if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(tempHash)) == 1 {
+			if successfulAccessTokenCache != nil {
+				successfulAccessTokenCache.Add(token, t.ID)
+			}
 			return &t, nil
 		}
 	}
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index 593677344..d584ed3d4 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -189,6 +189,7 @@ var (
 	PasswordComplexity                 []string
 	PasswordHashAlgo                   string
 	PasswordCheckPwn                   bool
+	SuccessfulTokensCacheSize          int
 
 	// UI settings
 	UI = struct {
@@ -840,6 +841,7 @@ func NewContext() {
 	PasswordHashAlgo = sec.Key("PASSWORD_HASH_ALGO").MustString("pbkdf2")
 	CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true)
 	PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false)
+	SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20)
 
 	InternalToken = loadInternalToken(sec)