diff --git a/models/auth/auth_token.go b/models/auth/auth_token.go
index 2c3ca90734..c64af3e41f 100644
--- a/models/auth/auth_token.go
+++ b/models/auth/auth_token.go
@@ -15,12 +15,31 @@ import (
 	"code.gitea.io/gitea/modules/util"
 )
 
+type AuthorizationPurpose string
+
+var (
+	// Used to store long term authorization tokens.
+	LongTermAuthorization AuthorizationPurpose = "long_term_authorization"
+
+	// Used to activate a user account.
+	UserActivation AuthorizationPurpose = "user_activation"
+
+	// Used to reset the password.
+	PasswordReset AuthorizationPurpose = "password_reset"
+)
+
+// Used to activate the specified email address for a user.
+func EmailActivation(email string) AuthorizationPurpose {
+	return AuthorizationPurpose("email_activation:" + email)
+}
+
 // AuthorizationToken represents a authorization token to a user.
 type AuthorizationToken struct {
 	ID              int64  `xorm:"pk autoincr"`
 	UID             int64  `xorm:"INDEX"`
 	LookupKey       string `xorm:"INDEX UNIQUE"`
 	HashedValidator string
+	Purpose         AuthorizationPurpose `xorm:"NOT NULL DEFAULT 'long_term_authorization'"`
 	Expiry          timeutil.TimeStamp
 }
 
@@ -41,7 +60,7 @@ func (authToken *AuthorizationToken) IsExpired() bool {
 // GenerateAuthToken generates a new authentication token for the given user.
 // It returns the lookup key and validator values that should be passed to the
 // user via a long-term cookie.
-func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeStamp) (lookupKey, validator string, err error) {
+func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeStamp, purpose AuthorizationPurpose) (lookupKey, validator string, err error) {
 	// Request 64 random bytes. The first 32 bytes will be used for the lookupKey
 	// and the other 32 bytes will be used for the validator.
 	rBytes, err := util.CryptoRandomBytes(64)
@@ -56,14 +75,15 @@ func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeSt
 		Expiry:          expiry,
 		LookupKey:       lookupKey,
 		HashedValidator: HashValidator(rBytes[32:]),
+		Purpose:         purpose,
 	})
 	return lookupKey, validator, err
 }
 
 // FindAuthToken will find a authorization token via the lookup key.
