diff --git a/models/activities/action.go b/models/activities/action.go
index 7f22605d0d..c19d142628 100644
--- a/models/activities/action.go
+++ b/models/activities/action.go
@@ -580,7 +580,7 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
 
 		if repoChanged {
 			// Add feeds for user self and all watchers.
-			watchers, err = repo_model.GetWatchers(ctx, act.RepoID)
+			watchers, err = repo_model.GetWatchersExcludeBlocked(ctx, act.RepoID, act.ActUserID)
 			if err != nil {
 				return fmt.Errorf("get watchers: %w", err)
 			}
diff --git a/models/activities/notification.go b/models/activities/notification.go
index e0af2ee8bb..7da064fee7 100644
--- a/models/activities/notification.go
+++ b/models/activities/notification.go
@@ -235,6 +235,15 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
 		for _, id := range issueUnWatches {
 			toNotify.Remove(id)
 		}
+
+		// Remove users who have the notification author blocked.
+		blockedAuthorIDs, err := user_model.ListBlockedByUsersID(ctx, notificationAuthorID)
+		if err != nil {
+			return err
+		}
+		for _, id := range blockedAuthorIDs {
+			toNotify.Remove(id)
+		}
 	}
 
 	err = issue.LoadRepo(ctx)
diff --git a/models/fixtures/forgejo_blocked_user.yml b/models/fixtures/forgejo_blocked_user.yml
new file mode 100644
index 0000000000..88c378a846
--- /dev/null
+++ b/models/fixtures/forgejo_blocked_user.yml
@@ -0,0 +1,5 @@
+-
+  id: 1
+  user_id: 4
+  block_id: 1
+  created_unix: 1671607299
diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml
index 15668e6cae..ee8bdde5eb 100644
--- a/models/fixtures/repository.yml
+++ b/models/fixtures/repository.yml
@@ -37,7 +37,7 @@
   lower_name: repo2
   name: repo2
   default_branch: master
-  num_watches: 0
+  num_watches: 1
   num_stars: 1
   num_forks: 0
   num_issues: 2
diff --git a/models/fixtures/watch.yml b/models/fixtures/watch.yml
index c29f6bb65a..c6c9726cc8 100644
--- a/models/fixtures/watch.yml
+++ b/models/fixtures/watch.yml
@@ -26,4 +26,10 @@
   id: 5
   user_id: 11
   repo_id: 1
-  mode: 3 # auto 
+  mode: 3 # auto
+
+-
+  id: 6
+  user_id: 4
+  repo_id: 2
+  mode: 1 # normal
diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go
index 88bbef70c7..713db4a090 100644
--- a/models/forgejo_migrations/migrate.go
+++ b/models/forgejo_migrations/migrate.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"os"
 
+	forgejo_v1_20 "code.gitea.io/gitea/models/forgejo_migrations/v1_20"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
@@ -34,7 +35,9 @@ func NewMigration(desc string, fn func(*xorm.Engine) error) *Migration {
 
 // This is a sequence of additional Forgejo migrations.
 // Add new migrations to the bottom of the list.
-var migrations = []*Migration{}
+var migrations = []*Migration{
+	NewMigration("Add Forgejo Blocked Users table", forgejo_v1_20.AddForgejoBlockedUser),
+}
 
 // GetCurrentDBVersion returns the current Forgejo database version.
 func GetCurrentDBVersion(x *xorm.Engine) (int64, error) {
diff --git a/models/forgejo_migrations/v1_20/v1.go b/models/forgejo_migrations/v1_20/v1.go
new file mode 100644
index 0000000000..1097613655
--- /dev/null
+++ b/models/forgejo_migrations/v1_20/v1.go
@@ -0,0 +1,21 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgejo_v1_20 //nolint:revive
+
+import (
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/xorm"
+)
+
+func AddForgejoBlockedUser(x *xorm.Engine) error {
+	type ForgejoBlockedUser struct {
+		ID          int64              `xorm:"pk autoincr"`
+		BlockID     int64              `xorm:"index"`
+		UserID      int64              `xorm:"index"`
+		CreatedUnix timeutil.TimeStamp `xorm:"created"`
+	}
+
+	return x.Sync(new(ForgejoBlockedUser))
+}
diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go
index 0f2ceadc6b..14fc4cd499 100644
--- a/models/issues/issue_test.go
+++ b/models/issues/issue_test.go
@@ -429,6 +429,8 @@ func TestIssue_ResolveMentions(t *testing.T) {
 	testSuccess("user2", "repo1", "user1", []string{"nonexisting"}, []int64{})
 	// Public repo, doer
 	testSuccess("user2", "repo1", "user1", []string{"user1"}, []int64{})
+	// Public repo, blocked user
+	testSuccess("user2", "repo1", "user1", []string{"user4"}, []int64{})
 	// Private repo, team member
 	testSuccess("user17", "big_test_private_4", "user20", []string{"user2"}, []int64{2})
 	// Private repo, not a team member
diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go
index 9607b21a67..f3876702be 100644
--- a/models/issues/issue_update.go
+++ b/models/issues/issue_update.go
@@ -608,9 +608,11 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
 				teamusers := make([]*user_model.User, 0, 20)
 				if err := db.GetEngine(ctx).
 					Join("INNER", "team_user", "team_user.uid = `user`.id").
+					Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id").
 					In("`team_user`.team_id", checked).
 					And("`user`.is_active = ?", true).
 					And("`user`.prohibit_login = ?", false).
+					And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})).
 					Find(&teamusers); err != nil {
 					return nil, fmt.Errorf("get teams users: %w", err)
 				}
@@ -644,8 +646,10 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
 
 	unchecked := make([]*user_model.User, 0, len(mentionUsers))
 	if err := db.GetEngine(ctx).
+		Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id").
 		Where("`user`.is_active = ?", true).
 		And("`user`.prohibit_login = ?", false).
+		And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})).
 		In("`user`.lower_name", mentionUsers).
 		Find(&unchecked); err != nil {
 		return nil, fmt.Errorf("find mentioned users: %w", err)
diff --git a/models/issues/reaction.go b/models/issues/reaction.go
index 293dfa3fd1..a005b45364 100644
--- a/models/issues/reaction.go
+++ b/models/issues/reaction.go
@@ -218,12 +218,12 @@ type ReactionOptions struct {
 }
 
 // CreateReaction creates reaction for issue or comment.
-func CreateReaction(opts *ReactionOptions) (*Reaction, error) {
+func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
 	if !setting.UI.ReactionsLookup.Contains(opts.Type) {
 		return nil, ErrForbiddenIssueReaction{opts.Type}
 	}
 
-	ctx, committer, err := db.TxContext(db.DefaultContext)
+	ctx, committer, err := db.TxContext(ctx)
 	if err != nil {
 		return nil, err
 	}
@@ -240,25 +240,6 @@ func CreateReaction(opts *ReactionOptions) (*Reaction, error) {
 	return reaction, nil
 }
 
-// CreateIssueReaction creates a reaction on issue.
-func CreateIssueReaction(doerID, issueID int64, content string) (*Reaction, error) {
-	return CreateReaction(&ReactionOptions{
-		Type:    content,
-		DoerID:  doerID,
-		IssueID: issueID,
-	})
-}
-
-// CreateCommentReaction creates a reaction on comment.
-func CreateCommentReaction(doerID, issueID, commentID int64, content string) (*Reaction, error) {
-	return CreateReaction(&ReactionOptions{
-		Type:      content,
-		DoerID:    doerID,
-		IssueID:   issueID,
-		CommentID: commentID,
-	})
-}
-
 // DeleteReaction deletes reaction for issue or comment.
 func DeleteReaction(ctx context.Context, opts *ReactionOptions) error {
 	reaction := &Reaction{
diff --git a/models/issues/reaction_test.go b/models/issues/reaction_test.go
index ddd0e2d04c..80b4e64b95 100644
--- a/models/issues/reaction_test.go
+++ b/models/issues/reaction_test.go
@@ -19,11 +19,14 @@ import (
 func addReaction(t *testing.T, doerID, issueID, commentID int64, content string) {
 	var reaction *issues_model.Reaction
 	var err error
-	if commentID == 0 {
-		reaction, err = issues_model.CreateIssueReaction(doerID, issueID, content)
-	} else {
-		reaction, err = issues_model.CreateCommentReaction(doerID, issueID, commentID, content)
-	}
+	// NOTE: This doesn't do user blocking checking.
+	reaction, err = issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{
+		DoerID:    doerID,
+		IssueID:   issueID,
+		CommentID: commentID,
+		Type:      content,
+	})
+
 	assert.NoError(t, err)
 	assert.NotNil(t, reaction)
 }
@@ -49,7 +52,7 @@ func TestIssueAddDuplicateReaction(t *testing.T) {
 
 	addReaction(t, user1.ID, issue1ID, 0, "heart")
 
-	reaction, err := issues_model.CreateReaction(&issues_model.ReactionOptions{
+	reaction, err := issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{
 		DoerID:  user1.ID,
 		IssueID: issue1ID,
 		Type:    "heart",
diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go
index dd2ef62201..5d6e24e2a5 100644
--- a/models/repo/user_repo.go
+++ b/models/repo/user_repo.go
@@ -177,3 +177,16 @@ func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull boo
 		Limit(30).
 		Find(&users)
 }
+
+// GetWatchedRepoIDsOwnedBy returns the repos owned by a particular user watched by a particular user
+func GetWatchedRepoIDsOwnedBy(ctx context.Context, userID, ownedByUserID int64) ([]int64, error) {
+	repoIDs := make([]int64, 0, 10)
+	err := db.GetEngine(ctx).
+		Table("repository").
+		Select("`repository`.id").
+		Join("LEFT", "watch", "`repository`.id=`watch`.repo_id").
+		Where("`watch`.user_id=?", userID).
+		And("`watch`.mode<>?", WatchModeDont).
+		And("`repository`.owner_id=?", ownedByUserID).Find(&repoIDs)
+	return repoIDs, err
+}
diff --git a/models/repo/user_repo_test.go b/models/repo/user_repo_test.go
index 7816b0262a..ad794beb9b 100644
--- a/models/repo/user_repo_test.go
+++ b/models/repo/user_repo_test.go
@@ -9,6 +9,7 @@ import (
 	"code.gitea.io/gitea/models/db"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
 
 	"github.com/stretchr/testify/assert"
 )
@@ -71,3 +72,15 @@ func TestRepoGetReviewers(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Len(t, reviewers, 1)
 }
+
+func GetWatchedRepoIDsOwnedBy(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9})
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+	repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(db.DefaultContext, user1.ID, user2.ID)
+	assert.NoError(t, err)
+	assert.Len(t, repoIDs, 1)
+	assert.EqualValues(t, 1, repoIDs[0])
+}
diff --git a/models/repo/watch.go b/models/repo/watch.go
index 00f313ca7c..53b35c0e51 100644
--- a/models/repo/watch.go
+++ b/models/repo/watch.go
@@ -10,6 +10,8 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/builder"
 )
 
 // WatchMode specifies what kind of watch the user has on a repository
@@ -142,6 +144,21 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
 		Find(&watches)
 }
 
+// GetWatchersExcludeBlocked returns all watchers of given repository, whereby
+// the doer isn't blocked by one of the watchers.
+func GetWatchersExcludeBlocked(ctx context.Context, repoID, doerID int64) ([]*Watch, error) {
+	watches := make([]*Watch, 0, 10)
+	return watches, db.GetEngine(ctx).
+		Join("INNER", "`user`", "`user`.id = `watch`.user_id").
+		Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `watch`.user_id").
+		Where("`watch`.repo_id=?", repoID).
+		And("`watch`.mode<>?", WatchModeDont).
+		And("`user`.is_active=?", true).
+		And("`user`.prohibit_login=?", false).
+		And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doerID})).
+		Find(&watches)
+}
+
 // GetRepoWatchersIDs returns IDs of watchers for a given repo ID
 // but avoids joining with `user` for performance reasons
 // User permissions must be verified elsewhere if required
