diff --git a/Cargo.lock b/Cargo.lock
index c5b7100f38..db53b9c45f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -553,6 +553,15 @@ dependencies = [
  "jobserver",
 ]
 
+[[package]]
+name = "censor"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5563d2728feef9a6186acdd148bccbe850dad63c5ba55a3b3355abc9137cb3eb"
+dependencies = [
+ "once_cell",
+]
+
 [[package]]
 name = "cesu8"
 version = "1.1.0"
@@ -6711,6 +6720,7 @@ dependencies = [
  "atomicwrites",
  "authc",
  "bincode",
+ "censor",
  "chrono",
  "chrono-tz",
  "crossbeam-channel",
diff --git a/client/src/lib.rs b/client/src/lib.rs
index 9a583bcb05..d79b949c79 100644
--- a/client/src/lib.rs
+++ b/client/src/lib.rs
@@ -54,12 +54,11 @@ use common::{
 use common_base::{prof_span, span};
 use common_net::{
     msg::{
-        self, validate_chat_msg,
+        self,
         world_msg::{EconomyInfo, PoiInfo, SiteId, SiteInfo},
-        ChatMsgValidationError, ClientGeneral, ClientMsg, ClientRegister, ClientType,
-        DisconnectReason, InviteAnswer, Notification, PingMsg, PlayerInfo, PlayerListUpdate,
-        PresenceKind, RegisterError, ServerGeneral, ServerInit, ServerRegisterAnswer,
-        MAX_BYTES_CHAT_MSG,
+        ClientGeneral, ClientMsg, ClientRegister, ClientType, DisconnectReason, InviteAnswer,
+        Notification, PingMsg, PlayerInfo, PlayerListUpdate, PresenceKind, RegisterError,
+        ServerGeneral, ServerInit, ServerRegisterAnswer,
     },
     sync::WorldSyncExt,
 };
@@ -1551,15 +1550,7 @@ impl Client {
     pub fn inventories(&self) -> ReadStorage<comp::Inventory> { self.state.read_storage() }
 
     /// Send a chat message to the server.
-    pub fn send_chat(&mut self, message: String) {
-        match validate_chat_msg(&message) {
-            Ok(()) => self.send_msg(ClientGeneral::ChatMsg(message)),
-            Err(ChatMsgValidationError::TooLong) => warn!(
-                "Attempted to send a message that's too long (Over {} bytes)",
-                MAX_BYTES_CHAT_MSG
-            ),
-        }
-    }
+    pub fn send_chat(&mut self, message: String) { self.send_msg(ClientGeneral::ChatMsg(message)); }
 
     /// Send a command to the server.
     pub fn send_command(&mut self, name: String, args: Vec<String>) {
diff --git a/common/net/src/msg/mod.rs b/common/net/src/msg/mod.rs
index 087f94452a..c2162a356e 100644
--- a/common/net/src/msg/mod.rs
+++ b/common/net/src/msg/mod.rs
@@ -41,18 +41,3 @@ pub enum PingMsg {
     Ping,
     Pong,
 }
-
-pub const MAX_BYTES_CHAT_MSG: usize = 256;
-
-pub enum ChatMsgValidationError {
-    TooLong,
-}
-
-pub fn validate_chat_msg(msg: &str) -> Result<(), ChatMsgValidationError> {
-    // TODO: Consider using grapheme cluster count instead of size in bytes
-    if msg.len() <= MAX_BYTES_CHAT_MSG {
-        Ok(())
-    } else {
-        Err(ChatMsgValidationError::TooLong)
-    }
-}
diff --git a/common/src/comp/chat.rs b/common/src/comp/chat.rs
index b31857638b..791e47580c 100644
--- a/common/src/comp/chat.rs
+++ b/common/src/comp/chat.rs
@@ -124,6 +124,28 @@ impl<G> ChatType<G> {
             message: msg.into(),
         }
     }
+
+    pub fn uid(&self) -> Option<Uid> {
+        match self {
+            ChatType::Online(_) => None,
+            ChatType::Offline(_) => None,
+            ChatType::CommandInfo => None,
+            ChatType::CommandError => None,
+            ChatType::FactionMeta(_) => None,
+            ChatType::GroupMeta(_) => None,
+            ChatType::Kill(_, _) => None,
+            ChatType::Tell(u, _t) => Some(*u),
+            ChatType::Say(u) => Some(*u),
+            ChatType::Group(u, _s) => Some(*u),
+            ChatType::Faction(u, _s) => Some(*u),
+            ChatType::Region(u) => Some(*u),
+            ChatType::World(u) => Some(*u),
+            ChatType::Npc(u, _r) => Some(*u),
+            ChatType::NpcSay(u, _r) => Some(*u),
+            ChatType::NpcTell(u, _t, _r) => Some(*u),
+            ChatType::Meta => None,
+        }
+    }
 }
 
 // Stores chat text, type
@@ -226,27 +248,7 @@ impl<G> GenericChatMsg<G> {
         }
     }
 
-    pub fn uid(&self) -> Option<Uid> {
-        match &self.chat_type {
-            ChatType::Online(_) => None,
-            ChatType::Offline(_) => None,
-            ChatType::CommandInfo => None,
-            ChatType::CommandError => None,
-            ChatType::FactionMeta(_) => None,
-            ChatType::GroupMeta(_) => None,
-            ChatType::Kill(_, _) => None,
-            ChatType::Tell(u, _t) => Some(*u),
-            ChatType::Say(u) => Some(*u),
-            ChatType::Group(u, _s) => Some(*u),
-            ChatType::Faction(u, _s) => Some(*u),
-            ChatType::Region(u) => Some(*u),
-            ChatType::World(u) => Some(*u),
-            ChatType::Npc(u, _r) => Some(*u),
-            ChatType::NpcSay(u, _r) => Some(*u),
-            ChatType::NpcTell(u, _t, _r) => Some(*u),
-            ChatType::Meta => None,
-        }
-    }
+    pub fn uid(&self) -> Option<Uid> { self.chat_type.uid() }
 }
 
 /// Player factions are used to coordinate pvp vs hostile factions or segment
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 8b7df44dfa..1f4c91b95d 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -58,6 +58,7 @@ slab  = "0.4"
 rand_distr = "0.4.0"
 enumset = "1.0.8"
 noise = { version = "0.7", default-features = false }