-func FindAuthToken(ctx context.Context, lookupKey string) (*AuthorizationToken, error) {
+func FindAuthToken(ctx context.Context, lookupKey string, purpose AuthorizationPurpose) (*AuthorizationToken, error) {
 	var authToken AuthorizationToken
-	has, err := db.GetEngine(ctx).Where("lookup_key = ?", lookupKey).Get(&authToken)
+	has, err := db.GetEngine(ctx).Where("lookup_key = ? AND purpose = ?", lookupKey, purpose).Get(&authToken)
 	if err != nil {
 		return nil, err
 	} else if !has {
diff --git a/models/user/email_address.go b/models/user/email_address.go
index 85824fcdcb..1359f36523 100644
--- a/models/user/email_address.go
+++ b/models/user/email_address.go
@@ -12,7 +12,6 @@ import (
 	"strings"
 
 	"code.gitea.io/gitea/models/db"
-	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/optional"
 	"code.gitea.io/gitea/modules/setting"
@@ -360,25 +359,6 @@ func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
 	return MakeEmailPrimaryWithUser(ctx, user, email)
 }
 
-// VerifyActiveEmailCode verifies active email code when active account
-func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress {
-	minutes := setting.Service.ActiveCodeLives
-
-	if user := GetVerifyUser(ctx, code); user != nil {
-		// time limit code
-		prefix := code[:base.TimeLimitCodeLength]
-		data := fmt.Sprintf("%d%s%s%s%s", user.ID, email, user.LowerName, user.Passwd, user.Rands)
-
-		if base.VerifyTimeLimitCode(data, minutes, prefix) {
-			emailAddress := &EmailAddress{UID: user.ID, Email: email}
-			if has, _ := db.GetEngine(ctx).Get(emailAddress); has {
-				return emailAddress
-			}
-		}
-	}
-	return nil
-}
-
 // SearchEmailOrderBy is used to sort the results from SearchEmails()
 type SearchEmailOrderBy string
 
diff --git a/models/user/user.go b/models/user/user.go
index 3123a28e37..74e7c64d2f 100644
--- a/models/user/user.go
+++ b/models/user/user.go
@@ -6,7 +6,9 @@ package user
 
 import (
 	"context"
+	"crypto/subtle"
 	"encoding/hex"
+	"errors"
 	"fmt"
 	"net/url"
 	"path/filepath"
@@ -308,15 +310,14 @@ func (u *User) OrganisationLink() string {
 	return setting.AppSubURL + "/org/" + url.PathEscape(u.Name)
 }
 
-// GenerateEmailActivateCode generates an activate code based on user information and given e-mail.
-func (u *User) GenerateEmailActivateCode(email string) string {
-	code := base.CreateTimeLimitCode(
-		fmt.Sprintf("%d%s%s%s%s", u.ID, email, u.LowerName, u.Passwd, u.Rands),
-		setting.Service.ActiveCodeLives, nil)
-
-	// Add tail hex username
-	code += hex.EncodeToString([]byte(u.LowerName))
-	return code
+// GenerateEmailAuthorizationCode generates an activation code based for the user for the specified purpose.
+// The standard expiry is ActiveCodeLives minutes.
+func (u *User) GenerateEmailAuthorizationCode(ctx context.Context, purpose auth.AuthorizationPurpose) (string, error) {
+	lookup, validator, err := auth.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(setting.Service.ActiveCodeLives)*60), purpose)
+	if err != nil {
+		return "", err
+	}
+	return lookup + ":" + validator, nil
 }
 
 // GetUserFollowers returns range of user's followers.
@@ -786,38 +787,50 @@ func countUsers(ctx context.Context, opts *CountUserFilter) int64 {
 	return count
 }
 
-// GetVerifyUser get user by verify code
-func GetVerifyUser(ctx context.Context, code string) (user *User) {
-	if len(code) <= base.TimeLimitCodeLength {
-		return nil
+// VerifyUserActiveCode verifies that the code is valid for the given purpose for this user.
+// If delete is specified, the token will be deleted.
+func VerifyUserAuthorizationToken(ctx context.Context, code string, purpose auth.AuthorizationPurpose, delete bool) (*User, error) {
+	lookupKey, validator, found := strings.Cut(code, ":")
+	if !found {
+		return nil, nil
 	}
 
-	// use tail hex username query user
-	hexStr := code[base.TimeLimitCodeLength:]
-	if b, err := hex.DecodeString(hexStr); err == nil {
-		if user, err = GetUserByName(ctx, string(b)); user != nil {
-			return user
+	authToken, err := auth.FindAuthToken(ctx, lookupKey, purpose)
+	if err != nil {
+		if errors.Is(err, util.ErrNotExist) {
+			return nil, nil
 		}
-		log.Error("user.getVerifyUser: %v", err)
+		return nil, err
 	}
 
-	return nil
-}
+	if authToken.IsExpired() {
+		return nil, auth.DeleteAuthToken(ctx, authToken)
+	}
 
-// VerifyUserActiveCode verifies active code when active account
-func VerifyUserActiveCode(ctx context.Context, code string) (user *User) {
-	minutes := setting.Service.ActiveCodeLives
+	rawValidator, err := hex.DecodeString(validator)
+	if err != nil {
+		return nil, err
+	}
 
-	if user = GetVerifyUser(ctx, code); user != nil {
-		// time limit code
-		prefix := code[:base.TimeLimitCodeLength]
-		data := fmt.Sprintf("%d%s%s%s%s", user.ID, user.Email, user.LowerName, user.Passwd, user.Rands)
+	if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 {
+		return nil, errors.New("validator doesn't match")
+	}
 
-		if base.VerifyTimeLimitCode(data, minutes, prefix) {
-			return user
+	u, err := GetUserByID(ctx, authToken.UID)
+	if err != nil {
+		if IsErrUserNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+
+	if delete {
+		if err := auth.DeleteAuthToken(ctx, authToken); err != nil {
+			return nil, err
 		}
 	}
-	return nil
+
+	return u, nil
 }
 
 // ValidateUser check if user is valid to insert / update into database
diff --git a/models/user/user_test.go b/models/user/user_test.go
index 729fcd0263..1359e9e218 100644
--- a/models/user/user_test.go
+++ b/models/user/user_test.go
@@ -5,8 +5,9 @@ package user_test
 
 import (
 	"context"
+	"crypto/rand"
+	"encoding/hex"
 	"fmt"
-	"math/rand"
 	"strings"
 	"testing"
 	"time"
@@ -19,7 +20,9 @@ import (
 	"code.gitea.io/gitea/modules/optional"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/test"
 	"code.gitea.io/gitea/modules/timeutil"
+	"code.gitea.io/gitea/modules/util"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -543,3 +546,66 @@ func Test_NormalizeUserFromEmail(t *testing.T) {
 		}
 	}
 }
+
+func TestGenerateEmailAuthorizationCode(t *testing.T) {
+	defer test.MockVariableValue(&setting.Service.ActiveCodeLives, 2)()
+	require.NoError(t, unittest.PrepareTestDatabase())
+
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+	code, err := user.GenerateEmailAuthorizationCode(db.DefaultContext, auth.UserActivation)
+	require.NoError(t, err)
+
+	lookupKey, validator, ok := strings.Cut(code, ":")
+	assert.True(t, ok)
+
+	rawValidator, err := hex.DecodeString(validator)
+	require.NoError(t, err)
+
+	authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation)
+	require.NoError(t, err)
+	assert.False(t, authToken.IsExpired())
+	assert.EqualValues(t, authToken.HashedValidator, auth.HashValidator(rawValidator))
+
+	authToken.Expiry = authToken.Expiry.Add(-int64(setting.Service.ActiveCodeLives) * 60)
+	assert.True(t, authToken.IsExpired())
+}
+
+func TestVerifyUserAuthorizationToken(t *testing.T) {
+	defer test.MockVariableValue(&setting.Service.ActiveCodeLives, 2)()
+	require.NoError(t, unittest.PrepareTestDatabase())
+
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+	code, err := user.GenerateEmailAuthorizationCode(db.DefaultContext, auth.UserActivation)
+	require.NoError(t, err)
+
+	lookupKey, _, ok := strings.Cut(code, ":")
+	assert.True(t, ok)
+
+	t.Run("Wrong purpose", func(t *testing.T) {
+		u, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.PasswordReset, false)
+		require.NoError(t, err)
+		assert.Nil(t, u)
+	})
+
+	t.Run("No delete", func(t *testing.T) {
+		u, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation, false)
+		require.NoError(t, err)
+		assert.EqualValues(t, user.ID, u.ID)
+
+		authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation)
+		require.NoError(t, err)
+		assert.NotNil(t, authToken)
+	})
+
+	t.Run("Delete", func(t *testing.T) {
+		u, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation, true)
+		require.NoError(t, err)
+		assert.EqualValues(t, user.ID, u.ID)
+
+		authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation)
+		require.ErrorIs(t, err, util.ErrNotExist)
+		assert.Nil(t, authToken)
+	})
+}
diff --git a/modules/base/tool.go b/modules/base/tool.go
index e4c3fb1818..4b5ae4d5da 100644
--- a/modules/base/tool.go
+++ b/modules/base/tool.go
@@ -15,12 +15,10 @@ import (
 	"runtime"
 	"strconv"
 	"strings"
-	"time"
 	"unicode/utf8"
 
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
-	"code.gitea.io/gitea/modules/setting"
 
 	"github.com/dustin/go-humanize"
 )
@@ -61,67 +59,6 @@ func BasicAuthDecode(encoded string) (string, string, error) {
 	return auth[0], auth[1], nil
 }
 
-// VerifyTimeLimitCode verify time limit code
-func VerifyTimeLimitCode(data string, minutes int, code string) bool {
-	if len(code) <= 18 {
-		return false
-	}
-
-	// split code
-	start := code[:12]
-	lives := code[12:18]
-	if d, err := strconv.ParseInt(lives, 10, 0); err == nil {
-		minutes = int(d)
-	}
-
-	// right active code
-	retCode := CreateTimeLimitCode(data, minutes, start)
-	if retCode == code && minutes > 0 {
-		// check time is expired or not
-		before, _ := time.ParseInLocation("200601021504", start, time.Local)
-		now := time.Now()
-		if before.Add(time.Minute*time.Duration(minutes)).Unix() > now.Unix() {
-			return true
-		}
-	}
-
-	return false
-}
-
-// TimeLimitCodeLength default value for time limit code
-const TimeLimitCodeLength = 12 + 6 + 40
-
-// CreateTimeLimitCode create a time limit code
-// code format: 12 length date time string + 6 minutes string + 40 sha1 encoded string
-func CreateTimeLimitCode(data string, minutes int, startInf any) string {
-	format := "200601021504"
-
-	var start, end time.Time
-	var startStr, endStr string
-
-	if startInf == nil {
-		// Use now time create code
-		start = time.Now()
-		startStr = start.Format(format)
-	} else {
-		// use start string create code
-		startStr = startInf.(string)
-		start, _ = time.ParseInLocation(format, startStr, time.Local)
-		startStr = start.Format(format)
-	}
-
-	end = start.Add(time.Minute * time.Duration(minutes))
-	endStr = end.Format(format)
-
-	// create sha1 encode string
-	sh := sha1.New()
-	_, _ = sh.Write([]byte(fmt.Sprintf("%s%s%s%s%d", data, hex.EncodeToString(setting.GetGeneralTokenSigningSecret()), startStr, endStr, minutes)))
-	encoded := hex.EncodeToString(sh.Sum(nil))
-
-	code := fmt.Sprintf("%s%06d%s", startStr, minutes, encoded)
-	return code
-}
-
 // FileSize calculates the file size and generate user-friendly string.
 func FileSize(s int64) string {
 	return humanize.IBytes(uint64(s))
diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go
index 9b2b24bad0..5c09c51b15 100644
--- a/modules/base/tool_test.go
+++ b/modules/base/tool_test.go
@@ -5,7 +5,6 @@ package base
 
 import (
 	"testing"
-	"time"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -45,46 +44,6 @@ func TestBasicAuthDecode(t *testing.T) {
 	require.Error(t, err)
 }
 
-func TestVerifyTimeLimitCode(t *testing.T) {
-	tc := []struct {
-		data    string
-		minutes int
-		code    string
-		valid   bool
-	}{{
-		data:    "data",
-		minutes: 2,
-		code:    testCreateTimeLimitCode(t, "data", 2),
-		valid:   true,
-	}, {
-		data:    "abc123-ß",
-		minutes: 1,
-		code:    testCreateTimeLimitCode(t, "abc123-ß", 1),
-		valid:   true,
-	}, {
-		data:    "data",
-		minutes: 2,
-		code:    "2021012723240000005928251dac409d2c33a6eb82c63410aaad569bed",
-		valid:   false,
-	}}
-	for _, test := range tc {
-		actualValid := VerifyTimeLimitCode(test.data, test.minutes, test.code)
-		assert.Equal(t, test.valid, actualValid, "data: '%s' code: '%s' should be valid: %t", test.data, test.code, test.valid)
-	}
-}
-
-func testCreateTimeLimitCode(t *testing.T, data string, m int) string {
-	result0 := CreateTimeLimitCode(data, m, nil)
-	result1 := CreateTimeLimitCode(data, m, time.Now().Format("200601021504"))
-	result2 := CreateTimeLimitCode(data, m, time.Unix(time.Now().Unix()+int64(time.Minute)*int64(m), 0).Format("200601021504"))
-
-	assert.Equal(t, result0, result1)
-	assert.NotEqual(t, result0, result2)
-
-	assert.NotEmpty(t, result0)
-	return result0
-}
-
 func TestFileSize(t *testing.T) {
 	var size int64 = 512
 	assert.Equal(t, "512 B", FileSize(size))
diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go
index cb88560233..def5f5b8f7 100644
--- a/routers/web/auth/auth.go
+++ b/routers/web/auth/auth.go
@@ -5,8 +5,6 @@
 package auth
 
 import (
-	"crypto/subtle"
-	"encoding/hex"
 	"errors"
 	"fmt"
 	"net/http"
@@ -62,38 +60,11 @@ func autoSignIn(ctx *context.Context) (bool, error) {
 		return false, nil
 	}
 
-	lookupKey, validator, found := strings.Cut(authCookie, ":")
-	if !found {
-		return false, nil
-	}
-
-	authToken, err := auth.FindAuthToken(ctx, lookupKey)
+	u, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorization, false)
 	if err != nil {
-		if errors.Is(err, util.ErrNotExist) {
-			return false, nil
-		}
-		return false, err
+		return false, fmt.Errorf("VerifyUserAuthorizationToken: %w", err)
 	}
-
-	if authToken.IsExpired() {
-		err = auth.DeleteAuthToken(ctx, authToken)
-		return false, err
-	}
-
-	rawValidator, err := hex.DecodeString(validator)
-	if err != nil {
-		return false, err
-	}
-
-	if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 {
-		return false, nil
-	}
-
-	u, err := user_model.GetUserByID(ctx, authToken.UID)
-	if err != nil {
-		if !user_model.IsErrUserNotExist(err) {
-			return false, fmt.Errorf("GetUserByID: %w", err)
-		}
+	if u == nil {
 		return false, nil
 	}
 
@@ -635,7 +606,10 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
 			return false
 		}
 
-		mailer.SendActivateAccountMail(ctx.Locale, u)
+		if err := mailer.SendActivateAccountMail(ctx, u); err != nil {
+			ctx.ServerError("SendActivateAccountMail", err)
+			return false
+		}
 
 		ctx.Data["IsSendRegisterMail"] = true
 		ctx.Data["Email"] = u.Email
@@ -676,7 +650,10 @@ func Activate(ctx *context.Context) {
 				ctx.Data["ResendLimited"] = true
 			} else {
 				ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
-				mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer)
+				if err := mailer.SendActivateAccountMail(ctx, ctx.Doer); err != nil {
+					ctx.ServerError("SendActivateAccountMail", err)
+					return
+				}
 
 				if err := ctx.Cache.Put(cacheKey+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
 					log.Error("Set cache(MailResendLimit) fail: %v", err)
@@ -689,7 +666,12 @@ func Activate(ctx *context.Context) {
 		return
 	}
 
-	user := user_model.VerifyUserActiveCode(ctx, code)
+	user, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation, false)
+	if err != nil {
+		ctx.ServerError("VerifyUserAuthorizationToken", err)
+		return
+	}
+
 	// if code is wrong
 	if user == nil {
 		ctx.Data["IsCodeInvalid"] = true
@@ -753,7 +735,12 @@ func ActivatePost(ctx *context.Context) {
 		return
 	}
 
-	user := user_model.VerifyUserActiveCode(ctx, code)
+	user, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation, true)
+	if err != nil {
+		ctx.ServerError("VerifyUserAuthorizationToken", err)
+		return
+	}
+
 	// if code is wrong
 	if user == nil {
 		ctx.Data["IsCodeInvalid"] = true
@@ -837,23 +824,32 @@ func ActivateEmail(ctx *context.Context) {
 	code := ctx.FormString("code")
 	emailStr := ctx.FormString("email")
 
-	// Verify code.
-	if email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); email != nil {
-		if err := user_model.ActivateEmail(ctx, email); err != nil {
-			ctx.ServerError("ActivateEmail", err)
-			return
-		}
-
-		log.Trace("Email activated: %s", email.Email)
-		ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
-
-		if u, err := user_model.GetUserByID(ctx, email.UID); err != nil {
-			log.Warn("GetUserByID: %d", email.UID)
-		} else {
-			// Allow user to validate more emails
-			_ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName)
-		}
+	u, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.EmailActivation(emailStr), true)
+	if err != nil {
+		ctx.ServerError("VerifyUserAuthorizationToken", err)
+		return
 	}
+	if u == nil {
+		ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+		return
+	}
+
+	email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID)
+	if err != nil {
+		ctx.ServerError("GetEmailAddressOfUser", err)
+		return
+	}
+
+	if err := user_model.ActivateEmail(ctx, email); err != nil {
+		ctx.ServerError("ActivateEmail", err)
+		return
+	}
+
+	log.Trace("Email activated: %s", email.Email)
+	ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
+
+	// Allow user to validate more emails
+	_ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName)
 
 	// FIXME: e-mail verification does not require the user to be logged in,
 	// so this could be redirecting to the login page.
diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go
index 53494df630..7a7f29b93b 100644
--- a/routers/web/auth/password.go
+++ b/routers/web/auth/password.go
@@ -87,7 +87,10 @@ func ForgotPasswdPost(ctx *context.Context) {
 		return
 	}
 
-	mailer.SendResetPasswordMail(u)
+	if err := mailer.SendResetPasswordMail(ctx, u); err != nil {
+		ctx.ServerError("SendResetPasswordMail", err)
+		return
+	}
 
 	if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
 		log.Error("Set cache(MailResendLimit) fail: %v", err)
@@ -98,7 +101,7 @@ func ForgotPasswdPost(ctx *context.Context) {
 	ctx.HTML(http.StatusOK, tplForgotPassword)
 }
 
-func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFactor) {
+func commonResetPassword(ctx *context.Context, shouldDeleteToken bool) (*user_model.User, *auth.TwoFactor) {
 	code := ctx.FormString("code")
 
 	ctx.Data["Title"] = ctx.Tr("auth.reset_password")
@@ -114,7 +117,12 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto
 	}
 
 	// Fail early, don't frustrate the user
-	u := user_model.VerifyUserActiveCode(ctx, code)
+	u, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.PasswordReset, shouldDeleteToken)
+	if err != nil {
+		ctx.ServerError("VerifyUserAuthorizationToken", err)
+		return nil, nil
+	}
+
 	if u == nil {
 		ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true)
 		return nil, nil
@@ -146,7 +154,7 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto
 func ResetPasswd(ctx *context.Context) {
 	ctx.Data["IsResetForm"] = true
 
-	commonResetPassword(ctx)
+	commonResetPassword(ctx, false)
 	if ctx.Written() {
 		return
 	}
@@ -156,7 +164,7 @@ func ResetPasswd(ctx *context.Context) {
 
 // ResetPasswdPost response from account recovery request
 func ResetPasswdPost(ctx *context.Context) {
-	u, twofa := commonResetPassword(ctx)
+	u, twofa := commonResetPassword(ctx, true)
 	if ctx.Written() {
 		return
 	}
diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go
index 6f412aed7f..dffe41cff0 100644
--- a/routers/web/user/setting/account.go
+++ b/routers/web/user/setting/account.go
@@ -146,9 +146,15 @@ func EmailPost(ctx *context.Context) {
 				return
 			}
 			// Only fired when the primary email is inactive (Wrong state)
-			mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer)
+			if err := mailer.SendActivateAccountMail(ctx, ctx.Doer); err != nil {
+				ctx.ServerError("SendActivateAccountMail", err)
+				return
+			}
 		} else {
-			mailer.SendActivateEmailMail(ctx.Doer, email.Email)
+			if err := mailer.SendActivateEmailMail(ctx, ctx.Doer, email.Email); err != nil {
+				ctx.ServerError("SendActivateEmailMail", err)
+				return
+			}
 		}
 		address = email.Email
 
@@ -209,7 +215,10 @@ func EmailPost(ctx *context.Context) {
 
 	// Send confirmation email
 	if setting.Service.RegisterEmailConfirm {
-		mailer.SendActivateEmailMail(ctx.Doer, form.Email)
+		if err := mailer.SendActivateEmailMail(ctx, ctx.Doer, form.Email); err != nil {
+			ctx.ServerError("SendActivateEmailMail", err)
+			return
+		}
 		if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
 			log.Error("Set cache(MailResendLimit) fail: %v", err)
 		}
diff --git a/services/context/context_cookie.go b/services/context/context_cookie.go
index 39e3218d1b..3699f81071 100644
--- a/services/context/context_cookie.go
+++ b/services/context/context_cookie.go
@@ -47,7 +47,7 @@ func (ctx *Context) GetSiteCookie(name string) string {
 // SetLTACookie will generate a LTA token and add it as an cookie.
 func (ctx *Context) SetLTACookie(u *user_model.User) error {
 	days := 86400 * setting.LogInRememberDays
-	lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days)))
+	lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days)), auth_model.LongTermAuthorization)
 	if err != nil {
 		return err
 	}
diff --git a/services/doctor/dbconsistency.go b/services/doctor/dbconsistency.go
index 0903ecc2a6..c137e4ca27 100644
--- a/services/doctor/dbconsistency.go
+++ b/services/doctor/dbconsistency.go
@@ -227,6 +227,9 @@ func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) er
 		// find redirects without existing user.
 		genericOrphanCheck("Orphaned Redirects without existing redirect user",
 			"user_redirect", "user", "user_redirect.redirect_user_id=`user`.id"),
+		// find authorization tokens without existing user
+		genericOrphanCheck("Authorization token without existing User",
+			"forgejo_auth_token", "user", "forgejo_auth_token.uid=user.id"),
 	)
 
 	for _, c := range consistencyChecks {
diff --git a/services/mailer/mail.go b/services/mailer/mail.go
index df8bd59a30..c20e2c483c 100644
--- a/services/mailer/mail.go
+++ b/services/mailer/mail.go
@@ -17,6 +17,7 @@ import (
 	"time"
 
 	activities_model "code.gitea.io/gitea/models/activities"
+	auth_model "code.gitea.io/gitea/models/auth"
 	issues_model "code.gitea.io/gitea/models/issues"
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
@@ -64,7 +65,7 @@ func SendTestMail(email string) error {
 }
 
 // sendUserMail sends a mail to the user
-func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, subject, info string) {
+func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, subject, info string) error {
 	locale := translation.NewLocale(language)
 	data := map[string]any{
 		"locale":            locale,
@@ -78,47 +79,66 @@ func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, s
 	var content bytes.Buffer
 
 	if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
-		log.Error("Template: %v", err)
-		return
+		return err
 	}
 
 	msg := NewMessage(u.Email, subject, content.String())
 	msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
 
 	SendAsync(msg)
+	return nil
 }
 
 // SendActivateAccountMail sends an activation mail to the user (new user registration)
-func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
+func SendActivateAccountMail(ctx context.Context, u *user_model.User) error {
 	if setting.MailService == nil {
 		// No mail service configured
-		return
+		return nil
 	}
-	sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.activate_account"), "activate account")
+
+	locale := translation.NewLocale(u.Language)
+	code, err := u.GenerateEmailAuthorizationCode(ctx, auth_model.UserActivation)
+	if err != nil {
+		return err
+	}
+
+	return sendUserMail(locale.Language(), u, mailAuthActivate, code, locale.TrString("mail.activate_account"), "activate account")
 }
 
 // SendResetPasswordMail sends a password reset mail to the user
-func SendResetPasswordMail(u *user_model.User) {
+func SendResetPasswordMail(ctx context.Context, u *user_model.User) error {
 	if setting.MailService == nil {
 		// No mail service configured
-		return
+		return nil
 	}
+
 	locale := translation.NewLocale(u.Language)
-	sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.reset_password"), "recover account")
+	code, err := u.GenerateEmailAuthorizationCode(ctx, auth_model.PasswordReset)
+	if err != nil {
+		return err
+	}
+
+	return sendUserMail(u.Language, u, mailAuthResetPassword, code, locale.TrString("mail.reset_password"), "recover account")
 }
 
 // SendActivateEmailMail sends confirmation email to confirm new email address
-func SendActivateEmailMail(u *user_model.User, email string) {
+func SendActivateEmailMail(ctx context.Context, u *user_model.User, email string) error {
 	if setting.MailService == nil {
 		// No mail service configured
-		return
+		return nil
 	}
+
 	locale := translation.NewLocale(u.Language)
+	code, err := u.GenerateEmailAuthorizationCode(ctx, auth_model.EmailActivation(email))
+	if err != nil {
+		return err
+	}
+
 	data := map[string]any{
 		"locale":          locale,
 		"DisplayName":     u.DisplayName(),
 		"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
-		"Code":            u.GenerateEmailActivateCode(email),
+		"Code":            code,
 		"Email":           email,
 		"Language":        locale.Language(),
 	}
@@ -126,14 +146,14 @@ func SendActivateEmailMail(u *user_model.User, email string) {
 	var content bytes.Buffer
 
 	if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
-		log.Error("Template: %v", err)
-		return
+		return err
 	}
 
 	msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String())
 	msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
 
 	SendAsync(msg)
+	return nil
 }
 
 // SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
diff --git a/services/user/delete.go b/services/user/delete.go
index e890990994..647ca2c8f6 100644
--- a/services/user/delete.go
+++ b/services/user/delete.go
@@ -96,6 +96,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
 		&user_model.BlockedUser{BlockID: u.ID},
 		&user_model.BlockedUser{UserID: u.ID},
 		&actions_model.ActionRunnerToken{OwnerID: u.ID},
+		&auth_model.AuthorizationToken{UID: u.ID},
 	); err != nil {
 		return fmt.Errorf("deleteBeans: %w", err)
 	}
diff --git a/tests/integration/auth_token_test.go b/tests/integration/auth_token_test.go
index 2c39c87da2..d1fd5dda83 100644
--- a/tests/integration/auth_token_test.go
+++ b/tests/integration/auth_token_test.go
@@ -84,7 +84,7 @@ func TestLTACookie(t *testing.T) {
 	assert.True(t, found)
 	rawValidator, err := hex.DecodeString(validator)
 	require.NoError(t, err)
-	unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{LookupKey: lookupKey, HashedValidator: auth.HashValidator(rawValidator), UID: user.ID})
+	unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{LookupKey: lookupKey, HashedValidator: auth.HashValidator(rawValidator), UID: user.ID, Purpose: auth.LongTermAuthorization})
 
 	// Check if the LTA cookie it provides authentication.
 	// If LTA cookie provides authentication /user/login shouldn't return status 200.