@@ -184,3 +201,9 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error
 	}
 	return watchRepoMode(ctx, watch, WatchModeAuto)
 }
+
+// UnwatchRepos will unwatch the user from all given repositories.
+func UnwatchRepos(ctx context.Context, userID int64, repoIDs []int64) error {
+	_, err := db.GetEngine(ctx).Where("user_id=?", userID).In("repo_id", repoIDs).Delete(&Watch{})
+	return err
+}
diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go
index 8b8c6d6250..02d0d3b0dd 100644
--- a/models/repo/watch_test.go
+++ b/models/repo/watch_test.go
@@ -43,6 +43,24 @@ func TestGetWatchers(t *testing.T) {
 	assert.Len(t, watches, 0)
 }
 
+func TestGetWatchersExcludeBlocked(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	watches, err := repo_model.GetWatchersExcludeBlocked(db.DefaultContext, repo.ID, 1)
+	assert.NoError(t, err)
+
+	// One watchers are inactive and one watcher is blocked, thus minus 2
+	assert.Len(t, watches, repo.NumWatches-2)
+	for _, watch := range watches {
+		assert.EqualValues(t, repo.ID, watch.RepoID)
+	}
+
+	watches, err = repo_model.GetWatchersExcludeBlocked(db.DefaultContext, unittest.NonexistentID, 1)
+	assert.NoError(t, err)
+	assert.Len(t, watches, 0)
+}
+
 func TestRepository_GetWatchers(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
@@ -137,3 +155,16 @@ func TestWatchRepoMode(t *testing.T) {
 	assert.NoError(t, repo_model.WatchRepoMode(12, 1, repo_model.WatchModeNone))
 	unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0)
 }
+
+func TestUnwatchRepos(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
+
+	err := repo_model.UnwatchRepos(db.DefaultContext, 4, []int64{1, 2})
+	assert.NoError(t, err)
+
+	unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
+	unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
+}
diff --git a/models/user/block.go b/models/user/block.go
new file mode 100644
index 0000000000..189cacc2a2
--- /dev/null
+++ b/models/user/block.go
@@ -0,0 +1,91 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+	"context"
+	"errors"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/timeutil"
+)
+
+// ErrBlockedByUser defines an error stating that the user is not allowed to perform the action because they are blocked.
+var ErrBlockedByUser = errors.New("user is blocked by the poster or repository owner")
+
+// BlockedUser represents a blocked user entry.
+type BlockedUser struct {
+	ID int64 `xorm:"pk autoincr"`
+	// UID of the one who got blocked.
+	BlockID int64 `xorm:"index"`
+	// UID of the one who did the block action.
+	UserID int64 `xorm:"index"`
+
+	CreatedUnix timeutil.TimeStamp `xorm:"created"`
+}
+
+// TableName provides the real table name
+func (*BlockedUser) TableName() string {
+	return "forgejo_blocked_user"
+}
+
+func init() {
+	db.RegisterModel(new(BlockedUser))
+}
+
+// IsBlocked returns if userID has blocked blockID.
+func IsBlocked(ctx context.Context, userID, blockID int64) bool {
+	has, _ := db.GetEngine(ctx).Exist(&BlockedUser{UserID: userID, BlockID: blockID})
+	return has
+}
+
+// IsBlockedMultiple returns if one of the userIDs has blocked blockID.
+func IsBlockedMultiple(ctx context.Context, userIDs []int64, blockID int64) bool {
+	has, _ := db.GetEngine(ctx).In("user_id", userIDs).Exist(&BlockedUser{BlockID: blockID})
+	return has
+}
+
+// UnblockUser removes the blocked user entry.
+func UnblockUser(ctx context.Context, userID, blockID int64) error {
+	_, err := db.GetEngine(ctx).Delete(&BlockedUser{UserID: userID, BlockID: blockID})
+	return err
+}
+
+// CountBlockedUsers returns the number of users the user has blocked.
+func CountBlockedUsers(ctx context.Context, userID int64) (int64, error) {
+	return db.GetEngine(ctx).Where("user_id=?", userID).Count(&BlockedUser{})
+}
+
+// ListBlockedUsers returns the users that the user has blocked.
+// The created_unix field of the user struct is overridden by the creation_unix
+// field of blockeduser.
+func ListBlockedUsers(ctx context.Context, userID int64, opts db.ListOptions) ([]*User, error) {
+	sess := db.GetEngine(ctx).
+		Select("`forgejo_blocked_user`.created_unix, `user`.*").
+		Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id").
+		Where("`forgejo_blocked_user`.user_id=?", userID)
+
+	if opts.Page > 0 {
+		sess = db.SetSessionPagination(sess, &opts)
+		users := make([]*User, 0, opts.PageSize)
+
+		return users, sess.Find(&users)
+	}
+
+	users := make([]*User, 0, 8)
+	return users, sess.Find(&users)
+}
+
+// ListBlockedByUsersID returns the ids of the users that blocked the user.
+func ListBlockedByUsersID(ctx context.Context, userID int64) ([]int64, error) {
+	users := make([]int64, 0, 8)
+	err := db.GetEngine(ctx).
+		Table("user").
+		Select("`user`.id").
+		Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.user_id").
+		Where("`forgejo_blocked_user`.block_id=?", userID).
+		Find(&users)
+
+	return users, err
+}
diff --git a/models/user/block_test.go b/models/user/block_test.go
new file mode 100644
index 0000000000..629c0c975a
--- /dev/null
+++ b/models/user/block_test.go
@@ -0,0 +1,77 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user_test
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIsBlocked(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1))
+
+	// Simple test cases to ensure the function can also respond with false.
+	assert.False(t, user_model.IsBlocked(db.DefaultContext, 1, 1))
+	assert.False(t, user_model.IsBlocked(db.DefaultContext, 3, 2))
+}
+
+func TestIsBlockedMultiple(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4}, 1))
+	assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4, 3, 4, 5}, 1))
+
+	// Simple test cases to ensure the function can also respond with false.
+	assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{1}, 1))
+	assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{3, 4, 1}, 2))
+}
+
+func TestUnblockUser(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1))
+
+	assert.NoError(t, user_model.UnblockUser(db.DefaultContext, 4, 1))
+
+	// Simple test cases to ensure the function can also respond with false.
+	assert.False(t, user_model.IsBlocked(db.DefaultContext, 4, 1))
+}
+
+func TestListBlockedUsers(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	blockedUsers, err := user_model.ListBlockedUsers(db.DefaultContext, 4, db.ListOptions{})
+	assert.NoError(t, err)
+	if assert.Len(t, blockedUsers, 1) {
+		assert.EqualValues(t, 1, blockedUsers[0].ID)
+		// The function returns the created Unix of the block, not that of the user.
+		assert.EqualValues(t, 1671607299, blockedUsers[0].CreatedUnix)
+	}
+}
+
+func TestListBlockedByUsersID(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	blockedByUserIDs, err := user_model.ListBlockedByUsersID(db.DefaultContext, 1)
+	assert.NoError(t, err)
+	if assert.Len(t, blockedByUserIDs, 1) {
+		assert.EqualValues(t, 4, blockedByUserIDs[0])
+	}
+}
+
+func TestCountBlockedUsers(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	count, err := user_model.CountBlockedUsers(db.DefaultContext, 4)
+	assert.NoError(t, err)
+	assert.EqualValues(t, 1, count)
+
+	count, err = user_model.CountBlockedUsers(db.DefaultContext, 1)
+	assert.NoError(t, err)
+	assert.EqualValues(t, 0, count)
+}
diff --git a/models/user/follow.go b/models/user/follow.go
index 7efecc26a7..5b3ff489ca 100644
--- a/models/user/follow.go
+++ b/models/user/follow.go
@@ -4,6 +4,8 @@
 package user
 
 import (
+	"context"
+
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/modules/timeutil"
 )