+censor = "0.2"
 
 rusqlite = { version = "0.24.2", features = ["array", "vtab", "bundled", "trace"] }
 refinery = { git = "https://gitlab.com/veloren/refinery.git", rev = "8ecf4b4772d791e6c8c0a3f9b66a7530fad1af3e", features = ["rusqlite"] }
diff --git a/server/src/alias_validator.rs b/server/src/alias_validator.rs
deleted file mode 100644
index 7eb27ea3f3..0000000000
--- a/server/src/alias_validator.rs
+++ /dev/null
@@ -1,139 +0,0 @@
-use common::character::MAX_NAME_LENGTH;
-use std::fmt::{self, Display};
-
-#[derive(Debug, Default)]
-pub struct AliasValidator {
-    banned_substrings: Vec<String>,
-}
-
-impl AliasValidator {
-    pub fn new(banned_substrings: Vec<String>) -> Self {
-        let banned_substrings = banned_substrings
-            .iter()
-            .map(|string| string.to_lowercase())
-            .collect();
-
-        AliasValidator { banned_substrings }
-    }
-
-    pub fn validate(&self, alias: &str) -> Result<(), ValidatorError> {
-        if alias.len() > MAX_NAME_LENGTH {
-            return Err(ValidatorError::TooLong(alias.to_owned(), alias.len()));
-        }
-
-        let lowercase_alias = alias.to_lowercase();
-
-        for banned_word in self.banned_substrings.iter() {
-            if lowercase_alias.contains(banned_word) {
-                return Err(ValidatorError::Forbidden(
-                    alias.to_owned(),
-                    banned_word.to_owned(),
-                ));
-            }
-        }
-        Ok(())
-    }
-}
-
-#[derive(Debug, PartialEq)]
-pub enum ValidatorError {
-    Forbidden(String, String),
-    TooLong(String, usize),
-}
-
-impl Display for ValidatorError {
-    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            Self::Forbidden(name, _) => write!(
-                formatter,
-                "Character name \"{}\" contains a banned word",
-                name
-            ),
-            Self::TooLong(name, _) => write!(formatter, "Character name \"{}\" too long", name),
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn multiple_matches() {
-        let banned_substrings = vec!["bad".to_owned(), "worse".to_owned()];
-        let validator = AliasValidator::new(banned_substrings);
-
-        let bad_alias = "BadplayerMcWorseFace";
-        let result = validator.validate(bad_alias);
-
-        assert_eq!(
-            result,
-            Err(ValidatorError::Forbidden(
-                bad_alias.to_owned(),
-                "bad".to_owned()
-            ))
-        );
-    }
-
-    #[test]
-    fn single_lowercase_match() {
-        let banned_substrings = vec!["blue".to_owned()];
-        let validator = AliasValidator::new(banned_substrings);
-
-        let bad_alias = "blueName";
-        let result = validator.validate(bad_alias);
-
-        assert_eq!(
-            result,
-            Err(ValidatorError::Forbidden(
-                bad_alias.to_owned(),
-                "blue".to_owned()
-            ))
-        );
-    }
-
-    #[test]
-    fn single_case_insensitive_match() {
-        let banned_substrings = vec!["GrEEn".to_owned()];
-        let validator = AliasValidator::new(banned_substrings);
-
-        let bad_alias = "gReenName";
-        let result = validator.validate(bad_alias);
-
-        assert_eq!(
-            result,
-            Err(ValidatorError::Forbidden(
-                bad_alias.to_owned(),
-                "green".to_owned()
-            ))
-        );
-    }
-
-    #[test]
-    fn mp_matches() {
-        let banned_substrings = vec!["orange".to_owned()];
-        let validator = AliasValidator::new(banned_substrings);
-
-        let good_alias = "ReasonableName";
-        let result = validator.validate(good_alias);
-
-        assert_eq!(result, Ok(()));
-    }
-
-    #[test]
-    fn too_long() {
-        let banned_substrings = vec!["orange".to_owned()];
-        let validator = AliasValidator::new(banned_substrings);
-
-        let bad_alias = "Thisnameistoolong Muchtoolong MuchTooLongByFar";
-        let result = validator.validate(bad_alias);
-
-        assert_eq!(
-            result,
-            Err(ValidatorError::TooLong(
-                bad_alias.to_owned(),
-                bad_alias.chars().count()
-            ))
-        );
-    }
-}
diff --git a/server/src/automod.rs b/server/src/automod.rs
new file mode 100644
index 0000000000..c9bc3b16e8
--- /dev/null
+++ b/server/src/automod.rs
@@ -0,0 +1,170 @@
+use crate::settings::ModerationSettings;
+use authc::Uuid;
+use censor::Censor;
+use common::comp::AdminRole;
+use hashbrown::HashMap;
+use std::{
+    fmt,
+    sync::Arc,
+    time::{Duration, Instant},
+};
+use tracing::info;
+
+pub const MAX_BYTES_CHAT_MSG: usize = 256;
+
+pub enum ActionNote {
+    SpamWarn,
+}
+
+impl fmt::Display for ActionNote {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            ActionNote::SpamWarn => write!(
+                f,
+                "You've sent a lot of messages recently. Make sure to reduce the rate of messages \
+                 or you will be automatically muted."
+            ),
+        }
+    }
+}
+
+pub enum ActionErr {
+    BannedWord,
+    TooLong,
+    SpamMuted(Duration),
+}
+
+impl fmt::Display for ActionErr {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            ActionErr::BannedWord => write!(
+                f,
+                "Your message contained a banned word. If you think this is a mistake, please let \
+                 a moderator know."
+            ),
+            ActionErr::TooLong => write!(
+                f,
+                "Your message was too long, no more than {} characters are permitted.",
+                MAX_BYTES_CHAT_MSG
+            ),
+            ActionErr::SpamMuted(dur) => write!(
+                f,
+                "You have sent too many messages and are muted for {} seconds.",
+                dur.as_secs_f32() as u64
+            ),
+        }
+    }
+}
+
+pub struct AutoMod {
+    settings: ModerationSettings,
+    censor: Arc<Censor>,
+    players: HashMap<Uuid, PlayerState>,
+}
+
+impl AutoMod {
+    pub fn new(settings: &ModerationSettings, censor: Arc<Censor>) -> Self {
+        if settings.automod {
+            info!(
+                "Automod enabled, players{} will be subject to automated spam/content filters",
+                if settings.admins_exempt {
+                    ""
+                } else {
+                    " (and admins)"
+                }
+            );
+        } else {
+            info!("Automod disabled");
+        }
+
+        Self {
+            settings: settings.clone(),
+            censor,
+            players: HashMap::default(),
+        }
+    }
+
+    pub fn enabled(&self) -> bool { self.settings.automod }
+
+    fn player_mut(&mut self, player: Uuid) -> &mut PlayerState {
+        self.players.entry(player).or_default()
+    }
+
+    pub fn validate_chat_msg(
+        &mut self,
+        player: Uuid,
+        role: Option<AdminRole>,
+        now: Instant,
+        msg: &str,
+    ) -> Result<Option<ActionNote>, ActionErr> {
+        // TODO: Consider using grapheme cluster count instead of size in bytes
+        if msg.len() > MAX_BYTES_CHAT_MSG {
+            Err(ActionErr::TooLong)
+        } else if !self.settings.automod || (role.is_some() && self.settings.admins_exempt) {
+            Ok(None)
+        } else if self.censor.check(msg) {
+            Err(ActionErr::BannedWord)
+        } else {
+            let volume = self.player_mut(player).enforce_message_volume(now);
+
+            if let Some(until) = self.player_mut(player).muted_until {
+                Err(ActionErr::SpamMuted(until.saturating_duration_since(now)))
+            } else if volume > 0.75 {
+                Ok(Some(ActionNote::SpamWarn))
+            } else {
+                Ok(None)
+            }
+        }
+    }
+}
+
+/// The period, in seconds, over which chat volume should be tracked to detect
+/// spam.
+const CHAT_VOLUME_PERIOD: f32 = 30.0;
+/// The maximum permitted average number of chat messages over the chat volume
+/// period.
+const MAX_AVG_MSG_PER_SECOND: f32 = 1.0 / 7.0; // No more than a message every 7 seconds on average
+/// The period for which a player should be muted when they exceed the message
+/// spam threshold.
+const SPAM_MUTE_PERIOD: Duration = Duration::from_secs(180);
+
+#[derive(Default)]
+pub struct PlayerState {
+    last_msg_time: Option<Instant>,
+    /// The average number of messages per second over the last N seconds.
+    chat_volume: f32,
+    muted_until: Option<Instant>,
+}
+
+impl PlayerState {
+    // 0.0 => message is permitted, nothing unusual
+    // >=1.0 => message is not permitted, chat volume exceeded
+    pub fn enforce_message_volume(&mut self, now: Instant) -> f32 {
+        if self.muted_until.map_or(false, |u| u <= now) {
+            self.muted_until = None;
+        }
+
+        if let Some(time_since_last) = self
+            .last_msg_time
+            .map(|last| now.saturating_duration_since(last).as_secs_f32())
+        {
+            let time_proportion = (time_since_last / CHAT_VOLUME_PERIOD).min(1.0);
+            self.chat_volume = self.chat_volume * (1.0 - time_proportion)
+                + (1.0 / time_since_last) * time_proportion;
+        } else {
+            self.chat_volume = 0.0;
+        }
+        self.last_msg_time = Some(now);
+
+        let min_level = 1.0 / CHAT_VOLUME_PERIOD;
+        let max_level = MAX_AVG_MSG_PER_SECOND;
+
+        let volume = ((self.chat_volume - min_level) / (max_level - min_level)).max(0.0);
+
+        if volume > 1.0 && self.muted_until.is_none() {
+            self.muted_until = now.checked_add(SPAM_MUTE_PERIOD);
+        }
+
+        volume
+    }
+}
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 4aa7c134c0..85c2e3a1ad 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -14,7 +14,7 @@
 )]
 #![cfg_attr(not(feature = "worldgen"), feature(const_panic))]
 