@@ -143,7 +143,7 @@ func TestLTAExpiry(t *testing.T) {
 	assert.True(t, found)
 
 	// Ensure it's not expired.
-	lta := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
+	lta := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey, Purpose: auth.LongTermAuthorization})
 	assert.False(t, lta.IsExpired())
 
 	// Manually stub LTA's expiry.
@@ -151,7 +151,7 @@ func TestLTAExpiry(t *testing.T) {
 	require.NoError(t, err)
 
 	// Ensure it's expired.
-	lta = unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
+	lta = unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey, Purpose: auth.LongTermAuthorization})
 	assert.True(t, lta.IsExpired())
 
 	// Should return 200 OK, because LTA doesn't provide authorization anymore.
@@ -160,5 +160,5 @@ func TestLTAExpiry(t *testing.T) {
 	session.MakeRequest(t, req, http.StatusOK)
 
 	// Ensure it's deleted.
-	unittest.AssertNotExistsBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
+	unittest.AssertNotExistsBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey, Purpose: auth.LongTermAuthorization})
 }
diff --git a/tests/integration/org_team_invite_test.go b/tests/integration/org_team_invite_test.go
index d04199a2c1..2fe296e8c3 100644
--- a/tests/integration/org_team_invite_test.go
+++ b/tests/integration/org_team_invite_test.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 	"testing"
 