@@ -22,17 +24,26 @@ func init() {
 
 // IsFollowing returns true if user is following followID.
 func IsFollowing(userID, followID int64) bool {
-	has, _ := db.GetEngine(db.DefaultContext).Get(&Follow{UserID: userID, FollowID: followID})
+	return IsFollowingCtx(db.DefaultContext, userID, followID)
+}
+
+// IsFollowingCtx returns true if user is following followID.
+func IsFollowingCtx(ctx context.Context, userID, followID int64) bool {
+	has, _ := db.GetEngine(ctx).Get(&Follow{UserID: userID, FollowID: followID})
 	return has
 }
 
 // FollowUser marks someone be another's follower.
-func FollowUser(userID, followID int64) (err error) {
-	if userID == followID || IsFollowing(userID, followID) {
+func FollowUser(ctx context.Context, userID, followID int64) (err error) {
+	if userID == followID || IsFollowingCtx(ctx, userID, followID) {
 		return nil
 	}
 
-	ctx, committer, err := db.TxContext(db.DefaultContext)
+	if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) {
+		return ErrBlockedByUser
+	}
+
+	ctx, committer, err := db.TxContext(ctx)
 	if err != nil {
 		return err
 	}
@@ -53,12 +64,12 @@ func FollowUser(userID, followID int64) (err error) {
 }
 
 // UnfollowUser unmarks someone as another's follower.
-func UnfollowUser(userID, followID int64) (err error) {
-	if userID == followID || !IsFollowing(userID, followID) {
+func UnfollowUser(ctx context.Context, userID, followID int64) (err error) {
+	if userID == followID || !IsFollowingCtx(ctx, userID, followID) {
 		return nil
 	}
 
-	ctx, committer, err := db.TxContext(db.DefaultContext)
+	ctx, committer, err := db.TxContext(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/models/user/user_test.go b/models/user/user_test.go
index 44eaf63556..e21e9ad52e 100644
--- a/models/user/user_test.go
+++ b/models/user/user_test.go
@@ -449,13 +449,19 @@ func TestFollowUser(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
 	testSuccess := func(followerID, followedID int64) {
-		assert.NoError(t, user_model.FollowUser(followerID, followedID))
+		assert.NoError(t, user_model.FollowUser(db.DefaultContext, followerID, followedID))
 		unittest.AssertExistsAndLoadBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID})
 	}
 	testSuccess(4, 2)
 	testSuccess(5, 2)
 
-	assert.NoError(t, user_model.FollowUser(2, 2))
+	assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2))
+
+	// Blocked user.
+	assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 1, 4))
+	assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 4, 1))
+	unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 1, FollowID: 4})
+	unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 4, FollowID: 1})
 
 	unittest.CheckConsistencyFor(t, &user_model.User{})
 }
@@ -464,7 +470,7 @@ func TestUnfollowUser(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
 	testSuccess := func(followerID, followedID int64) {
-		assert.NoError(t, user_model.UnfollowUser(followerID, followedID))
+		assert.NoError(t, user_model.UnfollowUser(db.DefaultContext, followerID, followedID))
 		unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID})
 	}
 	testSuccess(4, 2)
diff --git a/modules/repository/collaborator.go b/modules/repository/collaborator.go
index f5cdc35045..4099a178f2 100644
--- a/modules/repository/collaborator.go
+++ b/modules/repository/collaborator.go
@@ -14,6 +14,10 @@ import (
 )
 
 func AddCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User) error {
+	if user_model.IsBlocked(ctx, repo.OwnerID, u.ID) || user_model.IsBlocked(ctx, u.ID, repo.OwnerID) {
+		return user_model.ErrBlockedByUser
+	}
+
 	return db.WithTx(ctx, func(ctx context.Context) error {
 		collaboration := &repo_model.Collaboration{
 			RepoID: repo.ID,
diff --git a/modules/repository/collaborator_test.go b/modules/repository/collaborator_test.go
index c772806819..b051aa2ac7 100644
--- a/modules/repository/collaborator_test.go
+++ b/modules/repository/collaborator_test.go
@@ -33,6 +33,33 @@ func TestRepository_AddCollaborator(t *testing.T) {
 	testSuccess(3, 4)
 }
 
+func TestRepository_AddCollaborator_IsBlocked(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	testSuccess := func(repoID, userID int64) {
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
+		assert.NoError(t, repo.LoadOwner(db.DefaultContext))
+		user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+
+		// Owner blocked user.
+		unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID})
+		assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser)
+		unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
+		_, err := db.DeleteByBean(db.DefaultContext, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID})
+		assert.NoError(t, err)
+
+		// User has owner blocked.
+		unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: userID, BlockID: repo.OwnerID})
+		assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser)
+		unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
+	}
+	// Ensure idempotency (public repository).
+	testSuccess(1, 4)
+	testSuccess(1, 4)
+	// Add collaborator to private repository.
+	testSuccess(3, 4)
+}
+
 func TestRepoPermissionPublicNonOrgRepo(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
diff --git a/modules/structs/moderation.go b/modules/structs/moderation.go
new file mode 100644
index 0000000000..c1e55085a7
--- /dev/null
+++ b/modules/structs/moderation.go
@@ -0,0 +1,13 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package structs
+
+import "time"
+
+// BlockedUser represents a blocked user.
+type BlockedUser struct {
+	BlockID int64 `json:"block_id"`
+	// swagger:strfmt date-time
+	Created time.Time `json:"created_at"`
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 3256d2ba91..674ed2ea9e 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -588,6 +588,12 @@ joined_on = Joined on %s
 repositories = Repositories
 activity = Public Activity
 followers = Followers
+block_user = Block User
+block_user.detail = Please understand that if you block this user, other actions will be taken. Such as:
+block_user.detail_1 = You are being unfollowed from this user.
+block_user.detail_2 = This user cannot interact with your repositories, created issues and comments.
+block_user.detail_3 = This user cannot add you as a collaborator, nor can you add them as a collaborator.
+follow_blocked_user = You cannot follow this user because you have blocked this user or this user has blocked you.
 starred = Starred Repositories
 watched = Watched Repositories
 code = Code
@@ -596,6 +602,8 @@ overview = Overview
 following = Following
 follow = Follow
 unfollow = Unfollow
+block = Block
+unblock = Unblock
 heatmap.loading = Loading Heatmap…
 user_bio = Biography
 disabled_public_activity = This user has disabled the public visibility of the activity.
@@ -625,6 +633,7 @@ account_link = Linked Accounts
 organization = Organizations
 uid = Uid
 webauthn = Security Keys
+blocked_users = Blocked Users
 
 public_profile = Public Profile
 biography_placeholder = Tell us a little bit about yourself
@@ -893,6 +902,7 @@ hooks.desc = Add webhooks which will be triggered for <strong>all repositories</
 
 orgs_none = You are not a member of any organizations.
 repos_none = You do not own any repositories
+blocked_users_none = You haven't blocked any users.
 
 delete_account = Delete Your Account
 delete_prompt = This operation will permanently delete your user account. It <strong>CANNOT</strong> be undone.
@@ -915,6 +925,10 @@ visibility.limited_tooltip = Visible to authenticated users only
 visibility.private = Private
 visibility.private_tooltip = Visible only to organization members
 
+blocked_since = Blocked since %s
+user_unblock_success = The user has been unblocked successfully.
+user_block_success = The user has been blocked successfully.
+
 [repo]
 new_repo_helper = A repository contains all project files, including revision history.  Already have it elsewhere? <a href="%s">Migrate repository.</a>
 owner = Owner
@@ -1641,6 +1655,7 @@ issues.content_history.delete_from_history = Delete from history
 issues.content_history.delete_from_history_confirm = Delete from history?
 issues.content_history.options = Options
 issues.reference_link = Reference: %s
+issues.blocked_by_user = You cannot create a issue on this repository because you are blocked by the repository owner.
 
 compare.compare_base = base
 compare.compare_head = compare
@@ -1720,6 +1735,7 @@ pulls.reject_count_n = "%d change requests"
 pulls.waiting_count_1 = "%d waiting review"
 pulls.waiting_count_n = "%d waiting reviews"
 pulls.wrong_commit_id = "commit id must be a commit id on the target branch"
+pulls.blocked_by_user = You cannot create a pull request on this repository because you are blocked by the repository owner.
 
 pulls.no_merge_desc = This pull request cannot be merged because all repository merge options are disabled.
 pulls.no_merge_helper = Enable merge options in the repository settings or merge the pull request manually.
@@ -2077,6 +2093,8 @@ settings.add_collaborator_success = The collaborator has been added.
 settings.add_collaborator_inactive_user = Cannot add an inactive user as a collaborator.
 settings.add_collaborator_owner = Cannot add an owner as a collaborator.
 settings.add_collaborator_duplicate = The collaborator is already added to this repository.
+settings.add_collaborator_blocked_our = Cannot add the collaborator, because the repository owner has blocked them.
+settings.add_collaborator_blocked_them = Cannot add the collaborator, because they have blocked the repository owner.
 settings.delete_collaborator = Remove
 settings.collaborator_deletion = Remove Collaborator
 settings.collaborator_deletion_desc = Removing a collaborator will revoke their access to this repository. Continue?
@@ -2536,6 +2554,7 @@ team_access_desc = Repository access
 team_permission_desc = Permission
 team_unit_desc = Allow Access to Repository Sections
 team_unit_disabled = (Disabled)
+follow_blocked_user = You cannot follow this organisation because this organisation has blocked you.
 
 form.name_reserved = The organization name "%s" is reserved.
 form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name.
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 397eb10558..3cedbfd708 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -900,6 +900,12 @@ func Routes() *web.Route {
 					Delete(user.DeleteHook)
 			}, reqWebhooksEnabled())
 
+			m.Group("", func() {
+				m.Get("/list_blocked", user.ListBlockedUsers)
+				m.Put("/block/{username}", user.BlockUser)
+				m.Put("/unblock/{username}", user.UnblockUser)
+			})
+
 			m.Group("/avatar", func() {
 				m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar)
 				m.Delete("", user.DeleteAvatar)
@@ -1328,6 +1334,12 @@ func Routes() *web.Route {
 				m.Delete("", org.DeleteAvatar)
 			}, reqToken(), reqOrgOwnership())
 			m.Get("/activities/feeds", org.ListOrgActivityFeeds)
+
+			m.Group("", func() {
+				m.Get("/list_blocked", reqToken(), reqOrgOwnership(), org.ListBlockedUsers)
+				m.Put("/block/{username}", reqToken(), reqOrgOwnership(), org.BlockUser)
+				m.Put("/unblock/{username}", reqToken(), reqOrgOwnership(), org.UnblockUser)
+			}, reqToken(), reqOrgOwnership())
 		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true))
 		m.Group("/teams/{teamid}", func() {
 			m.Combo("").Get(reqToken(), org.GetTeam).
diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go
index b0666c87f8..28afc8fc02 100644
--- a/routers/api/v1/org/org.go
+++ b/routers/api/v1/org/org.go
@@ -447,3 +447,95 @@ func ListOrgActivityFeeds(ctx *context.APIContext) {
 
 	ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
 }
+
+// ListBlockedUsers list the organization's blocked users.
+func ListBlockedUsers(ctx *context.APIContext) {
+	// swagger:operation GET /orgs/{org}/list_blocked organization orgListBlockedUsers
+	// ---
+	// summary: List the organization's blocked users
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the org
+	//   type: string
+	//   required: true
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/BlockedUserList"
+
+	utils.ListUserBlockedUsers(ctx, ctx.ContextUser)
+}
+
+// BlockUser blocks a user from the organization.
+func BlockUser(ctx *context.APIContext) {
+	// swagger:operation PUT /orgs/{org}/block/{username} organization orgBlockUser
+	// ---
+	// summary: Blocks a user from the organization
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the org
+	//   type: string
+	//   required: true
+	// - name: username
+	//   in: path
+	//   description: username of the user
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	user := user.GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	utils.BlockUser(ctx, ctx.Org.Organization.AsUser(), user)
+}
+
+// UnblockUser unblocks a user from the organization.
+func UnblockUser(ctx *context.APIContext) {
+	// swagger:operation PUT /orgs/{org}/unblock/{username} organization orgUnblockUser
+	// ---
+	// summary: Unblock a user from the organization
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the org
+	//   type: string
+	//   required: true
+	// - name: username
+	//   in: path
+	//   description: username of the user
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	user := user.GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	utils.UnblockUser(ctx, ctx.Org.Organization.AsUser(), user)
+}
diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go
index 942d4c799f..cfa45e9995 100644
--- a/routers/api/v1/repo/collaborators.go
+++ b/routers/api/v1/repo/collaborators.go
@@ -156,6 +156,8 @@ func AddCollaborator(ctx *context.APIContext) {
 	//     "$ref": "#/responses/empty"
 	//   "422":
 	//     "$ref": "#/responses/validationError"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 
 	form := web.GetForm(ctx).(*api.AddCollaboratorOption)
 
@@ -175,7 +177,11 @@ func AddCollaborator(ctx *context.APIContext) {
 	}
 
 	if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil {
-		ctx.Error(http.StatusInternalServerError, "AddCollaborator", err)
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "AddCollaborator", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "AddCollaborator", err)
+		}
 		return
 	}
 
diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go
index 861e63a9b8..6eb7d9dc56 100644
--- a/routers/api/v1/repo/issue.go
+++ b/routers/api/v1/repo/issue.go
@@ -5,6 +5,7 @@
 package repo
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strconv"
@@ -678,7 +679,10 @@ func CreateIssue(ctx *context.APIContext) {
 	}
 
 	if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil {
-		if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "BlockedByUser", err)
+			return
+		} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
 			ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
 			return
 		}
diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go
index 1f8e16147f..feca265008 100644
--- a/routers/api/v1/repo/issue_comment.go
+++ b/routers/api/v1/repo/issue_comment.go
@@ -364,7 +364,11 @@ func CreateIssueComment(ctx *context.APIContext) {
 
 	comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil)
 	if err != nil {
-		ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err)
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "CreateIssueComment", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err)
+		}
 		return
 	}
 
diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go
index 49e5a74aa3..a5e77d09f3 100644
--- a/routers/api/v1/repo/issue_reaction.go
+++ b/routers/api/v1/repo/issue_reaction.go
@@ -8,11 +8,13 @@ import (
 	"net/http"
 
 	issues_model "code.gitea.io/gitea/models/issues"
+	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/context"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	"code.gitea.io/gitea/services/convert"
+	issue_service "code.gitea.io/gitea/services/issue"
 )
 
 // GetIssueCommentReactions list reactions of a comment from an issue
@@ -196,9 +198,9 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp
 
 	if isCreateType {
 		// PostIssueCommentReaction part
-		reaction, err := issues_model.CreateCommentReaction(ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction)
+		reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Reaction)
 		if err != nil {
-			if issues_model.IsErrForbiddenIssueReaction(err) {
+			if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) {
 				ctx.Error(http.StatusForbidden, err.Error(), err)
 			} else if issues_model.IsErrReactionAlreadyExist(err) {
 				ctx.JSON(http.StatusOK, api.Reaction{
@@ -406,9 +408,9 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i
 
 	if isCreateType {
 		// PostIssueReaction part
-		reaction, err := issues_model.CreateIssueReaction(ctx.Doer.ID, issue.ID, form.Reaction)
+		reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Reaction)
 		if err != nil {
-			if issues_model.IsErrForbiddenIssueReaction(err) {
+			if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) {
 				ctx.Error(http.StatusForbidden, err.Error(), err)
 			} else if issues_model.IsErrReactionAlreadyExist(err) {
 				ctx.JSON(http.StatusOK, api.Reaction{
diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go
index a507c1f44d..91e6527d85 100644
--- a/routers/api/v1/repo/pull.go
+++ b/routers/api/v1/repo/pull.go
@@ -418,7 +418,10 @@ func CreatePullRequest(ctx *context.APIContext) {
 	}
 
 	if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil {
-		if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "BlockedByUser", err)
+			return
+		} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
 			ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
 			return
 		}
diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go
index 3e23aa4d5a..263e335873 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -414,3 +414,10 @@ type swaggerRepoNewIssuePinsAllowed struct {
 	// in:body
 	Body api.NewIssuePinsAllowed `json:"body"`
 }
+
+// BlockedUserList
+// swagger:response BlockedUserList
+type swaggerBlockedUserList struct {
+	// in:body
+	Body []api.BlockedUser `json:"body"`
+}
diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go
index bc03b22ea7..bc1e6724f7 100644
--- a/routers/api/v1/user/follower.go
+++ b/routers/api/v1/user/follower.go
@@ -5,6 +5,7 @@
 package user
 
 import (
+	"errors"
 	"net/http"
 
 	user_model "code.gitea.io/gitea/models/user"
@@ -217,8 +218,14 @@ func Follow(ctx *context.APIContext) {
 	// responses:
 	//   "204":
 	//     "$ref": "#/responses/empty"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 
-	if err := user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
+	if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Error(http.StatusForbidden, "BlockedByUser", err)
+			return
+		}
 		ctx.Error(http.StatusInternalServerError, "FollowUser", err)
 		return
 	}
@@ -240,7 +247,7 @@ func Unfollow(ctx *context.APIContext) {
 	//   "204":
 	//     "$ref": "#/responses/empty"
 
-	if err := user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
+	if err := user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
 		ctx.Error(http.StatusInternalServerError, "UnfollowUser", err)
 		return
 	}
diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go
index 2a2361be67..595ca04d62 100644
--- a/routers/api/v1/user/user.go
+++ b/routers/api/v1/user/user.go
@@ -202,3 +202,80 @@ func ListUserActivityFeeds(ctx *context.APIContext) {
 
 	ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
 }
+
+// ListBlockedUsers list the authenticated user's blocked users.
+func ListBlockedUsers(ctx *context.APIContext) {
+	// swagger:operation GET /user/list_blocked user userListBlockedUsers
+	// ---
+	// summary: List the authenticated user's blocked users
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/BlockedUserList"
+
+	utils.ListUserBlockedUsers(ctx, ctx.Doer)
+}
+
+// BlockUser blocks a user from the doer.
+func BlockUser(ctx *context.APIContext) {
+	// swagger:operation PUT /user/block/{username} user userBlockUser
+	// ---
+	// summary: Blocks a user from the doer.
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: username
+	//   in: path
+	//   description: username of the user
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	user := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	utils.BlockUser(ctx, ctx.Doer, user)
+}
+
+// UnblockUser unblocks a user from the doer.
+func UnblockUser(ctx *context.APIContext) {
+	// swagger:operation PUT /user/unblock/{username} user userUnblockUser
+	// ---
+	// summary: Unblocks a user from the doer.
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: username
+	//   in: path
+	//   description: username of the user
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	user := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	utils.UnblockUser(ctx, ctx.Doer, user)
+}
diff --git a/routers/api/v1/utils/block.go b/routers/api/v1/utils/block.go
new file mode 100644
index 0000000000..187d69044e
--- /dev/null
+++ b/routers/api/v1/utils/block.go
@@ -0,0 +1,65 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package utils
+
+import (
+	"net/http"
+
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/context"
+	api "code.gitea.io/gitea/modules/structs"
+	user_service "code.gitea.io/gitea/services/user"
+)
+
+// ListUserBlockedUsers lists the blocked users of the provided doer.
+func ListUserBlockedUsers(ctx *context.APIContext, doer *user_model.User) {
+	count, err := user_model.CountBlockedUsers(ctx, doer.ID)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	blockedUsers, err := user_model.ListBlockedUsers(ctx, doer.ID, GetListOptions(ctx))
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	apiBlockedUsers := make([]*api.BlockedUser, len(blockedUsers))
+	for i, blockedUser := range blockedUsers {
+		apiBlockedUsers[i] = &api.BlockedUser{
+			BlockID: blockedUser.ID,
+			Created: blockedUser.CreatedUnix.AsTime(),
+		}
+		if err != nil {
+			ctx.InternalServerError(err)
+			return
+		}
+	}
+
+	ctx.SetTotalCountHeader(count)
+	ctx.JSON(http.StatusOK, apiBlockedUsers)
+}
+
+// BlockUser blocks the blockUser from the doer.
+func BlockUser(ctx *context.APIContext, doer, blockUser *user_model.User) {
+	err := user_service.BlockUser(ctx, doer.ID, blockUser.ID)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+// UnblockUser unblocks the blockUser from the doer.
+func UnblockUser(ctx *context.APIContext, doer, blockUser *user_model.User) {
+	err := user_model.UnblockUser(ctx, doer.ID, blockUser.ID)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/web/org/setting/blocked_users.go b/routers/web/org/setting/blocked_users.go
new file mode 100644
index 0000000000..ebec1f1df5
--- /dev/null
+++ b/routers/web/org/setting/blocked_users.go
@@ -0,0 +1,62 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+	"net/http"
+	"strings"
+
+	"code.gitea.io/gitea/models/db"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/routers/utils"
+	user_service "code.gitea.io/gitea/services/user"
+)
+
+const tplBlockedUsers = "org/settings/blocked_users"
+
+// BlockedUsers renders the blocked users page.
+func BlockedUsers(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
+	ctx.Data["PageIsSettingsBlockedUsers"] = true
+
+	blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID, db.ListOptions{})
+	if err != nil {
+		ctx.ServerError("ListBlockedUsers", err)
+		return
+	}
+
+	ctx.Data["BlockedUsers"] = blockedUsers
+
+	ctx.HTML(http.StatusOK, tplBlockedUsers)
+}
+
+// BlockedUsersBlock blocks a particular user from the organization.
+func BlockedUsersBlock(ctx *context.Context) {
+	uname := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("uname")))
+	u, err := user_model.GetUserByName(ctx, uname)
+	if err != nil {
+		ctx.ServerError("GetUserByName", err)
+		return
+	}
+
+	if err := user_service.BlockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil {
+		ctx.ServerError("BlockUser", err)
+		return
+	}
+
+	ctx.Flash.Success(ctx.Tr("settings.user_block_success"))
+	ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
+}
+
+// BlockedUsersUnblock unblocks a particular user from the organization.
+func BlockedUsersUnblock(ctx *context.Context) {
+	if err := user_model.UnblockUser(ctx, ctx.Org.Organization.ID, ctx.FormInt64("user_id")); err != nil {
+		ctx.ServerError("BlockUser", err)
+		return
+	}
+
+	ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
+	ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
+}
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 7bddabd10a..b9826afdb1 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -1199,7 +1199,10 @@ func NewIssuePost(ctx *context.Context) {
 	}
 
 	if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil {
-		if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.RenderWithErr(ctx.Tr("repo.issues.blocked_by_user"), tplIssueNew, form)
+			return
+		} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
 			ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
 			return
 		}
@@ -3164,7 +3167,7 @@ func ChangeIssueReaction(ctx *context.Context) {
 
 	switch ctx.Params(":action") {
 	case "react":
-		reaction, err := issues_model.CreateIssueReaction(ctx.Doer.ID, issue.ID, form.Content)
+		reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content)
 		if err != nil {
 			if issues_model.IsErrForbiddenIssueReaction(err) {
 				ctx.ServerError("ChangeIssueReaction", err)
@@ -3266,7 +3269,7 @@ func ChangeCommentReaction(ctx *context.Context) {
 
 	switch ctx.Params(":action") {
 	case "react":
-		reaction, err := issues_model.CreateCommentReaction(ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content)
+		reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Content)
 		if err != nil {
 			if issues_model.IsErrForbiddenIssueReaction(err) {
 				ctx.ServerError("ChangeIssueReaction", err)
diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go
index d76e90bf24..e9f1c07fdc 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -1414,7 +1414,11 @@ func CompareAndPullRequestPost(ctx *context.Context) {
 	// instead of 500.
 
 	if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil {
-		if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
+		if errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.Flash.Error(ctx.Tr("repo.pulls.blocked_by_user"))
+			ctx.Redirect(ctx.Link)
+			return
+		} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
 			ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
 			return
 		} else if git.IsErrPushRejected(err) {
diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go
index b708422cbd..0738d72f2f 100644
--- a/routers/web/repo/setting/collaboration.go
+++ b/routers/web/repo/setting/collaboration.go
@@ -4,6 +4,7 @@
 package setting
 
 import (
+	"errors"
 	"net/http"
 	"strings"
 
@@ -102,7 +103,18 @@ func CollaborationPost(ctx *context.Context) {
 	}
 
 	if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil {
-		ctx.ServerError("AddCollaborator", err)
+		if !errors.Is(err, user_model.ErrBlockedByUser) {
+			ctx.ServerError("AddCollaborator", err)
+			return
+		}
+
+		// To give an good error message, be precise on who has blocked who.
+		if blockedOurs := user_model.IsBlocked(ctx, ctx.Repo.Repository.OwnerID, u.ID); blockedOurs {
+			ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_our"))
+		} else {
+			ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_them"))
+		}
+		ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
 		return
 	}
 
diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go
index 6f7c844ce7..46180ac372 100644
--- a/routers/web/repo/setting/settings_test.go
+++ b/routers/web/repo/setting/settings_test.go
@@ -102,13 +102,15 @@ func TestCollaborationPost(t *testing.T) {
 	ctx.Req.Form.Set("collaborator", "user4")
 
 	u := &user_model.User{
+		ID:        2,
 		LowerName: "user2",
 		Type:      user_model.UserTypeIndividual,
 	}
 
 	re := &repo_model.Repository{
-		ID:    2,
-		Owner: u,
+		ID:      2,
+		Owner:   u,
+		OwnerID: u.ID,
 	}
 
 	repo := &context.Repository{
@@ -160,13 +162,15 @@ func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) {
 	ctx.Req.Form.Set("collaborator", "user4")
 
 	u := &user_model.User{
+		ID:        2,
 		LowerName: "user2",
 		Type:      user_model.UserTypeIndividual,
 	}
 
 	re := &repo_model.Repository{
-		ID:    2,
-		Owner: u,
+		ID:      2,
+		Owner:   u,
+		OwnerID: u.ID,
 	}
 
 	repo := &context.Repository{
diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go
index 516c853b02..fa7f915cf4 100644
--- a/routers/web/shared/user/header.go
+++ b/routers/web/shared/user/header.go
@@ -31,6 +31,7 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) {
 	prepareContextForCommonProfile(ctx)
 
 	ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx.Doer.ID, ctx.ContextUser.ID)
+	ctx.Data["IsBlocked"] = ctx.Doer != nil && user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
 	ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate
 
 	// Show OpenID URIs
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 7f6e41d30f..d165a63396 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -5,6 +5,7 @@
 package user
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -23,6 +24,7 @@ import (
 	"code.gitea.io/gitea/routers/web/feed"
 	"code.gitea.io/gitea/routers/web/org"
 	shared_user "code.gitea.io/gitea/routers/web/shared/user"
+	user_service "code.gitea.io/gitea/services/user"
 )
 
 // OwnerProfile render profile page for a user or a organization (aka, repo owner)
@@ -284,17 +286,38 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi
 // Action response for follow/unfollow user request
 func Action(ctx *context.Context) {
 	var err error
+	var redirectViaJSON bool
 	switch ctx.FormString("action") {
 	case "follow":
-		err = user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
+		err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
 	case "unfollow":
-		err = user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
+		err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+	case "block":
+		err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+		redirectViaJSON = true
+	case "unblock":
+		err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
 	}
 
 	if err != nil {
-		log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
-		ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
+		if !errors.Is(err, user_model.ErrBlockedByUser) {
+			log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
+			ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
+			return
+		}
+
+		if ctx.ContextUser.IsOrganization() {
+			ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"))
+		} else {
+			ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"))
+		}
+	}
+
+	if redirectViaJSON {
+		ctx.JSON(http.StatusOK, map[string]interface{}{
+			"redirect": ctx.ContextUser.HomeLink(),
+		})
 		return
 	}
-	ctx.JSONOK()
+	ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.ContextUser.HomeLink())
 }
diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go
new file mode 100644
index 0000000000..ed1c340fb9
--- /dev/null
+++ b/routers/web/user/setting/blocked_users.go
@@ -0,0 +1,46 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+	"net/http"
+
+	"code.gitea.io/gitea/models/db"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/setting"
+)
+
+const (
+	tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users"
+)
+
+// BlockedUsers render the blocked users list page.
+func BlockedUsers(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
+	ctx.Data["PageIsBlockedUsers"] = true
+	ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/blocked_users"
+	ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/blocked_users"
+
+	blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID, db.ListOptions{})
+	if err != nil {
+		ctx.ServerError("ListBlockedUsers", err)
+		return
+	}
+
+	ctx.Data["BlockedUsers"] = blockedUsers
+	ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
+}
+
+// UnblockUser unblocks a particular user for the doer.
+func UnblockUser(ctx *context.Context) {
+	if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil {
+		ctx.ServerError("UnblockUser", err)
+		return
+	}
+
+	ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
+	ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users")
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 67b3a47153..5f5a9b1f1c 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -521,6 +521,11 @@ func registerRoutes(m *web.Route) {
 			})
 			addWebhookEditRoutes()
 		}, webhooksEnabled)
+
+		m.Group("/blocked_users", func() {
+			m.Get("", user_setting.BlockedUsers)
+			m.Post("/unblock", user_setting.UnblockUser)
+		})
 	}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled))
 
 	m.Group("/user", func() {
@@ -776,6 +781,12 @@ func registerRoutes(m *web.Route) {
 
 				m.Methods("GET,POST", "/delete", org.SettingsDelete)
 
+				m.Group("/blocked_users", func() {
+					m.Get("", org_setting.BlockedUsers)
+					m.Post("/block", org_setting.BlockedUsersBlock)
+					m.Post("/unblock", org_setting.BlockedUsersUnblock)
+				})
+
 				m.Group("/packages", func() {
 					m.Get("", org.Packages)
 					m.Group("/rules", func() {
diff --git a/services/issue/comments.go b/services/issue/comments.go
index ed05f725ff..ddd2460e07 100644
--- a/services/issue/comments.go
+++ b/services/issue/comments.go
@@ -66,6 +66,11 @@ func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_mod
 
 // CreateIssueComment creates a plain issue comment.
 func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content string, attachments []string) (*issues_model.Comment, error) {
+	// Check if doer is blocked by the poster of the issue.
+	if user_model.IsBlocked(ctx, issue.PosterID, doer.ID) {
+		return nil, user_model.ErrBlockedByUser
+	}
+
 	comment, err := CreateComment(ctx, &issues_model.CreateCommentOptions{
 		Type:        issues_model.CommentTypeComment,
 		Doer:        doer,
diff --git a/services/issue/issue.go b/services/issue/issue.go
index b6c6a26cbd..b4c60afbbe 100644
--- a/services/issue/issue.go
+++ b/services/issue/issue.go
@@ -22,6 +22,11 @@ import (
 
 // NewIssue creates new issue with labels for repository.
 func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error {
+	// Check if the user is not blocked by the repo's owner.
+	if user_model.IsBlocked(ctx, repo.OwnerID, issue.PosterID) {
+		return user_model.ErrBlockedByUser
+	}
+
 	if err := issues_model.NewIssue(repo, issue, labelIDs, uuids); err != nil {
 		return err
 	}
diff --git a/services/issue/reaction.go b/services/issue/reaction.go
new file mode 100644
index 0000000000..dbb4735de2
--- /dev/null
+++ b/services/issue/reaction.go
@@ -0,0 +1,47 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package issue
+
+import (
+	"context"
+
+	issues_model "code.gitea.io/gitea/models/issues"
+	user_model "code.gitea.io/gitea/models/user"
+)
+
+// CreateIssueReaction creates a reaction on issue.
+func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, content string) (*issues_model.Reaction, error) {
+	if err := issue.LoadRepo(ctx); err != nil {
+		return nil, err
+	}
+
+	// Check if the doer is blocked by the issue's poster or repository owner.
+	if user_model.IsBlockedMultiple(ctx, []int64{issue.PosterID, issue.Repo.OwnerID}, doer.ID) {
+		return nil, user_model.ErrBlockedByUser
+	}
+
+	return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
+		Type:    content,
+		DoerID:  doer.ID,
+		IssueID: issue.ID,
+	})
+}
+
+// CreateCommentReaction creates a reaction on comment.
+func CreateCommentReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) {
+	if err := issue.LoadRepo(ctx); err != nil {
+		return nil, err
+	}
+
+	// Check if the doer is blocked by the issue's poster, the comment's poster or repository owner.
+	if user_model.IsBlockedMultiple(ctx, []int64{comment.PosterID, issue.PosterID, issue.Repo.OwnerID}, doer.ID) {
+		return nil, user_model.ErrBlockedByUser
+	}
+
+	return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
+		Type:      content,
+		DoerID:    doer.ID,
+		IssueID:   issue.ID,
+		CommentID: comment.ID,
+	})
+}
diff --git a/services/pull/pull.go b/services/pull/pull.go
index cf49d2fe20..e323b5f145 100644
--- a/services/pull/pull.go
+++ b/services/pull/pull.go
@@ -39,6 +39,11 @@ var pullWorkingPool = sync.NewExclusivePool()
 
 // NewPullRequest creates new pull request with labels for repository.
 func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *issues_model.Issue, labelIDs []int64, uuids []string, pr *issues_model.PullRequest, assigneeIDs []int64) error {
+	// Check if the doer is not blocked by the repository's owner.
+	if user_model.IsBlocked(ctx, repo.OwnerID, pull.PosterID) {
+		return user_model.ErrBlockedByUser
+	}
+
 	if err := TestPatch(pr); err != nil {
 		return err
 	}
diff --git a/services/user/block.go b/services/user/block.go
new file mode 100644
index 0000000000..05f9a376a7
--- /dev/null
+++ b/services/user/block.go
@@ -0,0 +1,58 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package user
+
+import (
+	"context"
+
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	user_model "code.gitea.io/gitea/models/user"
+)
+
+// BlockUser adds a blocked user entry for userID to block blockID.
+// TODO: Figure out if instance admins should be immune to blocking.
+// TODO: Add more mechanism like removing blocked user as collaborator on
+// repositories where the user is an owner.
+func BlockUser(ctx context.Context, userID, blockID int64) error {
+	if userID == blockID || user_model.IsBlocked(ctx, userID, blockID) {
+		return nil
+	}
+
+	ctx, committer, err := db.TxContext(ctx)
+	if err != nil {
+		return err
+	}
+	defer committer.Close()
+
+	// Add the blocked user entry.
+	_, err = db.GetEngine(ctx).Insert(&user_model.BlockedUser{UserID: userID, BlockID: blockID})
+	if err != nil {
+		return err
+	}
+
+	// Unfollow the user from the block's perspective.
+	err = user_model.UnfollowUser(ctx, blockID, userID)
+	if err != nil {
+		return err
+	}
+
+	// Unfollow the user from the doer's perspective.
+	err = user_model.UnfollowUser(ctx, userID, blockID)
+	if err != nil {
+		return err
+	}
+
+	// Blocked user unwatch all repository owned by the doer.
+	repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(ctx, blockID, userID)
+	if err != nil {
+		return err
+	}
+
+	err = repo_model.UnwatchRepos(ctx, blockID, repoIDs)
+	if err != nil {
+		return err
+	}
+
+	return committer.Commit()
+}
diff --git a/services/user/block_test.go b/services/user/block_test.go
new file mode 100644
index 0000000000..8a0a3c4739
--- /dev/null
+++ b/services/user/block_test.go
@@ -0,0 +1,41 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// TestBlockUser will ensure that when you block a user, certain actions have
+// been taken, like unfollowing each other etc.
+func TestBlockUser(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+	// Follow each other.
+	assert.NoError(t, user_model.FollowUser(db.DefaultContext, doer.ID, blockedUser.ID))
+	assert.NoError(t, user_model.FollowUser(db.DefaultContext, blockedUser.ID, doer.ID))
+
+	// Blocked user watch repository of doer.
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: doer.ID})
+	assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, blockedUser.ID, repo.ID, true))
+
+	assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
+
+	// Ensure they aren't following each other anymore.
+	assert.False(t, user_model.IsFollowing(doer.ID, blockedUser.ID))
+	assert.False(t, user_model.IsFollowing(blockedUser.ID, doer.ID))
+
+	// Ensure blocked user isn't following doer's repository.
+	assert.False(t, repo_model.IsWatching(blockedUser.ID, repo.ID))
+}
diff --git a/services/user/delete.go b/services/user/delete.go
index 01e3c37b39..0f33d712a4 100644
--- a/services/user/delete.go
+++ b/services/user/delete.go
@@ -90,6 +90,8 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
 		&pull_model.AutoMerge{DoerID: u.ID},
 		&pull_model.ReviewState{UserID: u.ID},
 		&user_model.Redirect{RedirectUserID: u.ID},
+		&user_model.BlockedUser{BlockID: u.ID},
+		&user_model.BlockedUser{UserID: u.ID},
 	); err != nil {
 		return fmt.Errorf("deleteBeans: %w", err)
 	}
diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl
index 445df520a9..aba67d0ade 100644
--- a/templates/org/home.tmpl
+++ b/templates/org/home.tmpl
@@ -1,5 +1,10 @@
 {{template "base/head" .}}
 <div role="main" aria-label="{{.Title}}" class="page-content organization profile">
+	{{if .Flash}}
+		<div class="ui container gt-mb-5">
+			{{template "base/alert" .}}
+		</div>
+	{{end}}
 	<div class="ui container gt-df">
 		{{avatar $.Context .Org 140 "org-avatar"}}
 		<div id="org-info">
diff --git a/templates/org/settings/blocked_users.tmpl b/templates/org/settings/blocked_users.tmpl
new file mode 100644
index 0000000000..904f6fd186
--- /dev/null
+++ b/templates/org/settings/blocked_users.tmpl
@@ -0,0 +1,40 @@
+{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings blocked-users")}}
+<div class="org-setting-content">
+	<div class="ui attached segment">
+		<form class="ui form ignore-dirty gt-df gt-fw gt-gap-3" action="{{$.Link}}/block" method="post">
+			{{.CsrfTokenHtml}}
+			<input type="hidden" name="uid" value="">
+			<div class="ui left">
+				<div id="search-user-box" class="ui search">
+					<div class="ui input">
+						<input class="prompt" name="uname" placeholder="{{.locale.Tr "repo.settings.search_user_placeholder"}}" autocomplete="off" required>
+					</div>
+				</div>
+			</div>
+			<button type="submit" class="ui red button">{{.locale.Tr "user.block"}}</button>
+		</form>
+	</div>
+	<div class="ui bottom attached table segment blocked-users">
+		{{range .BlockedUsers}}
+			<div class="item gt-df gt-ac gt-fw">
+				{{avatar $.Context . 48 "gt-mr-3 gt-mb-0"}}
+					<div class="gt-df gt-fc">
+						<a href="{{.HomeLink}}">{{.Name}}</a>
+						<i class="gt-mt-2">{{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}}</i>
+					</div>
+					<div class="gt-ml-auto content">
+						<form action="{{$.Link}}/unblock" method="post">
+							{{$.CsrfTokenHtml}}
+							<input type="hidden" name="user_id" value="{{.ID}}">
+							<button class="ui red button">{{$.locale.Tr "user.unblock"}}</button>
+						</form>
+					</div>
+				</div>
+			{{else}}
+				<div class="item">
+					<span class="text grey italic">{{$.locale.Tr "settings.blocked_users_none"}}</span>
+				</div>
+			{{end}}
+		</div>
+</div>
+{{template "org/settings/layout_footer" .}}
diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl
index e22d1b0f80..9528258826 100644
--- a/templates/org/settings/navbar.tmpl
+++ b/templates/org/settings/navbar.tmpl
@@ -38,6 +38,9 @@
 			</div>
 		</details>
 		{{end}}
+			<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{.OrgLink}}/settings/blocked_users">
+			{{.locale.Tr "settings.blocked_users"}}
+		</a>
 		<a class="{{if .PageIsSettingsDelete}}active {{end}}item" href="{{.OrgLink}}/settings/delete">
 			{{.locale.Tr "org.settings.delete"}}
 		</a>
diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl
index 97afbb6580..d76665d735 100644
--- a/templates/shared/user/profile_big_avatar.tmpl
+++ b/templates/shared/user/profile_big_avatar.tmpl
@@ -117,6 +117,18 @@
 					</button>
 				{{end}}
 			</li>
+			<li class="block">
+				{{if $.IsBlocked}}
+					<button class="ui basic red button link-action" data-url="{{.ContextUser.HomeLink}}?action=unblock&redirect_to={{$.Link}}">
+						{{svg "octicon-person"}} {{.locale.Tr "user.unblock"}}
+					</button>
+				{{else}}
+					<button type="submit" class="ui basic orange button delete-button"
+					data-modal-id="block-user" data-url="{{.ContextUser.HomeLink}}?action=block">
+						{{svg "octicon-blocked"}} {{.locale.Tr "user.block"}}
+					</button>
+				{{end}}
+			</li>
 			{{end}}
 		</ul>
 	</div>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 8cf5332baf..5dcae18d95 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -1655,6 +1655,42 @@
         }
       }
     },
+    "/orgs/{org}/block/{username}": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Blocks a user from the organization",
+        "operationId": "orgBlockUser",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the org",
+            "name": "org",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "username of the user",
+            "name": "username",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/orgs/{org}/hooks": {
       "get": {
         "produces": [
@@ -2019,6 +2055,44 @@
         }
       }
     },
+    "/orgs/{org}/list_blocked": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "List the organization's blocked users",
+        "operationId": "orgListBlockedUsers",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the org",
+            "name": "org",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/BlockedUserList"
+          }
+        }
+      }
+    },
     "/orgs/{org}/members": {
       "get": {
         "produces": [
@@ -2483,6 +2557,42 @@
         }
       }
     },
