diff --git a/routers/web/security_txt.go b/routers/web/security_txt.go
new file mode 100644
index 0000000000..f97e8b9c5b
--- /dev/null
+++ b/routers/web/security_txt.go
@@ -0,0 +1,24 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package web
+
+import (
+	"net/http"
+
+	"code.gitea.io/gitea/modules/context"
+)
+
+const securityTxtContent = `Contact: https://codeberg.org/forgejo/forgejo/src/branch/forgejo/CONTRIBUTING.md
+Contact: mailto:security@forgejo.org
+Expires: 2025-06-25T00:00:00Z
+Policy: https://codeberg.org/forgejo/forgejo/src/branch/forgejo/CONTRIBUTING.md
+Preferred-Languages: en
+`
+
+// returns /.well-known/security.txt content
+// RFC 9116, https://www.rfc-editor.org/rfc/rfc9116
+// https://securitytxt.org/
+func securityTxt(ctx *context.Context) {
+	ctx.PlainText(http.StatusOK, securityTxtContent)
+}
diff --git a/routers/web/security_txt_test.go b/routers/web/security_txt_test.go
new file mode 100644
index 0000000000..1edbf445d1
--- /dev/null
+++ b/routers/web/security_txt_test.go
@@ -0,0 +1,57 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package web
+
+import (
+	"regexp"
+	"testing"
+	"time"
+)
+
+func extractLines(message, pattern string) []string {
+	ptn := regexp.MustCompile(pattern)
+	return ptn.FindAllString(message, -1)
+}
+
+func TestSecurityTxt(t *testing.T) {
+	// Contact: is required and value MUST be https:// or mailto:
+	{
+		contacts := extractLines(securityTxtContent, `(?m:^Contact: .+$)`)
+		if contacts == nil {
+			t.Error("Error: \"Contact: \" field is required")
+		}
+		for _, contact := range contacts {
+			match, err := regexp.MatchString("Contact: (https:)|(mailto:)", contact)
+			if !match {
+				t.Error("Error in line ", contact, "\n\"Contact:\" field have incorrect format")
+			}
+			if err != nil {
+				t.Error("Error in line ", contact, err)
+			}
+		}
+	}
+	// Expires is required
+	{
+		expires := extractLines(securityTxtContent, `(?m:^Expires: .+$)`)
+		if expires == nil {
+			t.Error("Error: \"Expires: \" field is required")
+		}
+		if len(expires) != 1 {
+			t.Error("Error: \"Expires: \" MUST be single")
+		}
+		expRe := regexp.MustCompile(`Expires: (.*)`)
+		expSlice := expRe.FindStringSubmatch(expires[0])
+		if len(expSlice) != 2 {
+			t.Error("Error: \"Expires: \" have no value")
+		}
+		expValue := expSlice[1]
+		expTime, err := time.Parse(time.RFC3339, expValue)
+		if err != nil {
+			t.Error("Error parsing Expires value", expValue, err)
+		}
+		if time.Now().AddDate(0, 2, 0).After(expTime) {
+			t.Error("Error: Expires date time almost in the past", expTime)
+		}
+	}
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 4328272e20..feede757ba 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -351,6 +351,7 @@ func registerRoutes(m *web.Route) {
 		m.Get("/change-password", func(ctx *context.Context) {
 			ctx.Redirect(setting.AppSubURL + "/user/settings/account")
 		})
+		m.Get("/security.txt", securityTxt)
 	})
 
 	m.Group("/explore", func() {
diff --git a/tests/integration/links_test.go b/tests/integration/links_test.go
index 9136f8f915..91655833af 100644
--- a/tests/integration/links_test.go
+++ b/tests/integration/links_test.go
@@ -38,6 +38,7 @@ func TestLinksNoLogin(t *testing.T) {
 		"/user2/repo1/projects/1",
 		"/assets/img/404.png",
 		"/assets/img/500.png",
+		"/.well-known/security.txt",
 	}
 
 	for _, link := range links {