diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md
index 3fd78d483..16f8caf22 100644
--- a/docs/content/administration/config-cheat-sheet.en-us.md
+++ b/docs/content/administration/config-cheat-sheet.en-us.md
@@ -517,7 +517,6 @@ And the following unique queues:
 - `SECRET_KEY`: **\<random at every install\>**: Global secret key. This key is VERY IMPORTANT, if you lost it, the data encrypted by it (like 2FA secret) can't be decrypted anymore.
 - `SECRET_KEY_URI`: **_empty_**: Instead of defining SECRET_KEY, this option can be used to use the key stored in a file (example value: `file:/etc/gitea/secret_key`). It shouldn't be lost like SECRET_KEY.
 - `LOGIN_REMEMBER_DAYS`: **7**: Cookie lifetime, in days.
-- `COOKIE_USERNAME`: **gitea\_awesome**: Name of the cookie used to store the current username.
 - `COOKIE_REMEMBER_NAME`: **gitea\_incredible**: Name of cookie used to store authentication
    information.
 - `REVERSE_PROXY_AUTHENTICATION_USER`: **X-WEBAUTH-USER**: Header name for reverse proxy
diff --git a/docs/content/administration/config-cheat-sheet.zh-cn.md b/docs/content/administration/config-cheat-sheet.zh-cn.md
index 27f46cd8b..d54101315 100644
--- a/docs/content/administration/config-cheat-sheet.zh-cn.md
+++ b/docs/content/administration/config-cheat-sheet.zh-cn.md
@@ -506,7 +506,6 @@ Gitea 创建以下非唯一队列:
 - `SECRET_KEY`: **\<每次安装时随机生成\>**:全局服务器安全密钥。这个密钥非常重要,如果丢失将无法解密加密的数据(例如 2FA)。
 - `SECRET_KEY_URI`: **_empty_**:与定义 `SECRET_KEY` 不同,此选项可用于使用存储在文件中的密钥(示例值:`file:/etc/gitea/secret_key`)。它不应该像 `SECRET_KEY` 一样容易丢失。
 - `LOGIN_REMEMBER_DAYS`: **7**:Cookie 保存时间,单位为天。
-- `COOKIE_USERNAME`: **gitea\_awesome**:保存用户名的 Cookie 名称。
 - `COOKIE_REMEMBER_NAME`: **gitea\_incredible**:保存自动登录信息的 Cookie 名称。
 - `REVERSE_PROXY_AUTHENTICATION_USER`: **X-WEBAUTH-USER**:反向代理认证的 HTTP 头部名称,用于提供用户信息。
 - `REVERSE_PROXY_AUTHENTICATION_EMAIL`: **X-WEBAUTH-EMAIL**:反向代理认证的 HTTP 头部名称,用于提供邮箱信息。