+    "/orgs/{org}/unblock/{username}": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Unblock a user from the organization",
+        "operationId": "orgUnblockUser",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the org",
+            "name": "org",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "username of the user",
+            "name": "username",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/packages/{owner}": {
       "get": {
         "produces": [
@@ -3841,6 +3951,9 @@
           "204": {
             "$ref": "#/responses/empty"
           },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
           "422": {
             "$ref": "#/responses/validationError"
           }
@@ -13962,6 +14075,35 @@
         }
       }
     },
+    "/user/block/{username}": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Blocks a user from the doer.",
+        "operationId": "userBlockUser",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "username of the user",
+            "name": "username",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/user/emails": {
       "get": {
         "produces": [
@@ -14138,6 +14280,9 @@
         "responses": {
           "204": {
             "$ref": "#/responses/empty"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
           }
         }
       },
@@ -14608,6 +14753,37 @@
         }
       }
     },
+    "/user/list_blocked": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "List the authenticated user's blocked users",
+        "operationId": "userListBlockedUsers",
+        "parameters": [
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/BlockedUserList"
+          }
+        }
+      }
+    },
     "/user/orgs": {
       "get": {
         "produces": [
@@ -15009,6 +15185,35 @@
         }
       }
     },
+    "/user/unblock/{username}": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Unblocks a user from the doer.",
+        "operationId": "userUnblockUser",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "username of the user",
+            "name": "username",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/users/search": {
       "get": {
         "produces": [
@@ -15939,6 +16144,23 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "BlockedUser": {
+      "type": "object",
+      "title": "BlockedUser represents a blocked user.",
+      "properties": {
+        "block_id": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "BlockID"
+        },
+        "created_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "Created"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "Branch": {
       "description": "Branch represents a repository branch",
       "type": "object",
@@ -22164,6 +22386,15 @@
         }
       }
     },