+	"code.gitea.io/gitea/models/auth"
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/organization"
 	"code.gitea.io/gitea/models/unittest"
@@ -293,8 +294,10 @@ func TestOrgTeamEmailInviteRedirectsNewUserWithActivation(t *testing.T) {
 	require.NoError(t, err)
 	session.jar.SetCookies(baseURL, cr.Cookies())
 
-	activateURL := fmt.Sprintf("/user/activate?code=%s", user.GenerateEmailActivateCode("doesnotexist@example.com"))
-	req = NewRequestWithValues(t, "POST", activateURL, map[string]string{
+	code, err := user.GenerateEmailAuthorizationCode(db.DefaultContext, auth.UserActivation)
+	require.NoError(t, err)
+
+	req = NewRequestWithValues(t, "POST", "/user/activate?code="+url.QueryEscape(code), map[string]string{
 		"password": "examplePassword!1",
 	})
 
diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go
index 349fe38fea..9009f7cf0b 100644
--- a/tests/integration/user_test.go
+++ b/tests/integration/user_test.go
@@ -5,12 +5,16 @@
 package integration
 
 import (
+	"bytes"
+	"encoding/hex"
 	"fmt"
 	"net/http"
+	"net/url"
 	"strings"
 	"testing"
 
 	auth_model "code.gitea.io/gitea/models/auth"
+	"code.gitea.io/gitea/models/db"
 	issues_model "code.gitea.io/gitea/models/issues"
 	repo_model "code.gitea.io/gitea/models/repo"
 	unit_model "code.gitea.io/gitea/models/unit"
@@ -20,9 +24,11 @@ import (
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/test"
 	"code.gitea.io/gitea/modules/translation"
+	"code.gitea.io/gitea/services/mailer"
 	"code.gitea.io/gitea/tests"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestViewUser(t *testing.T) {
@@ -612,3 +618,171 @@ func TestUserPronouns(t *testing.T) {
 		assert.EqualValues(t, "user2", userName)
 	})
 }
+
+func TestUserActivate(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)()
+
+	called := false
+	code := ""
+	defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+		called = true
+		assert.Len(t, msgs, 1)
+		assert.Equal(t, `doesnotexist@example.com`, msgs[0].To)
+		assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.activate_account"), msgs[0].Subject)
+
+		messageDoc := NewHTMLParser(t, bytes.NewBuffer([]byte(msgs[0].Body)))
+		link, ok := messageDoc.Find("a").Attr("href")
+		assert.True(t, ok)
+		u, err := url.Parse(link)
+		require.NoError(t, err)
+		code = u.Query()["code"][0]
+	})()
+
+	session := emptyTestSession(t)
+	req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+		"_csrf":     GetCSRF(t, session, "/user/sign_up"),
+		"user_name": "doesnotexist",
+		"email":     "doesnotexist@example.com",
+		"password":  "examplePassword!1",
+		"retype":    "examplePassword!1",
+	})
+	session.MakeRequest(t, req, http.StatusOK)
+	assert.True(t, called)
+
+	queryCode, err := url.QueryUnescape(code)
+	require.NoError(t, err)
+
+	lookupKey, validator, ok := strings.Cut(queryCode, ":")
+	assert.True(t, ok)
+
+	rawValidator, err := hex.DecodeString(validator)
+	require.NoError(t, err)
+
+	authToken, err := auth_model.FindAuthToken(db.DefaultContext, lookupKey, auth_model.UserActivation)
+	require.NoError(t, err)
+	assert.False(t, authToken.IsExpired())
+	assert.EqualValues(t, authToken.HashedValidator, auth_model.HashValidator(rawValidator))
+
+	req = NewRequest(t, "POST", "/user/activate?code="+code)
+	session.MakeRequest(t, req, http.StatusOK)
+
+	unittest.AssertNotExistsBean(t, &auth_model.AuthorizationToken{ID: authToken.ID})
+	unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "doesnotexist", IsActive: true})
+}
+
+func TestUserPasswordReset(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+	called := false
+	code := ""
+	defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+		if called {
+			return
+		}
+		called = true
+
+		assert.Len(t, msgs, 1)
+		assert.Equal(t, user2.Email, msgs[0].To)
+		assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.reset_password"), msgs[0].Subject)
+
+		messageDoc := NewHTMLParser(t, bytes.NewBuffer([]byte(msgs[0].Body)))
+		link, ok := messageDoc.Find("a").Attr("href")
+		assert.True(t, ok)
+		u, err := url.Parse(link)
+		require.NoError(t, err)
+		code = u.Query()["code"][0]
+	})()
+
+	session := emptyTestSession(t)
+	req := NewRequestWithValues(t, "POST", "/user/forgot_password", map[string]string{
+		"_csrf": GetCSRF(t, session, "/user/forgot_password"),
+		"email": user2.Email,
+	})
+	session.MakeRequest(t, req, http.StatusOK)
+	assert.True(t, called)
+
+	queryCode, err := url.QueryUnescape(code)
+	require.NoError(t, err)
+
+	lookupKey, validator, ok := strings.Cut(queryCode, ":")
+	assert.True(t, ok)
+
+	rawValidator, err := hex.DecodeString(validator)
+	require.NoError(t, err)
+
+	authToken, err := auth_model.FindAuthToken(db.DefaultContext, lookupKey, auth_model.PasswordReset)
+	require.NoError(t, err)
+	assert.False(t, authToken.IsExpired())
+	assert.EqualValues(t, authToken.HashedValidator, auth_model.HashValidator(rawValidator))
+
+	req = NewRequestWithValues(t, "POST", "/user/recover_account", map[string]string{
+		"_csrf":    GetCSRF(t, session, "/user/recover_account"),
+		"code":     code,
+		"password": "new_password",
+	})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+
+	unittest.AssertNotExistsBean(t, &auth_model.AuthorizationToken{ID: authToken.ID})
+	assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).ValidatePassword("new_password"))
+}
+
+func TestActivateEmailAddress(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)()
+
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+	called := false
+	code := ""
+	defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+		if called {
+			return
+		}
+		called = true
+
+		assert.Len(t, msgs, 1)
+		assert.Equal(t, "newemail@example.org", msgs[0].To)
+		assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.activate_email"), msgs[0].Subject)
+
+		messageDoc := NewHTMLParser(t, bytes.NewBuffer([]byte(msgs[0].Body)))
+		link, ok := messageDoc.Find("a").Attr("href")
+		assert.True(t, ok)
+		u, err := url.Parse(link)
+		require.NoError(t, err)
+		code = u.Query()["code"][0]
+	})()
+
+	session := loginUser(t, user2.Name)
+	req := NewRequestWithValues(t, "POST", "/user/settings/account/email", map[string]string{
+		"_csrf": GetCSRF(t, session, "/user/settings"),
+		"email": "newemail@example.org",
+	})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+	assert.True(t, called)
+
+	queryCode, err := url.QueryUnescape(code)
+	require.NoError(t, err)
+
+	lookupKey, validator, ok := strings.Cut(queryCode, ":")
+	assert.True(t, ok)
+
+	rawValidator, err := hex.DecodeString(validator)
+	require.NoError(t, err)
+
+	authToken, err := auth_model.FindAuthToken(db.DefaultContext, lookupKey, auth_model.EmailActivation("newemail@example.org"))
+	require.NoError(t, err)
+	assert.False(t, authToken.IsExpired())
+	assert.EqualValues(t, authToken.HashedValidator, auth_model.HashValidator(rawValidator))
+
+	req = NewRequestWithValues(t, "POST", "/user/activate_email", map[string]string{
+		"code":  code,
+		"email": "newemail@example.org",
+	})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+
+	unittest.AssertNotExistsBean(t, &auth_model.AuthorizationToken{ID: authToken.ID})
+	unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{UID: user2.ID, IsActivated: true, Email: "newemail@example.org"})
+}