diff --git a/models/auth/token.go b/models/auth/access_token.go
similarity index 100%
rename from models/auth/token.go
rename to models/auth/access_token.go
diff --git a/models/auth/token_scope.go b/models/auth/access_token_scope.go
similarity index 100%
rename from models/auth/token_scope.go
rename to models/auth/access_token_scope.go
diff --git a/models/auth/token_scope_test.go b/models/auth/access_token_scope_test.go
similarity index 100%
rename from models/auth/token_scope_test.go
rename to models/auth/access_token_scope_test.go
diff --git a/models/auth/token_test.go b/models/auth/access_token_test.go
similarity index 100%
rename from models/auth/token_test.go
rename to models/auth/access_token_test.go
diff --git a/models/auth/auth_token.go b/models/auth/auth_token.go
new file mode 100644
index 000000000..65f1b169e
--- /dev/null
+++ b/models/auth/auth_token.go
@@ -0,0 +1,60 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+	"context"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/timeutil"
+	"code.gitea.io/gitea/modules/util"
+
+	"xorm.io/builder"
+)
+
+var ErrAuthTokenNotExist = util.NewNotExistErrorf("auth token does not exist")
+
+type AuthToken struct { //nolint:revive
+	ID          string `xorm:"pk"`
+	TokenHash   string
+	UserID      int64              `xorm:"INDEX"`
+	ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"`
+}
+
+func init() {
+	db.RegisterModel(new(AuthToken))
+}
+
+func InsertAuthToken(ctx context.Context, t *AuthToken) error {
+	_, err := db.GetEngine(ctx).Insert(t)
+	return err
+}
+
+func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) {
+	at := &AuthToken{}
+
+	has, err := db.GetEngine(ctx).ID(id).Get(at)
+	if err != nil {
+		return nil, err
+	}
+	if !has {
+		return nil, ErrAuthTokenNotExist
+	}
+	return at, nil
+}
+
+func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error {
+	_, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t)
+	return err
+}
+
+func DeleteAuthTokenByID(ctx context.Context, id string) error {
+	_, err := db.GetEngine(ctx).ID(id).Delete(&AuthToken{})
+	return err
+}
+
+func DeleteExpiredAuthTokens(ctx context.Context) error {
+	_, err := db.GetEngine(ctx).Where(builder.Lt{"expires_unix": timeutil.TimeStampNow()}).Delete(&AuthToken{})
+	return err
+}
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index a8037fa67..4a06cdc73 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -546,6 +546,8 @@ var migrations = []Migration{
 
 	// v280 -> v281
 	NewMigration("Rename user themes", v1_22.RenameUserThemes),
+	// v281 -> v282
+	NewMigration("Add auth_token table", v1_22.CreateAuthTokenTable),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v1_22/v281.go b/models/migrations/v1_22/v281.go
new file mode 100644
index 000000000..fc1866aa8
--- /dev/null
+++ b/models/migrations/v1_22/v281.go
@@ -0,0 +1,21 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_22 //nolint
+
+import (
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/xorm"
+)
+
+func CreateAuthTokenTable(x *xorm.Engine) error {
+	type AuthToken struct {
+		ID          string `xorm:"pk"`
+		TokenHash   string
+		UserID      int64              `xorm:"INDEX"`
+		ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"`
+	}
+
+	return x.Sync(new(AuthToken))
+}
diff --git a/modules/context/context_cookie.go b/modules/context/context_cookie.go
index 9ce67a529..b6f8dadb5 100644
--- a/modules/context/context_cookie.go
+++ b/modules/context/context_cookie.go
@@ -4,16 +4,11 @@
 package context
 
 import (
-	"encoding/hex"
 	"net/http"
 	"strings"
 
 	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web/middleware"
-
-	"github.com/minio/sha256-simd"
-	"golang.org/x/crypto/pbkdf2"
 )
 
 const CookieNameFlash = "gitea_flash"
@@ -45,42 +40,3 @@ func (ctx *Context) DeleteSiteCookie(name string) {
 func (ctx *Context) GetSiteCookie(name string) string {
 	return middleware.GetSiteCookie(ctx.Req, name)
 }
-
-// GetSuperSecureCookie returns given cookie value from request header with secret string.
-func (ctx *Context) GetSuperSecureCookie(secret, name string) (string, bool) {
-	val := ctx.GetSiteCookie(name)
-	return ctx.CookieDecrypt(secret, val)
-}
-
-// CookieDecrypt returns given value from with secret string.
-func (ctx *Context) CookieDecrypt(secret, val string) (string, bool) {
-	if val == "" {
-		return "", false
-	}
-
-	text, err := hex.DecodeString(val)
-	if err != nil {
-		return "", false
-	}
-
-	key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New)
-	text, err = util.AESGCMDecrypt(key, text)
-	return string(text), err == nil
-}
-
-// SetSuperSecureCookie sets given cookie value to response header with secret string.
-func (ctx *Context) SetSuperSecureCookie(secret, name, value string, maxAge int) {
-	text := ctx.CookieEncrypt(secret, value)
-	ctx.SetSiteCookie(name, text, maxAge)
-}
-
-// CookieEncrypt encrypts a given value using the provided secret
-func (ctx *Context) CookieEncrypt(secret, value string) string {
-	key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New)
-	text, err := util.AESGCMEncrypt(key, []byte(value))
-	if err != nil {
-		panic("error encrypting cookie: " + err.Error())
-	}
-
-	return hex.EncodeToString(text)
-}
diff --git a/modules/setting/security.go b/modules/setting/security.go
index 90f614d4c..92caa05fa 100644
--- a/modules/setting/security.go
+++ b/modules/setting/security.go
@@ -19,7 +19,6 @@ var (
 	SecretKey                          string
 	InternalToken                      string // internal access token
 	LogInRememberDays                  int
-	CookieUserName                     string
 	CookieRememberName                 string
 	ReverseProxyAuthUser               string
 	ReverseProxyAuthEmail              string
@@ -104,7 +103,6 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
 	sec := rootCfg.Section("security")
 	InstallLock = HasInstallLock(rootCfg)
 	LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(7)
-	CookieUserName = sec.Key("COOKIE_USERNAME").MustString("gitea_awesome")
 	SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY")
 	if SecretKey == "" {
 		// FIXME: https://github.com/go-gitea/gitea/issues/16832
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index b7b99bd7a..867746132 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -363,6 +363,7 @@ disable_register_prompt = Registration is disabled. Please contact your site adm
 disable_register_mail = Email confirmation for registration is disabled.
 manual_activation_only = Contact your site administrator to complete activation.
 remember_me = Remember This Device
+remember_me.compromised = The login token is not valid anymore which may indicate a compromised account. Please check your account for unusual activities.
 forgot_password_title= Forgot Password
 forgot_password = Forgot password?
 sign_up_now = Need an account? Register now.
diff --git a/routers/install/install.go b/routers/install/install.go
index 185e4bf6b..5c0290d2c 100644
--- a/routers/install/install.go
+++ b/routers/install/install.go
@@ -27,12 +27,14 @@ import (
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/translation"
 	"code.gitea.io/gitea/modules/user"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/modules/web/middleware"
 	"code.gitea.io/gitea/routers/common"
+	auth_service "code.gitea.io/gitea/services/auth"
 	"code.gitea.io/gitea/services/forms"
 
 	"gitea.com/go-chi/session"
@@ -547,11 +549,13 @@ func SubmitInstall(ctx *context.Context) {
 			u, _ = user_model.GetUserByName(ctx, u.Name)
 		}
 
-		days := 86400 * setting.LogInRememberDays
-		ctx.SetSiteCookie(setting.CookieUserName, u.Name, days)
+		nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
+		if err != nil {
+			ctx.ServerError("CreateAuthTokenForUserID", err)
+			return
+		}
 
-		ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd),
-			setting.CookieRememberName, u.Name, days)
+		ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
 
 		// Auto-login for admin
 		if err = ctx.Session.Set("uid", u.ID); err != nil {
diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go
index bc3cb4907..dc0062eba 100644
--- a/routers/web/auth/2fa.go
+++ b/routers/web/auth/2fa.go
@@ -26,8 +26,7 @@ var (
 func TwoFactor(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("twofa")
 
-	// Check auto-login.
-	if checkAutoLogin(ctx) {
+	if CheckAutoLogin(ctx) {
 		return
 	}
 
@@ -99,8 +98,7 @@ func TwoFactorPost(ctx *context.Context) {
 func TwoFactorScratch(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("twofa_scratch")
 
-	// Check auto-login.
-	if checkAutoLogin(ctx) {
+	if CheckAutoLogin(ctx) {
 		return
 	}
 
diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go
index df835a2fa..1238e5275 100644
--- a/routers/web/auth/auth.go
+++ b/routers/web/auth/auth.go
@@ -43,40 +43,51 @@ const (
 	TplActivate base.TplName = "user/auth/activate"
 )
 
-// AutoSignIn reads cookie and try to auto-login.
-func AutoSignIn(ctx *context.Context) (bool, error) {
+// autoSignIn reads cookie and try to auto-login.
+func autoSignIn(ctx *context.Context) (bool, error) {
 	if !db.HasEngine {
 		return false, nil
 	}
 
-	uname := ctx.GetSiteCookie(setting.CookieUserName)
-	if len(uname) == 0 {
-		return false, nil
-	}
-
 	isSucceed := false
 	defer func() {
 		if !isSucceed {
-			log.Trace("auto-login cookie cleared: %s", uname)
-			ctx.DeleteSiteCookie(setting.CookieUserName)
 			ctx.DeleteSiteCookie(setting.CookieRememberName)
 		}
 	}()
 
-	u, err := user_model.GetUserByName(ctx, uname)
+	if err := auth.DeleteExpiredAuthTokens(ctx); err != nil {
+		log.Error("Failed to delete expired auth tokens: %v", err)
+	}
+
+	t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName))
+	if err != nil {
+		switch err {
+		case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
+			return false, nil
+		}
+		return false, err
+	}
+	if t == nil {
+		return false, nil
+	}
+
+	u, err := user_model.GetUserByID(ctx, t.UserID)
 	if err != nil {
 		if !user_model.IsErrUserNotExist(err) {
-			return false, fmt.Errorf("GetUserByName: %w", err)
+			return false, fmt.Errorf("GetUserByID: %w", err)
 		}
 		return false, nil
 	}
 
-	if val, ok := ctx.GetSuperSecureCookie(
-		base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name {
-		return false, nil
+	isSucceed = true
+
+	nt, token, err := auth_service.RegenerateAuthToken(ctx, t)
+	if err != nil {
+		return false, err
 	}
 
-	isSucceed = true
+	ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
 
 	if err := updateSession(ctx, nil, map[string]any{
 		// Set session IDs
@@ -113,11 +124,15 @@ func resetLocale(ctx *context.Context, u *user_model.User) error {
 	return nil
 }
 
-func checkAutoLogin(ctx *context.Context) bool {
+func CheckAutoLogin(ctx *context.Context) bool {
 	// Check auto-login
-	isSucceed, err := AutoSignIn(ctx)
+	isSucceed, err := autoSignIn(ctx)
 	if err != nil {
-		ctx.ServerError("AutoSignIn", err)
+		if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) {
+			ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true)
+			return false
+		}
+		ctx.ServerError("autoSignIn", err)
 		return true
 	}
 
@@ -141,8 +156,7 @@ func checkAutoLogin(ctx *context.Context) bool {
 func SignIn(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("sign_in")
 
-	// Check auto-login
-	if checkAutoLogin(ctx) {
+	if CheckAutoLogin(ctx) {
 		return
 	}
 
@@ -290,10 +304,13 @@ func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) {
 
 func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string {
 	if remember {
-		days := 86400 * setting.LogInRememberDays
-		ctx.SetSiteCookie(setting.CookieUserName, u.Name, days)
-		ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd),
-			setting.CookieRememberName, u.Name, days)
+		nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
+		if err != nil {
+			ctx.ServerError("CreateAuthTokenForUserID", err)
+			return setting.AppSubURL + "/"
+		}
+
+		ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
 	}
 
 	if err := updateSession(ctx, []string{
@@ -368,7 +385,6 @@ func getUserName(gothUser *goth.User) string {
 func HandleSignOut(ctx *context.Context) {
 	_ = ctx.Session.Flush()
 	_ = ctx.Session.Destroy(ctx.Resp, ctx.Req)
-	ctx.DeleteSiteCookie(setting.CookieUserName)
 	ctx.DeleteSiteCookie(setting.CookieRememberName)
 	ctx.Csrf.DeleteCookie(ctx)
 	middleware.DeleteRedirectToCookie(ctx.Resp)
diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go
index aa0712963..29ef772b1 100644
--- a/routers/web/auth/openid.go
+++ b/routers/web/auth/openid.go
@@ -16,7 +16,6 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
-	"code.gitea.io/gitea/modules/web/middleware"
 	"code.gitea.io/gitea/services/auth"
 	"code.gitea.io/gitea/services/forms"
 )
@@ -36,23 +35,7 @@ func SignInOpenID(ctx *context.Context) {
 		return
 	}
 
-	// Check auto-login.
-	isSucceed, err := AutoSignIn(ctx)
-	if err != nil {
-		ctx.ServerError("AutoSignIn", err)
-		return
-	}
-
-	redirectTo := ctx.FormString("redirect_to")
-	if len(redirectTo) > 0 {
-		middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
-	} else {
-		redirectTo = ctx.GetSiteCookie("redirect_to")
-	}
-
-	if isSucceed {
-		middleware.DeleteRedirectToCookie(ctx.Resp)
-		ctx.RedirectToFirst(redirectTo)
+	if CheckAutoLogin(ctx) {
 		return
 	}
 
diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go
index 9b516ce39..95c8d262a 100644
--- a/routers/web/auth/webauthn.go
+++ b/routers/web/auth/webauthn.go
@@ -26,8 +26,7 @@ var tplWebAuthn base.TplName = "user/auth/webauthn"
 func WebAuthn(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("twofa")
 
-	// Check auto-login.
-	if checkAutoLogin(ctx) {
+	if CheckAutoLogin(ctx) {
 		return
 	}
 
diff --git a/routers/web/home.go b/routers/web/home.go
index ab3fbde2c..2321b00ef 100644
--- a/routers/web/home.go
+++ b/routers/web/home.go
@@ -54,8 +54,7 @@ func Home(ctx *context.Context) {
 	}
 
 	// Check auto-login.
-	uname := ctx.GetSiteCookie(setting.CookieUserName)
-	if len(uname) != 0 {
+	if ctx.GetSiteCookie(setting.CookieRememberName) != "" {
 		ctx.Redirect(setting.AppSubURL + "/user/login")
 		return
 	}
diff --git a/routers/web/web.go b/routers/web/web.go
index d2179a000..6449f7716 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -187,7 +187,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
 
 		// Redirect to log in page if auto-signin info is provided and has not signed in.
 		if !options.SignOutRequired && !ctx.IsSigned &&
-			len(ctx.GetSiteCookie(setting.CookieUserName)) > 0 {
+			ctx.GetSiteCookie(setting.CookieRememberName) != "" {
 			if ctx.Req.URL.Path != "/user/events" {
 				middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
 			}
diff --git a/services/auth/auth_token.go b/services/auth/auth_token.go
new file mode 100644
index 000000000..6b59238c9
--- /dev/null
+++ b/services/auth/auth_token.go
@@ -0,0 +1,123 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+	"context"
+	"crypto/sha256"
+	"crypto/subtle"
+	"encoding/hex"
+	"errors"
+	"strings"
+	"time"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/timeutil"
+	"code.gitea.io/gitea/modules/util"
+)
+
+// Based on https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies
+
+// The auth token consists of two parts: ID and token hash
+// Every device login creates a new auth token with an individual id and hash.
+// If a device uses the token to login into the instance, a fresh token gets generated which has the same id but a new hash.
+
+var (
+	ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format")
+	ErrAuthTokenExpired       = util.NewInvalidArgumentErrorf("auth token has expired")
+	ErrAuthTokenInvalidHash   = util.NewInvalidArgumentErrorf("auth token is invalid")
+)
+
+func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, error) {
+	if len(value) == 0 {
+		return nil, nil
+	}
+
+	parts := strings.SplitN(value, ":", 2)
+	if len(parts) != 2 {
+		return nil, ErrAuthTokenInvalidFormat
+	}
+
+	t, err := auth_model.GetAuthTokenByID(ctx, parts[0])
+	if err != nil {
+		if errors.Is(err, util.ErrNotExist) {
+			return nil, ErrAuthTokenExpired
+		}
+		return nil, err
+	}
+
+	if t.ExpiresUnix < timeutil.TimeStampNow() {
+		return nil, ErrAuthTokenExpired
+	}
+
+	hashedToken := sha256.Sum256([]byte(parts[1]))
+
+	if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(hex.EncodeToString(hashedToken[:]))) == 0 {
+		// If an attacker steals a token and uses the token to create a new session the hash gets updated.
+		// When the victim uses the old token the hashes don't match anymore and the victim should be notified about the compromised token.
+		return nil, ErrAuthTokenInvalidHash
+	}
+
+	return t, nil
+}
+
+func RegenerateAuthToken(ctx context.Context, t *auth_model.AuthToken) (*auth_model.AuthToken, string, error) {
+	token, hash, err := generateTokenAndHash()
+	if err != nil {
+		return nil, "", err
+	}
+
+	newToken := &auth_model.AuthToken{
+		ID:          t.ID,
+		TokenHash:   hash,
+		UserID:      t.UserID,
+		ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour),
+	}
+
+	if err := auth_model.UpdateAuthTokenByID(ctx, newToken); err != nil {
+		return nil, "", err
+	}
+
+	return newToken, token, nil
+}
+
+func CreateAuthTokenForUserID(ctx context.Context, userID int64) (*auth_model.AuthToken, string, error) {
+	t := &auth_model.AuthToken{
+		UserID:      userID,
+		ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour),
+	}
+
+	var err error
+	t.ID, err = util.CryptoRandomString(10)
+	if err != nil {
+		return nil, "", err
+	}
+
+	token, hash, err := generateTokenAndHash()
+	if err != nil {
+		return nil, "", err
+	}
+
+	t.TokenHash = hash
+
+	if err := auth_model.InsertAuthToken(ctx, t); err != nil {
+		return nil, "", err
+	}
+
+	return t, token, nil
+}
+
+func generateTokenAndHash() (string, string, error) {
+	buf, err := util.CryptoRandomBytes(32)
+	if err != nil {
+		return "", "", err
+	}
+
+	token := hex.EncodeToString(buf)
+
+	hashedToken := sha256.Sum256([]byte(token))
+
+	return token, hex.EncodeToString(hashedToken[:]), nil
+}
diff --git a/services/auth/auth_token_test.go b/services/auth/auth_token_test.go
new file mode 100644
index 000000000..654275df1
--- /dev/null
+++ b/services/auth/auth_token_test.go
@@ -0,0 +1,107 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+	"testing"
+	"time"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/unittest"
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCheckAuthToken(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	t.Run("Empty", func(t *testing.T) {
+		token, err := CheckAuthToken(db.DefaultContext, "")
+		assert.NoError(t, err)
+		assert.Nil(t, token)
+	})
+
+	t.Run("InvalidFormat", func(t *testing.T) {
+		token, err := CheckAuthToken(db.DefaultContext, "dummy")
+		assert.ErrorIs(t, err, ErrAuthTokenInvalidFormat)
+		assert.Nil(t, token)
+	})
+
+	t.Run("NotFound", func(t *testing.T) {
+		token, err := CheckAuthToken(db.DefaultContext, "notexists:dummy")
+		assert.ErrorIs(t, err, ErrAuthTokenExpired)
+		assert.Nil(t, token)
+	})
+
+	t.Run("Expired", func(t *testing.T) {
+		timeutil.Set(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))
+
+		at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2)
+		assert.NoError(t, err)
+		assert.NotNil(t, at)
+		assert.NotEmpty(t, token)
+
+		timeutil.Unset()
+
+		at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token)
+		assert.ErrorIs(t, err, ErrAuthTokenExpired)
+		assert.Nil(t, at2)
+
+		assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID))
+	})
+
+	t.Run("InvalidHash", func(t *testing.T) {
+		at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2)
+		assert.NoError(t, err)
+		assert.NotNil(t, at)
+		assert.NotEmpty(t, token)
+
+		at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token+"dummy")
+		assert.ErrorIs(t, err, ErrAuthTokenInvalidHash)
+		assert.Nil(t, at2)
+
+		assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID))
+	})
+
+	t.Run("Valid", func(t *testing.T) {
+		at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2)
+		assert.NoError(t, err)
+		assert.NotNil(t, at)
+		assert.NotEmpty(t, token)
+
+		at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token)
+		assert.NoError(t, err)
+		assert.NotNil(t, at2)
+
+		assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID))
+	})
+}
+
+func TestRegenerateAuthToken(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	timeutil.Set(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))
+	defer timeutil.Unset()
+
+	at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2)
+	assert.NoError(t, err)
+	assert.NotNil(t, at)
+	assert.NotEmpty(t, token)
+
+	timeutil.Set(time.Date(2023, 1, 1, 0, 0, 1, 0, time.UTC))
+
+	at2, token2, err := RegenerateAuthToken(db.DefaultContext, at)
+	assert.NoError(t, err)
+	assert.NotNil(t, at2)
+	assert.NotEmpty(t, token2)
+
+	assert.Equal(t, at.ID, at2.ID)
+	assert.Equal(t, at.UserID, at2.UserID)
+	assert.NotEqual(t, token, token2)
+	assert.NotEqual(t, at.ExpiresUnix, at2.ExpiresUnix)
+
+	assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID))
+}
diff --git a/services/auth/main_test.go b/services/auth/main_test.go
new file mode 100644
index 000000000..b81c39a1f
--- /dev/null
+++ b/services/auth/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+	unittest.MainTest(m)
+}
diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go
index 9ae45d324..2584b88f6 100644
--- a/tests/integration/signin_test.go
+++ b/tests/integration/signin_test.go
@@ -5,11 +5,13 @@ package integration
 
 import (
 	"net/http"
+	"net/url"
 	"strings"
 	"testing"
 
 	"code.gitea.io/gitea/models/unittest"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/translation"
 	"code.gitea.io/gitea/tests"
 
@@ -57,3 +59,37 @@ func TestSignin(t *testing.T) {
 		testLoginFailed(t, s.username, s.password, s.message)
 	}
 }
+
+func TestSigninWithRememberMe(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	baseURL, _ := url.Parse(setting.AppURL)
+
+	session := emptyTestSession(t)
+	req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+		"_csrf":     GetCSRF(t, session, "/user/login"),
+		"user_name": user.Name,
+		"password":  userPassword,
+		"remember":  "on",
+	})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+
+	c := session.GetCookie(setting.CookieRememberName)
+	assert.NotNil(t, c)
+
+	session = emptyTestSession(t)
+
+	// Without session the settings page should not be reachable
+	req = NewRequest(t, "GET", "/user/settings")
+	session.MakeRequest(t, req, http.StatusSeeOther)
+
+	req = NewRequest(t, "GET", "/user/login")
+	// Set the remember me cookie for the login GET request
+	session.jar.SetCookies(baseURL, []*http.Cookie{c})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+
+	// With session the settings page should be reachable
+	req = NewRequest(t, "GET", "/user/settings")
+	session.MakeRequest(t, req, http.StatusOK)
+}