+    "BlockedUserList": {
+      "description": "BlockedUserList",
+      "schema": {
+        "type": "array",
+        "items": {
+          "$ref": "#/definitions/BlockedUser"
+        }
+      }
+    },
     "Branch": {
       "description": "Branch",
       "schema": {
diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl
index 396d9ebcd4..05e95a1a34 100644
--- a/templates/user/profile.tmpl
+++ b/templates/user/profile.tmpl
@@ -1,6 +1,7 @@
 {{template "base/head" .}}
 <div role="main" aria-label="{{.Title}}" class="page-content user profile">
 	<div class="ui container">
+		{{template "base/alert" .}}
 		<div class="ui stackable grid">
 			<div class="ui four wide column">
 				{{template "shared/user/profile_big_avatar" .}}
@@ -41,4 +42,20 @@
 		</div>
 	</div>
 </div>
+<div class="ui small basic delete modal" id="block-user">
+	<div class="ui icon header">
+		{{svg "octicon-blocked" 16 "blocked inside"}}
+		{{$.locale.Tr "user.block_user"}}
+	</div>
+	<div class="content">
+		<p>{{$.locale.Tr "user.block_user.detail"}}</p>
+		<ul>
+				<li>{{$.locale.Tr "user.block_user.detail_1"}}</li>
+				<li>{{$.locale.Tr "user.block_user.detail_2"}}</li>
+				<li>{{$.locale.Tr "user.block_user.detail_3"}}</li>
+		</ul>
+	</div>
+	{{template "base/modal_actions_confirm" .}}
+</div>
+
 {{template "base/footer" .}}
diff --git a/templates/user/settings/blocked_users.tmpl b/templates/user/settings/blocked_users.tmpl
new file mode 100644
index 0000000000..dc90970ec2
--- /dev/null
+++ b/templates/user/settings/blocked_users.tmpl
@@ -0,0 +1,31 @@
+{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings blocked_users")}}
+	<div class="user-setting-content">
+		<h4 class="ui top attached header">
+			{{.locale.Tr "settings.blocked_users"}}
+		</h4>
+		<div class="ui attached segment">
+			<div class="ui blocked-user list gt-mt-0">
+				{{range .BlockedUsers}}
+					<div class="item gt-df gt-ac">
+						{{avatar $.Context . 28 "gt-mr-3"}}
+						<div class="gt-df gt-fc">
+							<a href="{{.HomeLink}}">{{.Name}}</a>
+							<i class="gt-mt-2">{{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}}</i>
+						</div>
+						<div class="gt-ml-auto content">
+							<form action="{{$.Link}}/unblock" method="post">
+								{{$.CsrfTokenHtml}}
+								<input type="hidden" name="user_id" value="{{.ID}}">
+								<button class="ui red button">{{$.locale.Tr "user.unblock"}}</button>
+							</form>
+						</div>
+					</div>
+				{{else}}
+					<div class="item">
+						<span class="text grey italic">{{$.locale.Tr "settings.blocked_users_none"}}</span>
+					</div>
+				{{end}}
+			</div>
+		</div>
+	</div>
+{{template "user/settings/layout_footer" .}}
diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl
index 274540c5bd..32fb77f3e0 100644
--- a/templates/user/settings/navbar.tmpl
+++ b/templates/user/settings/navbar.tmpl
@@ -51,5 +51,8 @@
 		<a class="{{if .PageIsSettingsRepos}}active {{end}}item" href="{{AppSubUrl}}/user/settings/repos">
 			{{.locale.Tr "settings.repos"}}
 		</a>
+		<a class="{{if .PageIsBlockedUsers}}active {{end}}item" href="{{AppSubUrl}}/user/settings/blocked_users">
+			{{.locale.Tr "settings.blocked_users"}}
+		</a>
 	</div>
 </div>
diff --git a/tests/integration/api_block_test.go b/tests/integration/api_block_test.go
new file mode 100644
index 0000000000..b07dd2455a
--- /dev/null
+++ b/tests/integration/api_block_test.go
@@ -0,0 +1,141 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPIUserBlock(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := "user4"
+	session := loginUser(t, user)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+	t.Run("BlockUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/user2?token=%s", token))
+		MakeRequest(t, req, http.StatusNoContent)
+
+		unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2})
+	})
+
+	t.Run("ListBlocked", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/list_blocked?token=%s", token))
+		resp := MakeRequest(t, req, http.StatusOK)
+
+		// One user just got blocked and the other one is defined in the fixtures.
+		assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
+
+		var blockedUsers []api.BlockedUser
+		DecodeJSON(t, resp, &blockedUsers)
+		assert.Len(t, blockedUsers, 2)
+		assert.EqualValues(t, 1, blockedUsers[0].BlockID)
+		assert.EqualValues(t, 2, blockedUsers[1].BlockID)
+	})
+
+	t.Run("UnblockUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/user2?token=%s", token))
+		MakeRequest(t, req, http.StatusNoContent)
+
+		unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2})
+	})
+}
+
+func TestAPIOrgBlock(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := "user5"
+	org := "user6"
+	session := loginUser(t, user)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+
+	t.Run("BlockUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token))
+		MakeRequest(t, req, http.StatusNoContent)
+
+		unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
+	})
+
+	t.Run("ListBlocked", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token))
+		resp := MakeRequest(t, req, http.StatusOK)
+
+		assert.Equal(t, "1", resp.Header().Get("X-Total-Count"))
+
+		var blockedUsers []api.BlockedUser
+		DecodeJSON(t, resp, &blockedUsers)
+		assert.Len(t, blockedUsers, 1)
+		assert.EqualValues(t, 2, blockedUsers[0].BlockID)
+	})
+
+	t.Run("UnblockUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2?token=%s", org, token))
+		MakeRequest(t, req, http.StatusNoContent)
+
+		unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
+	})
+}
+
+// TestAPIBlock_AddCollaborator ensures that the doer and blocked user cannot
+// add each others as collaborators via the API.
+func TestAPIBlock_AddCollaborator(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user1 := "user10"
+	user2 := "user2"
+	perm := "write"
+	collabOption := &api.AddCollaboratorOption{Permission: &perm}
+
+	// User1 blocks User2.
+	session := loginUser(t, user1)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
+
+	req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s?token=%s", user2, token))
+	MakeRequest(t, req, http.StatusNoContent)
+	unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 10, BlockID: 2})
+
+	t.Run("BlockedUser Add Doer", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2})
+		session := loginUser(t, user2)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user2, repo.Name, user1, token), collabOption)
+		session.MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("Doer Add BlockedUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: 10})
+		session := loginUser(t, user1)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user1, repo.Name, user2, token), collabOption)
+		session.MakeRequest(t, req, http.StatusForbidden)
+	})
+}
diff --git a/tests/integration/api_user_follow_test.go b/tests/integration/api_user_follow_test.go
index 62717af90e..bf6560b103 100644
--- a/tests/integration/api_user_follow_test.go
+++ b/tests/integration/api_user_follow_test.go
@@ -19,7 +19,7 @@ func TestAPIFollow(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 
 	user1 := "user4"
-	user2 := "user1"
+	user2 := "user10"
 
 	session1 := loginUser(t, user1)
 	token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser)
