diff --git a/docs/api-change.md b/docs/api-change.md
index 597a484417..4d6c0d63e8 100644
--- a/docs/api-change.md
+++ b/docs/api-change.md
@@ -2,6 +2,14 @@
 
 Breaking changes are indicated by the :warning: icon.
 
+## Unreleased
+
+- Added `admin/set-emoji-moderator` endpoint, where moderators can give these permissions to regular users:
+	- `add`: Add new custom emojis, set tag/category/license to newly added custom emojis
+	- `mod`: `add` permission + edit the name/category/tag/license of the existing custom emojis
+	- `full`: `mod` permission + delete existing custom emojis
+- Emoji moderators are able to access to the endpoints under `admin/emoji/`
+
 ## v20240217
 
 - :warning: Since the auto NSFW media detection has been removed, these endpoints are affected:
diff --git a/docs/changelog.md b/docs/changelog.md
index 1427249b7a..e1de713f2b 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,10 @@
 
 Critical security updates are indicated by the :warning: icon.
 
+## Unreleased
+
+- Add the ability to give regular (non-moderator) users permission to manage custom emojis
+
 ## :warning: v20240217-1
 
 - Fix a [security issue](https://github.com/misskey-dev/misskey/security/advisories/GHSA-qqrm-9grj-6v32)
diff --git a/docs/downgrade.sql b/docs/downgrade.sql
index af3a385d2e..1840414a6d 100644
--- a/docs/downgrade.sql
+++ b/docs/downgrade.sql
@@ -1,11 +1,16 @@
 BEGIN;
 
 DELETE FROM "migrations" WHERE name IN (
+		'EmojiModerator1692825433698',
     'RemoveNsfwDetection1705848938166',
     'FirefishUrlMove1707850084123',
     'RemoveNativeUtilsMigration1705877093218'
 );
 
+-- emoji-moderator
+ALTER TABLE "user" DROP COLUMN "emojiModPerm";
+DROP TYPE "public"."user_emojimodperm_enum";
+
 -- remove-nsfw-detection
 ALTER TABLE "user_profile" ADD "autoSensitive" boolean NOT NULL DEFAULT false;
 ALTER TABLE "meta" ADD "enableSensitiveMediaDetectionForVideos" boolean NOT NULL DEFAULT false;
diff --git a/locales/en-US.yml b/locales/en-US.yml
index 499692e22d..64e5ba4c2f 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1170,6 +1170,13 @@ replaceWidgetsButtonWithReloadButton: "Replace widgets button with reload button
 searchEngine: "Search engine used in search bar MFM"
 postSearch: "Post search on this server"
 showBigPostButton: "Show a huge post button on the posting form" 
+emojiModPerm: "Custom emoji management permission"
+emojiModPermDescription: "Add: Allow this user to add new custom emojis and to set tag/category/license to newly added custom emojis.\nAdd and Edit: \"Add\" Permission + Allow this user to edit the name/category/tag/license of the existing custom emojis.\nAllow All: \"Add and Edit\" Permission + Allow this user to delete existing custom emojis."
+_emojiModPerm:
+  unauthorized: "None"
+  add: "Add"
+  mod: "Add and Edit"
+  full: "Allow All"
 
 _sensitiveMediaDetection:
   description: "Reduces the effort of server moderation through automatically recognizing
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 5aa9a6845c..0e02e486a8 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2019,3 +2019,10 @@ replaceWidgetsButtonWithReloadButton: "ウィジェットのボタンを再読
 searchEngine: "検索のMFMで使用する検索エンジン"
 postSearch: "このサーバーの投稿検索"
 showBigPostButton: "投稿ボタンを巨大にする"
+emojiModPerm: "カスタム絵文字の管理権"
+emojiModPermDescription: "追加: カスタム絵文字の新規追加と新規追加されたカスタム絵文字(正確には、タグとカテゴリとライセンスが設定されていないカスタム絵文字)へのタグとカテゴリとライセンスの設定を許可します。\n追加と変更:「追加」の権限に加え、既存の絵文字の名前・カテゴリ・タグ・ライセンスの変更を許可します。\n全て許可:「追加と変更」の権限に加え、既存のカスタム絵文字の削除を許可します。\nこの設定にかかわらず、サーバーの管理者およびモデレーターには「全て許可」の権限が与えられます。"
+_emojiModPerm:
+  unauthorized: "無し"
+  add: "追加"
+  mod: "追加と変更"
+  full: "全て許可"
diff --git a/packages/backend-rs/src/model/entity/sea_orm_active_enums.rs b/packages/backend-rs/src/model/entity/sea_orm_active_enums.rs
index 418af3eca5..ae7df67b5b 100644
--- a/packages/backend-rs/src/model/entity/sea_orm_active_enums.rs
+++ b/packages/backend-rs/src/model/entity/sea_orm_active_enums.rs
@@ -125,6 +125,22 @@ pub enum RelayStatusEnum {
     Requesting,
 }
 #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
+#[sea_orm(
+    rs_type = "String",
+    db_type = "Enum",
+    enum_name = "user_emojimodperm_enum"
+)]
+pub enum UserEmojimodpermEnum {
+    #[sea_orm(string_value = "add")]
+    Add,
+    #[sea_orm(string_value = "full")]
+    Full,
+    #[sea_orm(string_value = "mod")]
+    Mod,
+    #[sea_orm(string_value = "unauthorized")]
+    Unauthorized,
+}
+#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
 #[sea_orm(
     rs_type = "String",
     db_type = "Enum",