-pub mod alias_validator;
+pub mod automod;
 mod character_creator;
 pub mod chunk_generator;
 mod chunk_serialize;
@@ -54,7 +54,7 @@ pub use crate::{
 #[cfg(feature = "persistent_world")]
 use crate::terrain_persistence::TerrainPersistence;
 use crate::{
-    alias_validator::AliasValidator,
+    automod::AutoMod,
     chunk_generator::ChunkGenerator,
     client::Client,
     cmd::ChatCommandExt,
@@ -68,6 +68,7 @@ use crate::{
     state_ext::StateExt,
     sys::sentinel::{DeletedEntities, TrackedStorages},
 };
+use censor::Censor;
 #[cfg(not(feature = "worldgen"))]
 use common::grid::Grid;
 use common::{
@@ -338,36 +339,15 @@ impl Server {
         state.ecs_mut().register::<login_provider::PendingLogin>();
         state.ecs_mut().register::<RepositionOnChunkLoad>();
 
-        //Alias validator
-        let banned_words_paths = &settings.banned_words_files;
-        let mut banned_words = Vec::new();
-        for path in banned_words_paths {
-            let mut list = match std::fs::File::open(&path) {
-                Ok(file) => match ron::de::from_reader(&file) {
-                    Ok(vec) => vec,
-                    Err(error) => {
-                        warn!(?error, ?file, "Couldn't deserialize banned words file");
-                        return Err(Error::Other(format!(
-                            "Couldn't read banned words file \"{}\"",
-                            path.to_string_lossy()
-                        )));
-                    },
-                },
-                Err(error) => {
-                    warn!(?error, ?path, "Couldn't open banned words file");
-                    return Err(Error::Other(format!(
-                        "Couldn't open banned words file \"{}\". Error: {}",
-                        path.to_string_lossy(),
-                        error
-                    )));
-                },
-            };
-            banned_words.append(&mut list);
-        }
-        let banned_words_count = banned_words.len();
-        debug!(?banned_words_count);
-        trace!(?banned_words);
-        state.ecs_mut().insert(AliasValidator::new(banned_words));
+        // Load banned words list
+        let banned_words = settings.moderation.load_banned_words(data_dir);
+        let censor = Arc::new(Censor::Custom(banned_words.into_iter().collect()));
+        state.ecs_mut().insert(Arc::clone(&censor));
+
+        // Init automod
+        state
+            .ecs_mut()
+            .insert(AutoMod::new(&settings.moderation, censor));
 
         #[cfg(feature = "worldgen")]
         let (world, index) = World::generate(
diff --git a/server/src/settings.rs b/server/src/settings.rs
index ecabef5284..896dc8df7f 100644
--- a/server/src/settings.rs
+++ b/server/src/settings.rs
@@ -95,6 +95,44 @@ impl Default for GameplaySettings {
     }
 }
 
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct ModerationSettings {
+    #[serde(default)]
+    pub banned_words_files: Vec<PathBuf>,
+    #[serde(default)]
+    pub automod: bool,
+    #[serde(default)]
+    pub admins_exempt: bool,
+}
+
+impl ModerationSettings {
+    pub fn load_banned_words(&self, data_dir: &Path) -> Vec<String> {
+        let mut banned_words = Vec::new();
+        for fname in self.banned_words_files.iter() {
+            let mut path = with_config_dir(data_dir);
+            path.push(fname);
+            match std::fs::File::open(&path) {
+                Ok(file) => match ron::de::from_reader(&file) {
+                    Ok(mut words) => banned_words.append(&mut words),
+                    Err(error) => error!(?error, ?file, "Couldn't read banned words file"),
+                },
+                Err(error) => error!(?error, ?path, "Couldn't open banned words file"),
+            }
+        }
+        banned_words
+    }
+}
+
+impl Default for ModerationSettings {
+    fn default() -> Self {
+        Self {
+            banned_words_files: Vec::new(),
+            automod: false,
+            admins_exempt: true,
+        }
+    }
+}
+
 #[derive(Clone, Debug, Serialize, Deserialize)]
 pub enum CalendarMode {
     None,
@@ -132,7 +170,6 @@ pub struct Settings {
     /// uses the value of the file options to decide how to proceed.
     pub map_file: Option<FileOpts>,
     pub max_view_distance: Option<u32>,
-    pub banned_words_files: Vec<PathBuf>,
     pub max_player_group_size: u32,
     pub client_timeout: Duration,
     pub spawn_town: Option<String>,
@@ -146,6 +183,8 @@ pub struct Settings {
 
     #[serde(default)]
     pub gameplay: GameplaySettings,
+    #[serde(default)]
+    pub moderation: ModerationSettings,
 }
 
 impl Default for Settings {
@@ -167,7 +206,6 @@ impl Default for Settings {
             start_time: 9.0 * 3600.0,
             map_file: None,
             max_view_distance: Some(65),
-            banned_words_files: Vec::new(),
             max_player_group_size: 6,
             calendar_mode: CalendarMode::Auto,
             client_timeout: Duration::from_secs(40),
@@ -175,6 +213,7 @@ impl Default for Settings {
             max_player_for_kill_broadcast: None,
             experimental_terrain_persistence: false,
             gameplay: GameplaySettings::default(),
+            moderation: ModerationSettings::default(),
         }
     }
 }
@@ -265,7 +304,7 @@ impl Settings {
     }
 }
 
-fn with_config_dir(path: &Path) -> PathBuf {
+pub fn with_config_dir(path: &Path) -> PathBuf {
     let mut path = PathBuf::from(path);
     path.push(CONFIG_DIR);
     path
diff --git a/server/src/state_ext.rs b/server/src/state_ext.rs
index 42bc324286..237f891849 100644
--- a/server/src/state_ext.rs
+++ b/server/src/state_ext.rs
@@ -1,4 +1,5 @@
 use crate::{
+    automod::AutoMod,
     client::Client,
     events::update_map_markers,
     persistence::PersistedComponents,
@@ -17,7 +18,7 @@ use common::{
         self,
         item::MaterialStatManifest,
         skills::{GeneralSkill, Skill},
-        Group, Inventory, Item, Poise,
+        ChatType, Group, Inventory, Item, Player, Poise,
     },
     effect::Effect,
     link::{Link, LinkHandle},
@@ -36,7 +37,7 @@ use specs::{
     saveload::MarkerAllocator, Builder, Entity as EcsEntity, EntityBuilder as EcsEntityBuilder,
     Join, WorldExt,
 };
-use std::time::Duration;
+use std::time::{Duration, Instant};
 use tracing::{trace, warn};
 use vek::*;
 
@@ -113,6 +114,7 @@ pub trait StateExt {
     /// Performed after loading component data from the database
     fn update_character_data(&mut self, entity: EcsEntity, components: PersistedComponents);
     /// Iterates over registered clients and send each `ServerMsg`
+    fn validate_chat_msg(&self, player: EcsEntity, msg: &str) -> bool;
     fn send_chat(&self, msg: comp::UnresolvedChatMsg);
     fn notify_players(&self, msg: ServerGeneral);
     fn notify_in_game_clients(&self, msg: ServerGeneral);
@@ -674,6 +676,39 @@ impl StateExt for State {
         }
     }
 
+    fn validate_chat_msg(&self, entity: EcsEntity, msg: &str) -> bool {
+        let mut automod = self.ecs().write_resource::<AutoMod>();
+        let Some(client) = self.ecs().read_storage::<Client>().get(entity) else { return true };
+        let Some(player) = self.ecs().read_storage::<Player>().get(entity) else { return true };
+
+        match automod.validate_chat_msg(
+            player.uuid(),
+            self.ecs()
+                .read_storage::<comp::Admin>()
+                .get(entity)
+                .map(|a| a.0),
+            Instant::now(),
+            msg,
+        ) {
+            Ok(note) => {
+                if let Some(note) = note {
+                    let _ = client.send(ServerGeneral::server_msg(
+                        ChatType::CommandInfo,
+                        format!("{}", note),
+                    ));
+                }
+                true
+            },
+            Err(err) => {
+                let _ = client.send(ServerGeneral::server_msg(
+                    ChatType::CommandError,
+                    format!("{}", err),
+                ));
+                false
+            },
+        }
+    }
+
     /// Send the chat message to the proper players. Say and region are limited
     /// by location. Faction and group are limited by component.
     fn send_chat(&self, msg: comp::UnresolvedChatMsg) {
@@ -689,165 +724,183 @@ impl StateExt for State {
             .clone()
             .map_group(|_| group_info.map_or_else(|| "???".to_string(), |i| i.name.clone()));
 
-        match &msg.chat_type {
-            comp::ChatType::Offline(_)
-            | comp::ChatType::CommandInfo
-            | comp::ChatType::CommandError
-            | comp::ChatType::Meta
-            | comp::ChatType::World(_) => self.notify_players(ServerGeneral::ChatMsg(resolved_msg)),
-            comp::ChatType::Online(u) => {
-                for (client, uid) in
-                    (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
-                {
-                    if uid != u {
-                        client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
+        if msg.chat_type.uid().map_or(true, |sender| {
+            (*ecs.read_resource::<UidAllocator>())
+                .retrieve_entity_internal(sender.0)
+                .map_or(false, |e| self.validate_chat_msg(e, &msg.message))
+        }) {
+            match &msg.chat_type {
+                comp::ChatType::Offline(_)
+                | comp::ChatType::CommandInfo
+                | comp::ChatType::CommandError
+                | comp::ChatType::Meta
+                | comp::ChatType::World(_) => {
+                    self.notify_players(ServerGeneral::ChatMsg(resolved_msg))
+                },
+                comp::ChatType::Online(u) => {
+                    for (client, uid) in
+                        (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
+                    {
+                        if uid != u {
+                            client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
+                        }
                     }
-                }
-            },
-            comp::ChatType::Tell(u, t) => {
-                for (client, uid) in
-                    (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
-                {
-                    if uid == u || uid == t {
-                        client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
-                    }
-                }
-            },
-            comp::ChatType::Kill(kill_source, uid) => {
-                let comp::chat::GenericChatMsg { message, .. } = msg;
-                let clients = ecs.read_storage::<Client>();
-                let clients_count = clients.count();
-                // Avoid chat spam, send kill message only to group or nearby players if a
-                // certain amount of clients are online
-                if clients_count
-                    > ecs
-                        .fetch::<Settings>()
-                        .max_player_for_kill_broadcast
-                        .unwrap_or_default()
-                {
-                    // Send kill message to the dead player's group
-                    let killed_entity =
-                        (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
-                    let groups = ecs.read_storage::<Group>();
-                    let killed_group = killed_entity.and_then(|e| groups.get(e));
-                    if let Some(g) = &killed_group {
-                        send_to_group(g, ecs, &resolved_msg);
+                },
+                comp::ChatType::Tell(from, to) => {
+                    for (client, uid) in
+                        (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
+                    {
+                        if uid == from || uid == to {
+                            client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
+                        }
                     }
+                },
+                comp::ChatType::Kill(kill_source, uid) => {
+                    let comp::chat::GenericChatMsg { message, .. } = msg;
+                    let clients = ecs.read_storage::<Client>();
+                    let clients_count = clients.count();
+                    // Avoid chat spam, send kill message only to group or nearby players if a
+                    // certain amount of clients are online
+                    if clients_count
+                        > ecs
+                            .fetch::<Settings>()
+                            .max_player_for_kill_broadcast
+                            .unwrap_or_default()
+                    {
+                        // Send kill message to the dead player's group
+                        let killed_entity =
+                            (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
+                        let groups = ecs.read_storage::<Group>();
+                        let killed_group = killed_entity.and_then(|e| groups.get(e));
+                        if let Some(g) = &killed_group {
+                            send_to_group(g, ecs, &resolved_msg);
+                        }
 
-                    // Send kill message to nearby players that aren't part of the deceased's group
-                    let positions = ecs.read_storage::<comp::Pos>();
-                    if let Some(died_player_pos) = killed_entity.and_then(|e| positions.get(e)) {
-                        for (ent, client, pos) in (&*ecs.entities(), &clients, &positions).join() {
-                            let client_group = groups.get(ent);
-                            let is_different_group =
-                                !(killed_group == client_group && client_group.is_some());
-                            if is_within(comp::ChatMsg::SAY_DISTANCE, pos, died_player_pos)
-                                && is_different_group
+                        // Send kill message to nearby players that aren't part of the deceased's
+                        // group
+                        let positions = ecs.read_storage::<comp::Pos>();
+                        if let Some(died_player_pos) = killed_entity.and_then(|e| positions.get(e))
+                        {
+                            for (ent, client, pos) in
+                                (&*ecs.entities(), &clients, &positions).join()
                             {
+                                let client_group = groups.get(ent);
+                                let is_different_group =
+                                    !(killed_group == client_group && client_group.is_some());
+                                if is_within(comp::ChatMsg::SAY_DISTANCE, pos, died_player_pos)
+                                    && is_different_group
+                                {
+                                    client.send_fallible(ServerGeneral::ChatMsg(
+                                        resolved_msg.clone(),
+                                    ));
+                                }
+                            }
+                        }
+                    } else {
+                        self.notify_players(ServerGeneral::server_msg(
+                            comp::ChatType::Kill(kill_source.clone(), *uid),
+                            message,
+                        ))
+                    }
+                },
+                comp::ChatType::Say(uid) => {
+                    let entity_opt =
+                        (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
+
+                    let positions = ecs.read_storage::<comp::Pos>();
+                    if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
+                        for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
+                            if is_within(comp::ChatMsg::SAY_DISTANCE, pos, speaker_pos) {
                                 client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
                             }
                         }
                     }
-                } else {
-                    self.notify_players(ServerGeneral::server_msg(
-                        comp::ChatType::Kill(kill_source.clone(), *uid),
-                        message,
-                    ))
-                }
-            },
-            comp::ChatType::Say(uid) => {
-                let entity_opt =
-                    (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
-                let positions = ecs.read_storage::<comp::Pos>();
-                if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
-                    for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
-                        if is_within(comp::ChatMsg::SAY_DISTANCE, pos, speaker_pos) {
-                            client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
-                        }
-                    }
-                }
-            },
-            comp::ChatType::Region(uid) => {
-                let entity_opt =
-                    (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
-                let positions = ecs.read_storage::<comp::Pos>();
-                if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
-                    for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
-                        if is_within(comp::ChatMsg::REGION_DISTANCE, pos, speaker_pos) {
-                            client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
-                        }
-                    }
-                }
-            },
-            comp::ChatType::Npc(uid, _r) => {
-                let entity_opt =
-                    (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
-                let positions = ecs.read_storage::<comp::Pos>();
-                if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
-                    for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
-                        if is_within(comp::ChatMsg::NPC_DISTANCE, pos, speaker_pos) {
-                            client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
-                        }
-                    }
-                }
-            },
-            comp::ChatType::NpcSay(uid, _r) => {
-                let entity_opt =
-                    (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
-                let positions = ecs.read_storage::<comp::Pos>();
-                if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
-                    for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
-                        if is_within(comp::ChatMsg::NPC_SAY_DISTANCE, pos, speaker_pos) {
-                            client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
-                        }
-                    }
-                }
-            },
-            comp::ChatType::NpcTell(from, to, _r) => {
-                for (client, uid) in
-                    (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
-                {
-                    if uid == from || uid == to {
-                        client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
-                    }
-                }
-            },
-            comp::ChatType::FactionMeta(s) | comp::ChatType::Faction(_, s) => {
-                for (client, faction) in (
-                    &ecs.read_storage::<Client>(),
-                    &ecs.read_storage::<comp::Faction>(),
-                )
-                    .join()
-                {
-                    if s == &faction.0 {
-                        client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
-                    }
-                }
-            },
-            comp::ChatType::Group(from, g) => {
-                if group_info.is_none() {
-                    // group not found, reply with command error
-                    let reply = comp::ChatMsg {
-                        chat_type: comp::ChatType::CommandError,
-                        message: "You are using group chat but do not belong to a group. Use \
-                                  /world or /region to change chat."
-                            .into(),
-                    };
+                },
+                comp::ChatType::Region(uid) => {
+                    let entity_opt =
+                        (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
 
-                    if let Some((client, _)) =
-                        (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>())
-                            .join()
-                            .find(|(_, uid)| *uid == from)
-                    {
-                        client.send_fallible(ServerGeneral::ChatMsg(reply));
+                    let positions = ecs.read_storage::<comp::Pos>();
+                    if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
+                        for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
+                            if is_within(comp::ChatMsg::REGION_DISTANCE, pos, speaker_pos) {
+                                client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
+                            }
+                        }
                     }
-                    return;
-                }
-                send_to_group(g, ecs, &resolved_msg);
-            },
-            comp::ChatType::GroupMeta(g) => {
-                send_to_group(g, ecs, &resolved_msg);
-            },
+                },
+                comp::ChatType::Npc(uid, _r) => {
+                    let entity_opt =
+                        (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
+
+                    let positions = ecs.read_storage::<comp::Pos>();
+                    if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
+                        for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
+                            if is_within(comp::ChatMsg::NPC_DISTANCE, pos, speaker_pos) {
+                                client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
+                            }
+                        }
+                    }
+                },
+                comp::ChatType::NpcSay(uid, _r) => {
+                    let entity_opt =
+                        (*ecs.read_resource::<UidAllocator>()).retrieve_entity_internal(uid.0);
+
+                    let positions = ecs.read_storage::<comp::Pos>();
+                    if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) {
+                        for (client, pos) in (&ecs.read_storage::<Client>(), &positions).join() {
+                            if is_within(comp::ChatMsg::NPC_SAY_DISTANCE, pos, speaker_pos) {
+                                client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
+                            }
+                        }
+                    }
+                },
+                comp::ChatType::NpcTell(from, to, _r) => {
+                    for (client, uid) in
+                        (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>()).join()
+                    {
+                        if uid == from || uid == to {
+                            client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
+                        }
+                    }
+                },
+                comp::ChatType::FactionMeta(s) | comp::ChatType::Faction(_, s) => {
+                    for (client, faction) in (
+                        &ecs.read_storage::<Client>(),
+                        &ecs.read_storage::<comp::Faction>(),
+                    )
+                        .join()
+                    {
+                        if s == &faction.0 {
+                            client.send_fallible(ServerGeneral::ChatMsg(resolved_msg.clone()));
+                        }
+                    }
+                },
+                comp::ChatType::Group(from, g) => {
+                    if group_info.is_none() {
+                        // group not found, reply with command error
+                        let reply = comp::ChatMsg {
+                            chat_type: comp::ChatType::CommandError,
+                            message: "You are using group chat but do not belong to a group. Use \
+                                      /world or /region to change chat."
+                                .into(),
+                        };
+
+                        if let Some((client, _)) =
+                            (&ecs.read_storage::<Client>(), &ecs.read_storage::<Uid>())
+                                .join()
+                                .find(|(_, uid)| *uid == from)
+                        {
+                            client.send_fallible(ServerGeneral::ChatMsg(reply));
+                        }
+                        return;
+                    }
+                    send_to_group(g, ecs, &resolved_msg);
+                },
+                comp::ChatType::GroupMeta(g) => {
+                    send_to_group(g, ecs, &resolved_msg);
+                },
+            }
         }
     }
 
diff --git a/server/src/sys/msg/character_screen.rs b/server/src/sys/msg/character_screen.rs
index 7190a5a91a..d9a12f58f0 100644
--- a/server/src/sys/msg/character_screen.rs
+++ b/server/src/sys/msg/character_screen.rs
@@ -1,5 +1,5 @@
 use crate::{
-    alias_validator::AliasValidator,
+    automod::AutoMod,
     character_creator,
     client::Client,
     persistence::{character_loader::CharacterLoader, character_updater::CharacterUpdater},
@@ -14,7 +14,7 @@ use common::{
 use common_ecs::{Job, Origin, Phase, System};
 use common_net::msg::{ClientGeneral, ServerGeneral};
 use specs::{Entities, Join, Read, ReadExpect, ReadStorage, WriteExpect};
-use std::sync::atomic::Ordering;
+use std::sync::{atomic::Ordering, Arc};
 use tracing::debug;
 
 impl Sys {
@@ -29,7 +29,8 @@ impl Sys {
         admins: &ReadStorage<'_, Admin>,
         presences: &ReadStorage<'_, Presence>,
         editable_settings: &ReadExpect<'_, EditableSettings>,
-        alias_validator: &ReadExpect<'_, AliasValidator>,
+        censor: &ReadExpect<'_, Arc<censor::Censor>>,
+        automod: &AutoMod,
         msg: ClientGeneral,
     ) -> Result<(), crate::error::Error> {
         let mut send_join_messages = || -> Result<(), crate::error::Error> {
@@ -41,6 +42,14 @@ impl Sys {
                 ))?;
             }
 
+            // Warn them about automod
+            if automod.enabled() {
+                client.send(ServerGeneral::server_msg(
+                    ChatType::CommandInfo,
+                    "Automatic moderation is enabled: play nice and have fun!",
+                ))?;
+            }
+
             if !client.login_msg_sent.load(Ordering::Relaxed) {
                 if let Some(player_uid) = uids.get(entity) {
                     server_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg {
@@ -128,9 +137,12 @@ impl Sys {
                 offhand,
                 body,
             } => {
-                if let Err(error) = alias_validator.validate(&alias) {
-                    debug!(?error, ?alias, "denied alias as it contained a banned word");
-                    client.send(ServerGeneral::CharacterActionError(error.to_string()))?;
+                if censor.check(&alias) {
+                    debug!(?alias, "denied alias as it contained a banned word");
+                    client.send(ServerGeneral::CharacterActionError(format!(
+                        "Alias '{}' contains a banned word",
+                        alias
+                    )))?;
                 } else if let Some(player) = players.get(entity) {
                     if let Err(error) = character_creator::create_character(
                         entity,
@@ -153,9 +165,12 @@ impl Sys {
                 }
             },
             ClientGeneral::EditCharacter { id, alias, body } => {
-                if let Err(error) = alias_validator.validate(&alias) {
-                    debug!(?error, ?alias, "denied alias as it contained a banned word");
-                    client.send(ServerGeneral::CharacterActionError(error.to_string()))?;
+                if censor.check(&alias) {
+                    debug!(?alias, "denied alias as it contained a banned word");
+                    client.send(ServerGeneral::CharacterActionError(format!(
+                        "Alias '{}' contains a banned word",
+                        alias
+                    )))?;
                 } else if let Some(player) = players.get(entity) {
                     if let Err(error) = character_creator::edit_character(
                         entity,
@@ -210,7 +225,8 @@ impl<'a> System<'a> for Sys {
         ReadStorage<'a, Admin>,
         ReadStorage<'a, Presence>,
         ReadExpect<'a, EditableSettings>,
-        ReadExpect<'a, AliasValidator>,
+        ReadExpect<'a, Arc<censor::Censor>>,
+        ReadExpect<'a, AutoMod>,
     );
 
     const NAME: &'static str = "msg::character_screen";
@@ -230,7 +246,8 @@ impl<'a> System<'a> for Sys {
             admins,
             presences,
             editable_settings,
-            alias_validator,
+            censor,
+            automod,
         ): Self::SystemData,
     ) {
         let mut server_emitter = server_event_bus.emitter();
@@ -248,7 +265,8 @@ impl<'a> System<'a> for Sys {
                     &admins,
                     &presences,
                     &editable_settings,
-                    &alias_validator,
+                    &censor,
+                    &automod,
                     msg,
                 )
             });
diff --git a/server/src/sys/msg/general.rs b/server/src/sys/msg/general.rs
index 0527da7dcf..0588308b96 100644
--- a/server/src/sys/msg/general.rs
+++ b/server/src/sys/msg/general.rs
@@ -6,9 +6,7 @@ use common::{
     uid::Uid,
 };
 use common_ecs::{Job, Origin, Phase, System};
-use common_net::msg::{
-    validate_chat_msg, ChatMsgValidationError, ClientGeneral, MAX_BYTES_CHAT_MSG,
-};
+use common_net::msg::ClientGeneral;
 use specs::{Entities, Join, Read, ReadStorage};
 use tracing::{debug, error, warn};
 
@@ -25,24 +23,16 @@ impl Sys {
         match msg {
             ClientGeneral::ChatMsg(message) => {
                 if player.is_some() {
-                    match validate_chat_msg(&message) {
-                        Ok(()) => {
-                            if let Some(from) = uids.get(entity) {
-                                const CHAT_MODE_DEFAULT: &ChatMode = &ChatMode::default();
-                                let mode = chat_modes.get(entity).unwrap_or(CHAT_MODE_DEFAULT);
-                                // Send chat message
-                                server_emitter
-                                    .emit(ServerEvent::Chat(mode.new_message(*from, message)));
-                            } else {
-                                error!("Could not send message. Missing player uid");
-                            }
-                        },
-                        Err(ChatMsgValidationError::TooLong) => {
-                            let max = MAX_BYTES_CHAT_MSG;
-                            let len = message.len();
-                            warn!(?len, ?max, "Received a chat message that's too long")
-                        },
+                    if let Some(from) = uids.get(entity) {
+                        const CHAT_MODE_DEFAULT: &ChatMode = &ChatMode::default();
+                        let mode = chat_modes.get(entity).unwrap_or(CHAT_MODE_DEFAULT);
+                        // Send chat message
+                        server_emitter.emit(ServerEvent::Chat(mode.new_message(*from, message)));
+                    } else {
+                        error!("Could not send message. Missing player uid");
                     }
+                } else {
+                    warn!("Received a chat message from an unregistered client");
                 }
             },
             ClientGeneral::Command(name, args) => {
@@ -93,7 +83,7 @@ impl<'a> System<'a> for Sys {
     ) {
         let mut server_emitter = server_event_bus.emitter();
 
-        for (entity, client, player) in (&entities, &clients, (&players).maybe()).join() {
+        for (entity, client, player) in (&entities, &clients, players.maybe()).join() {
             let res = super::try_recv_all(client, 3, |client, msg| {
                 Self::handle_general_msg(
                     &mut server_emitter,
diff --git a/voxygen/src/hud/chat.rs b/voxygen/src/hud/chat.rs
index adc2541cf8..dbdffb21cb 100644
--- a/voxygen/src/hud/chat.rs
+++ b/voxygen/src/hud/chat.rs
@@ -9,7 +9,6 @@ use common::comp::{
     group::Role,
     BuffKind, ChatMode, ChatMsg, ChatType,
 };
-use common_net::msg::validate_chat_msg;
 use conrod_core::{
     color,
     input::Key,
@@ -120,9 +119,7 @@ impl<'a> Chat<'a> {
     }
 
     pub fn input(mut self, input: String) -> Self {
-        if let Ok(()) = validate_chat_msg(&input) {
-            self.force_input = Some(input);
-        }
+        self.force_input = Some(input);
         self
     }
 
@@ -388,9 +385,7 @@ impl<'a> Widget for Chat<'a> {
                 .set(state.ids.chat_input, ui)
             {
                 input.retain(|c| c != '\n');
-                if let Ok(()) = validate_chat_msg(&input) {
-                    state.update(|s| s.input.message = input);
-                }
+                state.update(|s| s.input.message = input);
             }
         }