diff --git a/tests/integration/block_test.go b/tests/integration/block_test.go
new file mode 100644
index 0000000000..be1097f0f8
--- /dev/null
+++ b/tests/integration/block_test.go
@@ -0,0 +1,257 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"path"
+	"strconv"
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	issue_model "code.gitea.io/gitea/models/issues"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	forgejo_context "code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/translation"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func BlockUser(t *testing.T, doer, blockedUser *user_model.User) {
+	t.Helper()
+
+	unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})
+
+	session := loginUser(t, doer.Name)
+	req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+		"_csrf":  GetCSRF(t, session, "/"+blockedUser.Name),
+		"action": "block",
+	})
+	resp := session.MakeRequest(t, req, http.StatusOK)
+
+	type redirect struct {
+		Redirect string `json:"redirect"`
+	}
+
+	var respBody redirect
+	DecodeJSON(t, resp, &respBody)
+	assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect)
+	assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}))
+}
+
+func TestBlockUser(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	BlockUser(t, doer, blockedUser)
+
+	// Unblock user.
+	session := loginUser(t, doer.Name)
+	req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+		"_csrf":  GetCSRF(t, session, "/"+blockedUser.Name),
+		"action": "unblock",
+	})
+	resp := session.MakeRequest(t, req, http.StatusSeeOther)
+
+	loc := resp.Header().Get("Location")
+	assert.EqualValues(t, "/"+blockedUser.Name, loc)
+	unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})
+}
+
+func TestBlockIssueCreation(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: doer.ID})
+	BlockUser(t, doer, blockedUser)
+
+	session := loginUser(t, blockedUser.Name)
+	req := NewRequest(t, "GET", "/"+repo.OwnerName+"/"+repo.Name+"/issues/new")
+	resp := session.MakeRequest(t, req, http.StatusOK)
+
+	htmlDoc := NewHTMLParser(t, resp.Body)
+	link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
+	assert.True(t, exists)
+	req = NewRequestWithValues(t, "POST", link, map[string]string{
+		"_csrf":   htmlDoc.GetCSRF(),
+		"title":   "Title",
+		"content": "Hello!",
+	})
+
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	htmlDoc = NewHTMLParser(t, resp.Body)
+	assert.Contains(t,
+		htmlDoc.doc.Find(".ui.negative.message").Text(),
+		translation.NewLocale("en-US").Tr("repo.issues.blocked_by_user"),
+	)
+}
+
+func TestBlockIssueReaction(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+	issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4, PosterID: doer.ID, RepoID: repo.ID})
+	issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), issue.Index)
+
+	BlockUser(t, doer, blockedUser)
+
+	session := loginUser(t, blockedUser.Name)
+	req := NewRequest(t, "GET", issueURL)
+	resp := session.MakeRequest(t, req, http.StatusOK)
+	htmlDoc := NewHTMLParser(t, resp.Body)
+
+	req = NewRequestWithValues(t, "POST", path.Join(issueURL, "/reactions/react"), map[string]string{
+		"_csrf":   htmlDoc.GetCSRF(),
+		"content": "eyes",
+	})
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	type reactionResponse struct {
+		Empty bool `json:"empty"`
+	}
+
+	var respBody reactionResponse
+	DecodeJSON(t, resp, &respBody)
+
+	assert.EqualValues(t, true, respBody.Empty)
+}
+
+func TestBlockCommentReaction(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 1, RepoID: repo.ID})
+	comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 3, PosterID: doer.ID, IssueID: issue.ID})
+	_ = comment.LoadIssue(db.DefaultContext)
+	issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), issue.Index)
+
+	BlockUser(t, doer, blockedUser)
+
+	session := loginUser(t, blockedUser.Name)
+	req := NewRequest(t, "GET", issueURL)
+	resp := session.MakeRequest(t, req, http.StatusOK)
+	htmlDoc := NewHTMLParser(t, resp.Body)
+
+	req = NewRequestWithValues(t, "POST", path.Join(repo.Link(), "/comments/", strconv.FormatInt(comment.ID, 10), "/reactions/react"), map[string]string{
+		"_csrf":   htmlDoc.GetCSRF(),
+		"content": "eyes",
+	})
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	type reactionResponse struct {
+		Empty bool `json:"empty"`
+	}
+
+	var respBody reactionResponse
+	DecodeJSON(t, resp, &respBody)
+
+	assert.EqualValues(t, true, respBody.Empty)
+}
+
+// TestBlockFollow ensures that the doer and blocked user cannot follow each other.
+func TestBlockFollow(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+	BlockUser(t, doer, blockedUser)
+
+	// Doer cannot follow blocked user.
+	session := loginUser(t, doer.Name)
+	req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+		"_csrf":  GetCSRF(t, session, "/"+blockedUser.Name),
+		"action": "follow",
+	})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+
+	unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
+
+	// Blocked user cannot follow doer.
+	session = loginUser(t, blockedUser.Name)
+	req = NewRequestWithValues(t, "POST", "/"+doer.Name, map[string]string{
+		"_csrf":  GetCSRF(t, session, "/"+doer.Name),
+		"action": "follow",
+	})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+
+	unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
+}
+
+// TestBlockUserFromOrganization ensures that an organisation can block and unblock an user.
+func TestBlockUserFromOrganization(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
+	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+	org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17, Type: user_model.UserTypeOrganization})
+	unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
+
+	session := loginUser(t, doer.Name)
+	req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{
+		"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+		"uname": blockedUser.Name,
+	})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+	assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}))
+
+	req = NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{
+		"_csrf":   GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+		"user_id": strconv.FormatInt(blockedUser.ID, 10),
+	})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+	unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
+}
+
+// TestBlockAddCollaborator ensures that the doer and blocked user cannot add each each other as collaborators.
+func TestBlockAddCollaborator(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+	BlockUser(t, user1, user2)
+
+	t.Run("BlockedUser Add Doer", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: user2.ID})
+
+		session := loginUser(t, user2.Name)
+		req := NewRequestWithValues(t, "POST", path.Join(repo.Link(), "/settings/collaboration"), map[string]string{
+			"_csrf":        GetCSRF(t, session, path.Join(repo.Link(), "/settings/collaboration")),
+			"collaborator": user1.Name,
+		})
+		session.MakeRequest(t, req, http.StatusSeeOther)
+
+		flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+		assert.NotNil(t, flashCookie)
+		assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthey%2Bhave%2Bblocked%2Bthe%2Brepository%2Bowner.", flashCookie.Value)
+	})
+
+	t.Run("Doer Add BlockedUser", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: user1.ID})
+
+		session := loginUser(t, user1.Name)
+		req := NewRequestWithValues(t, "POST", path.Join(repo.Link(), "/settings/collaboration"), map[string]string{
+			"_csrf":        GetCSRF(t, session, path.Join(repo.Link(), "/settings/collaboration")),
+			"collaborator": user2.Name,
+		})
+		session.MakeRequest(t, req, http.StatusSeeOther)
+
+		flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+		assert.NotNil(t, flashCookie)
+		assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthe%2Brepository%2Bowner%2Bhas%2Bblocked%2Bthem.", flashCookie.Value)
+	})
+}
diff --git a/web_src/css/org.css b/web_src/css/org.css
index 4bb3b0cd57..f0bfd88b5a 100644
--- a/web_src/css/org.css
+++ b/web_src/css/org.css
@@ -168,6 +168,25 @@
   border-bottom: 1px solid var(--color-secondary);
 }
 