diff --git a/packages/backend-rs/src/model/entity/user.rs b/packages/backend-rs/src/model/entity/user.rs
index 1d6a2bcd1d..1e56d6ea4e 100644
--- a/packages/backend-rs/src/model/entity/user.rs
+++ b/packages/backend-rs/src/model/entity/user.rs
@@ -1,5 +1,6 @@
 //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.10
 
+use super::sea_orm_active_enums::UserEmojimodpermEnum;
 use sea_orm::entity::prelude::*;
 
 #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
@@ -38,6 +39,8 @@ pub struct Model {
     pub is_bot: bool,
     #[sea_orm(column_name = "isCat")]
     pub is_cat: bool,
+    #[sea_orm(column_name = "emojiModPerm")]
+    pub emoji_mod_perm: UserEmojimodpermEnum,
     #[sea_orm(column_name = "isAdmin")]
     pub is_admin: bool,
     #[sea_orm(column_name = "isModerator")]
diff --git a/packages/backend/migration/1692825433698-emoji-moderator.js b/packages/backend/migration/1692825433698-emoji-moderator.js
new file mode 100644
index 0000000000..95ece9cbf1
--- /dev/null
+++ b/packages/backend/migration/1692825433698-emoji-moderator.js
@@ -0,0 +1,17 @@
+export class EmojiModerator1692825433698 {
+	name = "EmojiModerator1692825433698";
+
+	async up(queryRunner) {
+		await queryRunner.query(
+			`CREATE TYPE "public"."user_emojimodperm_enum" AS ENUM('unauthorized', 'add', 'mod', 'full')`,
+		);
+		await queryRunner.query(
+			`ALTER TABLE "user" ADD "emojiModPerm" "public"."user_emojimodperm_enum" NOT NULL DEFAULT 'unauthorized'`,
+		);
+	}
+
+	async down(queryRunner) {
+		await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "emojiModPerm"`);
+		await queryRunner.query(`DROP TYPE "public"."user_emojimodperm_enum"`);
+	}
+}
diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts
index 2d5e5dca3e..07aba7badf 100644
--- a/packages/backend/src/models/entities/user.ts
+++ b/packages/backend/src/models/entities/user.ts
@@ -178,6 +178,17 @@ export class User {
 	})
 	public isModerator: boolean;
 
+	// unauthorized: no permission
+	//          add: add custom emojis to the server
+	//          mod: add permission + modify {category, tags, license} of existing custom emojis
+	//         full: mod permission + {rename, delete} existing custom emojis
+	@Column({
+		type: "enum",
+		enum: ["unauthorized", "add", "mod", "full"],
+		default: "unauthorized",
+	})
+	public emojiModPerm: "unauthorized" | "add" | "mod" | "full";
+
 	@Index()
 	@Column("boolean", {
 		default: true,
diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts
index 45f17f984a..dbacb4e5d9 100644
--- a/packages/backend/src/models/repositories/user.ts
+++ b/packages/backend/src/models/repositories/user.ts
@@ -449,6 +449,7 @@ export const UserRepository = db.getRepository(User).extend({
 			avatarUrl: this.getAvatarUrlSync(user),
 			avatarBlurhash: user.avatar?.blurhash || null,
 			avatarColor: null, // 後方互換性のため
+			emojiModPerm: user.emojiModPerm ?? "unauthorized",
 			isAdmin: user.isAdmin || falsy,
 			isModerator: user.isModerator || falsy,
 			isBot: user.isBot || falsy,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index bc283709e6..24da7eef8d 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -55,6 +55,7 @@ import * as ep___admin_search_indexAll from "./endpoints/admin/search/index-all.
 import * as ep___admin_sendEmail from "./endpoints/admin/send-email.js";
 import * as ep___admin_sendModMail from "./endpoints/admin/send-mod-mail.js";
 import * as ep___admin_serverInfo from "./endpoints/admin/server-info.js";
+import * as ep___admin_setEmojiModerator from "./endpoints/admin/set-emoji-moderator.js";
 import * as ep___admin_showModerationLogs from "./endpoints/admin/show-moderation-logs.js";
 import * as ep___admin_showUser from "./endpoints/admin/show-user.js";
 import * as ep___admin_showUsers from "./endpoints/admin/show-users.js";
@@ -411,6 +412,7 @@ const eps = [
 	["admin/send-email", ep___admin_sendEmail],
 	["admin/send-mod-mail", ep___admin_sendModMail],
 	["admin/server-info", ep___admin_serverInfo],
+	["admin/set-emoji-moderator", ep___admin_setEmojiModerator],
 	["admin/show-moderation-logs", ep___admin_showModerationLogs],
 	["admin/show-user", ep___admin_showUser],
 	["admin/show-users", ep___admin_showUsers],
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts
index 02b3877b87..2bf659b7bc 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts
@@ -2,12 +2,21 @@ import define from "@/server/api/define.js";
 import { Emojis } from "@/models/index.js";
 import { In } from "typeorm";
 import { db } from "@/db/postgre.js";
+import { ApiError } from "@/server/api/error.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
+
+	errors: {
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -30,11 +39,22 @@ export const paramDef = {
 	required: ["ids", "aliases"],
 } as const;
 
-export default define(meta, paramDef, async (ps) => {
+export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "add" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "unauthorized"))
+		throw new ApiError(meta.errors.accessDenied);
+
 	const emojis = await Emojis.findBy({
 		id: In(ps.ids),
 	});
 
+	// require emoji "mod" permission if an alias has already been set
+	if (me.emojiModPerm === "add") {
+		for (const emoji of emojis)
+			if (emoji.aliases.length > 0)
+				throw new ApiError(meta.errors.accessDenied);
+	}
+
 	for (const emoji of emojis) {
 		await Emojis.update(emoji.id, {
 			updatedAt: new Date(),
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
index 8d156e6f71..8d4e756797 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
@@ -9,10 +9,10 @@ import { db } from "@/db/postgre.js";
 import { getEmojiSize } from "@/misc/emoji-meta.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
 
 	errors: {
 		noSuchFile: {
@@ -20,6 +20,11 @@ export const meta = {
 			code: "MO_SUCH_FILE",
 			id: "fc46b5a4-6b92-4c33-ac66-b806659bb5cf",
 		},
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
 	},
 } as const;
 
@@ -32,6 +37,10 @@ export const paramDef = {
 } as const;
 
 export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "add" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm !== "unauthorized"))
+		throw new ApiError(meta.errors.accessDenied);
+
 	const file = await DriveFiles.findOneBy({ id: ps.fileId });
 
 	if (file == null) throw new ApiError(meta.errors.noSuchFile);
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
index 29b1d82125..fad735b207 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
@@ -9,10 +9,10 @@ import { db } from "@/db/postgre.js";
 import { getEmojiSize } from "@/misc/emoji-meta.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
 
 	errors: {
 		noSuchEmoji: {
@@ -20,6 +20,11 @@ export const meta = {
 			code: "NO_SUCH_EMOJI",
 			id: "e2785b66-dca3-4087-9cac-b93c541cc425",
 		},
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
 	},
 
 	res: {
@@ -46,6 +51,12 @@ export const paramDef = {
 } as const;
 
 export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "mod" permission
+	if (
+		!(me.isAdmin || me.isModerator || ["mod", "full"].includes(me.emojiModPerm))
+	)
+		throw new ApiError(meta.errors.accessDenied);
+
 	const emoji = await Emojis.findOneBy({ id: ps.emojiId });
 
 	if (emoji == null) {
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts
index a5c4c8bcc5..f9c582989f 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts
@@ -3,12 +3,21 @@ import { Emojis } from "@/models/index.js";
 import { In } from "typeorm";
 import { insertModerationLog } from "@/services/insert-moderation-log.js";
 import { db } from "@/db/postgre.js";
+import { ApiError } from "@/server/api/error.js";
 
 export const meta = {
 	tags: ["admin"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
+
+	errors: {
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -26,6 +35,10 @@ export const paramDef = {
 } as const;
 
 export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "full" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "full"))
+		throw new ApiError(meta.errors.accessDenied);
+
 	const emojis = await Emojis.findBy({
 		id: In(ps.ids),
 	});
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts
index 726f90396b..81f737bc97 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts
@@ -5,10 +5,10 @@ import { ApiError } from "@/server/api/error.js";
 import { db } from "@/db/postgre.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
 
 	errors: {
 		noSuchEmoji: {
@@ -16,6 +16,11 @@ export const meta = {
 			code: "NO_SUCH_EMOJI",
 			id: "be83669b-773a-44b7-b1f8-e5e5170ac3c2",
 		},
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
 	},
 } as const;
 
@@ -28,6 +33,10 @@ export const paramDef = {
 } as const;
 
 export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "full" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "full"))
+		throw new ApiError(meta.errors.accessDenied);
+
 	const emoji = await Emojis.findOneBy({ id: ps.id });
 
 	if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji);
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts
index 4b49e45dd9..c52a80f721 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts
@@ -1,10 +1,21 @@
 import define from "@/server/api/define.js";
 import { createImportCustomEmojisJob } from "@/queue/index.js";
+import { ApiError } from "@/server/api/error.js";
 
 export const meta = {
+	tags: ["admin", "emoji"],
+
 	secure: true,
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
+
+	errors: {
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -16,5 +27,11 @@ export const paramDef = {
 } as const;
 
 export default define(meta, paramDef, async (ps, user) => {
+	// require emoji "add" permission
+	if (
+		!(user.isAdmin || user.isModerator || user.emojiModPerm !== "unauthorized")
+	)
+		throw new ApiError(meta.errors.accessDenied);
+
 	createImportCustomEmojisJob(user, ps.fileId);
 });
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts
index c6246c48c7..f6a88b8366 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts
@@ -3,12 +3,13 @@ import { Emojis } from "@/models/index.js";
 import { toPuny } from "@/misc/convert-host.js";
 import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js";
 import { sqlLikeEscape } from "@/misc/sql-like-escape.js";
+import { ApiError } from "@/server/api/error.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
 
 	res: {
 		type: "array",
@@ -74,6 +75,14 @@ export const meta = {
 			},
 		},
 	},
+
+	errors: {
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -93,7 +102,11 @@ export const paramDef = {
 	required: [],
 } as const;
 
-export default define(meta, paramDef, async (ps) => {
+export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "add" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm !== "unauthorized"))
+		throw new ApiError(meta.errors.accessDenied);
+
 	const q = makePaginationQuery(
 		Emojis.createQueryBuilder("emoji"),
 		ps.sinceId,
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts
index 12de0b1817..434b679608 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts
@@ -3,12 +3,13 @@ import { Emojis } from "@/models/index.js";
 import { makePaginationQuery } from "../../../common/make-pagination-query.js";
 import type { Emoji } from "@/models/entities/emoji.js";
 //import { sqlLikeEscape } from "@/misc/sql-like-escape.js";
+import { ApiError } from "@/server/api/error.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
 
 	res: {
 		type: "array",
@@ -74,6 +75,14 @@ export const meta = {
 			},
 		},
 	},
+
+	errors: {
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -87,7 +96,11 @@ export const paramDef = {
 	required: [],
 } as const;
 
-export default define(meta, paramDef, async (ps) => {
+export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "add" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm !== "unauthorized"))
+		throw new ApiError(meta.errors.accessDenied);
+
 	const q = makePaginationQuery(
 		Emojis.createQueryBuilder("emoji"),
 		ps.sinceId,
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts
index 49d31805f0..fc0a32000b 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts
@@ -2,12 +2,21 @@ import define from "@/server/api/define.js";
 import { Emojis } from "@/models/index.js";
 import { In } from "typeorm";
 import { db } from "@/db/postgre.js";
+import { ApiError } from "@/server/api/error.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
+
+	errors: {
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -30,7 +39,13 @@ export const paramDef = {
 	required: ["ids", "aliases"],
 } as const;
 
-export default define(meta, paramDef, async (ps) => {
+export default define(meta, paramDef, async (ps, me) => {
+	// require emoji mod permission
+	if (
+		!(me.isAdmin || me.isModerator || ["mod", "full"].includes(me.emojiModPerm))
+	)
+		throw new ApiError(meta.errors.accessDenied);
+
 	const emojis = await Emojis.findBy({
 		id: In(ps.ids),
 	});
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts
index 72d7fab804..a0f405a508 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts
@@ -2,12 +2,21 @@ import define from "@/server/api/define.js";
 import { Emojis } from "@/models/index.js";
 import { In } from "typeorm";
 import { db } from "@/db/postgre.js";
+import { ApiError } from "@/server/api/error.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
+
+	errors: {
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -30,7 +39,22 @@ export const paramDef = {
 	required: ["ids", "aliases"],
 } as const;
 
-export default define(meta, paramDef, async (ps) => {
+export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "add" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "unauthorized"))
+		throw new ApiError(meta.errors.accessDenied);
+
+	const emojis = await Emojis.findBy({
+		id: In(ps.ids),
+	});
+
+	// require emoji mod permission if an alias has already been set
+	if (me.emojiModPerm === "add") {
+		for (const emoji of emojis)
+			if (emoji.aliases.length > 0)
+				throw new ApiError(meta.errors.accessDenied);
+	}
+
 	await Emojis.update(
 		{
 			id: In(ps.ids),
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts
index 89e68aa4c1..ca31045aa6 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts
@@ -2,12 +2,21 @@ import define from "@/server/api/define.js";
 import { Emojis } from "@/models/index.js";
 import { In } from "typeorm";
 import { db } from "@/db/postgre.js";
+import { ApiError } from "@/server/api/error.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
+
+	errors: {
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -29,7 +38,21 @@ export const paramDef = {
 	required: ["ids"],
 } as const;
 
-export default define(meta, paramDef, async (ps) => {
+export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "add" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "unauthorized"))
+		throw new ApiError(meta.errors.accessDenied);
+
+	const emojis = await Emojis.findBy({
+		id: In(ps.ids),
+	});
+
+	// require emoji "mod" permission if a category has already been set
+	if (me.emojiModPerm === "add") {
+		for (const emoji of emojis)
+			if (emoji.category != null) throw new ApiError(meta.errors.accessDenied);
+	}
+
 	await Emojis.update(
 		{
 			id: In(ps.ids),
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts
index e4500e02a2..51c29ade0f 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts
@@ -2,12 +2,21 @@ import define from "@/server/api/define.js";
 import { Emojis } from "@/models/index.js";
 import { In } from "typeorm";
 import { db } from "@/db/postgre.js";
+import { ApiError } from "@/server/api/error.js";
 
 export const meta = {
 	tags: ["admin"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
+
+	errors: {
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
+	},
 } as const;
 
 export const paramDef = {
@@ -29,7 +38,21 @@ export const paramDef = {
 	required: ["ids"],
 } as const;
 
-export default define(meta, paramDef, async (ps) => {
+export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "add" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "unauthorized"))
+		throw new ApiError(meta.errors.accessDenied);
+
+	const emojis = await Emojis.findBy({
+		id: In(ps.ids),
+	});
+
+	// require emoji "mod" permission if a license has already been set
+	if (me.emojiModPerm === "add") {
+		for (const emoji of emojis)
+			if (emoji.license != null) throw new ApiError(meta.errors.accessDenied);
+	}
+
 	await Emojis.update(
 		{
 			id: In(ps.ids),
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
index 6fedf86204..aca2d90078 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
@@ -4,10 +4,10 @@ import { ApiError } from "@/server/api/error.js";
 import { db } from "@/db/postgre.js";
 
 export const meta = {
-	tags: ["admin"],
+	tags: ["admin", "emoji"],
 
 	requireCredential: true,
-	requireModerator: true,
+	requireModerator: false,
 
 	errors: {
 		noSuchEmoji: {
@@ -15,6 +15,11 @@ export const meta = {
 			code: "NO_SUCH_EMOJI",
 			id: "684dec9d-a8c2-4364-9aa8-456c49cb1dc8",
 		},
+		accessDenied: {
+			message: "Access denied.",
+			code: "ACCESS_DENIED",
+			id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9",
+		},
 	},
 } as const;
 
@@ -42,11 +47,25 @@ export const paramDef = {
 	required: ["id", "name", "aliases"],
 } as const;
 
-export default define(meta, paramDef, async (ps) => {
+export default define(meta, paramDef, async (ps, me) => {
+	// require emoji "add" permission
+	if (!(me.isAdmin || me.isModerator || me.emojiModPerm !== "unauthorized"))
+		throw new ApiError(meta.errors.accessDenied);
+
 	const emoji = await Emojis.findOneBy({ id: ps.id });
 
 	if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji);
 
+	// require emoji "mod" permission if a category/alias/license has already been set
+	if (me.emojiModPerm === "add") {
+		if (
+			emoji.category != null ||
+			emoji.aliases.length > 0 ||
+			emoji.license != null
+		)
+			throw new ApiError(meta.errors.accessDenied);
+	}
+
 	await Emojis.update(emoji.id, {
 		updatedAt: new Date(),
 		name: ps.name,
diff --git a/packages/backend/src/server/api/endpoints/admin/set-emoji-moderator.ts b/packages/backend/src/server/api/endpoints/admin/set-emoji-moderator.ts
new file mode 100644
index 0000000000..dfaf54ca7d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/set-emoji-moderator.ts
@@ -0,0 +1,46 @@
+import define from "@/server/api/define.js";
+import { Users } from "@/models/index.js";
+import { publishInternalEvent } from "@/services/stream.js";
+
+export const meta = {
+	tags: ["admin"],
+
+	requireCredential: true,
+	requireModerator: true,
+} as const;
+
+export const paramDef = {
+	type: "object",
+	properties: {
+		userId: { type: "string", format: "misskey:id" },
+		emojiModPerm: { type: "string" },
+	},
+	required: ["userId", "emojiModPerm"],
+} as const;
+
+export default define(meta, paramDef, async (ps) => {
+	const user = await Users.findOneBy({ id: ps.userId });
+
+	if (user == null) {
+		throw new Error("user not found");
+	}
+
+	if (!["unauthorized", "add", "mod", "full"].includes(ps.emojiModPerm)) {
+		throw new Error(
+			"emojiModPerm must be 'unauthorized', 'add', 'mod', or 'full'",
+		);
+	}
+
+	const _emojiModPerm =
+		(ps.emojiModPerm as "unauthorized" | "add" | "mod" | "full") ??
+		"unauthorized";
+
+	await Users.update(user.id, {
+		emojiModPerm: _emojiModPerm,
+	});
+
+	publishInternalEvent("userChangeModeratorState", {
+		id: user.id,
+		isModerator: true,
+	});
+});
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts
index 30f847f642..164d6ae419 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -65,6 +65,7 @@ export default define(meta, paramDef, async (ps, me) => {
 		isModerator: user.isModerator,
 		isSilenced: user.isSilenced,
 		isSuspended: user.isSuspended,
+		emojiModPerm: user.emojiModPerm,
 		lastActiveDate: user.lastActiveDate,
 		moderationNote: profile.moderationNote,
 		signins,
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
index 98dee355e1..5309b1299a 100644
--- a/packages/client/src/pages/user-info.vue
+++ b/packages/client/src/pages/user-info.vue
@@ -178,6 +178,29 @@
 					</FormSection>
 				</div>
 				<div v-else-if="tab === 'moderation'" class="_formRoot">
+					<FormSelect
+						v-model="emojiModPerm"
+						class="_formBlock"
+						@update:modelValue="setEmojiMod"
+					>
+						<template #label>{{ i18n.ts.emojiModPerm }}</template>
+						<option value="unauthorized">
+							{{ i18n.ts._emojiModPerm.unauthorized }}
+						</option>
+						<option value="add">
+							{{ i18n.ts._emojiModPerm.add }}
+						</option>
+						<option value="mod">
+							{{ i18n.ts._emojiModPerm.mod }}
+						</option>
+						<option value="full">
+							{{ i18n.ts._emojiModPerm.full }}
+						</option>
+					</FormSelect>
+					<MkInfo class="_formBlock">{{
+						i18n.ts.emojiModPermDescription
+					}}</MkInfo>
+
 					<FormSwitch
 						v-if="
 							user.host == null &&
@@ -368,6 +391,7 @@ import FormSection from "@/components/form/section.vue";
 import FormButton from "@/components/MkButton.vue";
 import FormInput from "@/components/form/input.vue";
 import FormFolder from "@/components/form/folder.vue";
+import FormSelect from "@/components/form/select.vue";
 import MkKeyValue from "@/components/MkKeyValue.vue";
 import FormSuspense from "@/components/form/suspense.vue";
 import MkFileListForAdmin from "@/components/MkFileListForAdmin.vue";
@@ -393,6 +417,7 @@ const info = ref();
 const ips = ref(null);
 const ap = ref(null);
 const moderator = ref(false);
+const emojiModPerm = ref<"unauthorized" | "add" | "mod" | "full">();
 const silenced = ref(false);
 const suspended = ref(false);
 const driveCapacityOverrideMb = ref<number | null>(0);
@@ -425,6 +450,7 @@ function createFetcher() {
 				info.value = _info;
 				ips.value = _ips;
 				moderator.value = info.value.isModerator;
+				emojiModPerm.value = info.value.emojiModPerm;
 				silenced.value = info.value.isSilenced;
 				suspended.value = info.value.isSuspended;
 				driveCapacityOverrideMb.value =
@@ -510,6 +536,14 @@ async function toggleModerator(v) {
 	await refreshUser();
 }
 
+async function setEmojiMod() {
+	await os.api("admin/set-emoji-moderator", {
+		userId: user.value.id,
+		emojiModPerm: emojiModPerm.value,
+	});
+	await refreshUser();
+}
+
 async function sendModMail() {
 	const { canceled, result } = await os.inputParagraph({
 		title: "Moderation Notice",
diff --git a/packages/client/src/reactiveAccount.ts b/packages/client/src/reactiveAccount.ts
index 38da697f77..19863478a2 100644
--- a/packages/client/src/reactiveAccount.ts
+++ b/packages/client/src/reactiveAccount.ts
@@ -10,4 +10,5 @@ export const $i = accountData
 
 export const isSignedIn = $i != null;
 export const isModerator = $i != null && ($i.isModerator || $i.isAdmin);
-export const isAdmin = $i != null && $i.isAdmin;
+export const isEmojiMod = isModerator || $i?.emojiModPerm !== "unauthorized";
+export const isAdmin = $i?.isAdmin;
diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts
index 97dabc9457..ee9f91fb54 100644
--- a/packages/client/src/router.ts
+++ b/packages/client/src/router.ts
@@ -1,6 +1,6 @@
 import type { AsyncComponentLoader } from "vue";
 import { defineAsyncComponent, inject } from "vue";
-import { $i, isModerator } from "@/reactiveAccount";
+import { $i, isModerator, isEmojiMod } from "@/reactiveAccount";
 import { Router } from "@/nirax";
 import MkError from "@/pages/_error_.vue";
 import MkLoading from "@/pages/_loading_.vue";
@@ -422,6 +422,13 @@ export const routes = [
 			? page(() => import("./pages/admin-file.vue"))
 			: page(() => import("./pages/not-found.vue")),
 	},
+	{
+		path: "/admin/emojis",
+		name: "emojis",
+		component: isEmojiMod
+			? page(() => import("./pages/admin/emojis.vue"))
+			: page(() => import("./pages/not-found.vue")),
+	},
 	{
 		path: "/admin",
 		component: isModerator
@@ -443,11 +450,6 @@ export const routes = [
 				name: "hashtags",
 				component: page(() => import("./pages/admin/hashtags.vue")),
 			},
-			{
-				path: "/emojis",
-				name: "emojis",
-				component: page(() => import("./pages/admin/emojis.vue")),
-			},
 			{
 				path: "/federation",
 				name: "federation",
diff --git a/packages/client/src/ui/_common_/navbar-for-mobile.vue b/packages/client/src/ui/_common_/navbar-for-mobile.vue
index a574df725a..8fb2177380 100644
--- a/packages/client/src/ui/_common_/navbar-for-mobile.vue
+++ b/packages/client/src/ui/_common_/navbar-for-mobile.vue
@@ -80,6 +80,17 @@
 					<i :class="icon('ph-door icon ph-fw')"></i
 					><span class="text">{{ i18n.ts.controlPanel }}</span>
 				</MkA>
+				<MkA
+					v-else-if="$i.emojiModPerm !== 'unauthorized'"
+					v-click-anime
+					v-tooltip.noDelay.right="i18n.ts.customEmojis"
+					class="item _button"
+					active-class="active"
+					to="/admin/emojis"
+				>
+					<i class="icon ph-smiley ph-bold ph-fw ph-lg"></i
+					><span class="text">{{ i18n.ts.customEmojis }}</span>
+				</MkA>
 				<button v-click-anime class="item _button" @click="more">
 					<i :class="icon('ph-dots-three-outline icon ph-fw')"></i
 					><span class="text">{{ i18n.ts.more }}</span>
diff --git a/packages/client/src/ui/_common_/navbar.vue b/packages/client/src/ui/_common_/navbar.vue
index 9c2dc5d8d7..b629ddb85f 100644
--- a/packages/client/src/ui/_common_/navbar.vue
+++ b/packages/client/src/ui/_common_/navbar.vue
@@ -92,6 +92,17 @@
 					><i :class="icon('ph-door icon ph-fw')"></i
 					><span class="text">{{ i18n.ts.controlPanel }}</span>
 				</MkA>
+				<MkA
+					v-else-if="$i.emojiModPerm !== 'unauthorized'"
+					v-click-anime
+					v-tooltip.noDelay.right="i18n.ts.customEmojis"
+					class="item _button"
+					active-class="active"
+					to="/admin/emojis"
+				>
+					<i class="icon ph-smiley ph-bold ph-fw ph-lg"></i
+					><span class="text">{{ i18n.ts.customEmojis }}</span>
+				</MkA>
 				<button
 					v-click-anime
 					v-tooltip.noDelay.right="i18n.ts.more"