+.organization.teams .repositories .item,
+.organization.teams .members .item,
+.organization.settings .blocked-users .item {
+  padding: 10px 19px;
+}
+
+.organization.teams .repositories .item:not(:last-child),
+.organization.teams .members .item:not(:last-child),
+.organization.settings .blocked-users .item:not(:last-child) {
+  border-bottom: 1px solid var(--color-secondary);
+}
+
+.organization.teams .repositories .item .button,
+.organization.teams .members .item .button,
+.organization.settings .blocked-users .item button {
+  padding: 9px 10px;
+  margin: 0;
+}
+
 .org-team-navbar .active.item {
   background: var(--color-box-body) !important;
 }
diff --git a/web_src/css/user.css b/web_src/css/user.css
index 33e64bbb7c..9cc84b1825 100644
--- a/web_src/css/user.css
+++ b/web_src/css/user.css
@@ -36,6 +36,19 @@
   width: 100%;
 }
 
+.user.profile .ui.card .extra.content > ul > li .svg {
+  margin-left: 1px;
+  margin-right: 5px;
+}
+
+.user.profile .ui.card .extra.content > ul > li.follow .ui.button,
+.user.profile .ui.card .extra.content > ul > li.block .ui.button {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  width: 100%;
+}
+
 .user.profile .ui.card #profile-avatar {
   padding: 1rem 1rem 0.25rem;
   justify-content: center;