diff --git a/Cargo.lock b/Cargo.lock
index e6eb69a527706ae4d1ff815c3a2fb86e2921f005..e70356486b16ce06a2f46cda493253c2637b18fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -416,6 +416,7 @@ dependencies = [
  "insta",
  "js-sys",
  "sys-locale",
+ "unicode-segmentation",
  "util",
  "wasm-bindgen",
  "wasm-bindgen-futures",
diff --git a/Cargo.toml b/Cargo.toml
index a8e2e6ecc21a18279dc3677cf6befb4293767841..03eb4da9bf8c7478fa87a7c7bc8d2b5dc91bd327 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@ exclude = ["assets/*"]
 [features]
 multicam = []
 placeholder = []
-password = []
+password = ["unicode-segmentation"]
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
@@ -37,6 +37,7 @@ cosmic-text = { version = "0.11.2" }
 crossbeam-channel = "0.5.8"
 image = "0.24.6"
 sys-locale = "0.3.0"
+unicode-segmentation = { optional = true }
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 arboard = "3.2.0"
diff --git a/src/plugins/password/mod.rs b/src/plugins/password/mod.rs
index 3c1c6dfb37f28bcfe10603664a79602c46681361..d890369a2d7bc6dcde163017629b34772c96599a 100644
--- a/src/plugins/password/mod.rs
+++ b/src/plugins/password/mod.rs
@@ -1,5 +1,6 @@
 use bevy::prelude::*;
 use cosmic_text::{Buffer, Edit, Shaping};
+use unicode_segmentation::UnicodeSegmentation;
 
 use crate::{
     input::{input_mouse, kb_input_text, kb_move_cursor},
@@ -51,6 +52,22 @@ impl Default for Password {
     }
 }
 
+// TODO: impl this on buffer
+fn get_text(buffer: &mut Buffer) -> String {
+    let mut text = String::new();
+    let line_count = buffer.lines.len();
+
+    for (i, line) in buffer.lines.iter().enumerate() {
+        text.push_str(line.text());
+
+        if i < line_count - 1 {
+            text.push('\n');
+        }
+    }
+
+    text
+}
+
 fn hide_password_text(
     mut q: Query<(
         &mut Password,
@@ -62,44 +79,31 @@ fn hide_password_text(
 ) {
     for (mut password, mut buffer, attrs, editor_opt) in q.iter_mut() {
         if let Some(mut editor) = editor_opt {
+            let mut cursor = editor.cursor();
+
             editor.with_buffer_mut(|buffer| {
-                fn get_text(buffer: &mut Buffer) -> String {
-                    let mut text = String::new();
-                    let line_count = buffer.lines.len();
+                let text = get_text(buffer);
 
-                    for (i, line) in buffer.lines.iter().enumerate() {
-                        text.push_str(line.text());
+                let (pre, _post) = text.split_at(cursor.index);
 
-                        if i < line_count - 1 {
-                            text.push('\n');
-                        }
-                    }
+                let graphemes = pre.graphemes(true).count();
 
-                    text
-                }
+                cursor.index = graphemes * password.glyph.len_utf8();
 
-                let text = get_text(buffer);
                 buffer.set_text(
                     &mut font_system,
-                    password.glyph.to_string().repeat(text.len()).as_str(),
+                    password
+                        .glyph
+                        .to_string()
+                        .repeat(text.graphemes(true).count())
+                        .as_str(),
                     attrs.as_attrs(),
                     Shaping::Advanced,
                 );
 
-                for (i, c) in text.char_indices() {
-                    if !text.is_char_boundary(i) || c.len_utf8() > 1 {
-                        panic!("Widechars (like {c}) are not yet supported in password fields.")
-                    }
-                }
-
                 password.real_text = text;
             });
 
-            let mut cursor = editor.cursor();
-            cursor.index *= password.glyph.len_utf8(); // HACK: multiply cursor position assuming no widechars are input
-                                                       // TODO: Count characters until cursor and set new position accordingly,
-                                                       // noting the previous location for restoring
-                                                       // TODO: Look into unicode graphemes
             editor.set_cursor(cursor);
 
             continue;
@@ -127,17 +131,39 @@ fn restore_password_text(
 ) {
     for (password, mut buffer, attrs, editor_opt) in q.iter_mut() {
         if let Some(mut editor) = editor_opt {
+            let mut cursor = editor.cursor();
+            let mut index = 0;
+
             editor.with_buffer_mut(|buffer| {
+                let text = get_text(buffer);
+                let (pre, _post) = text.split_at(cursor.index);
+
+                let grapheme_count = pre.graphemes(true).count();
+
+                let mut g_idx = 0;
+                for (i, _c) in password.real_text.grapheme_indices(true) {
+                    if g_idx == grapheme_count {
+                        index = i;
+                    }
+                    g_idx += 1;
+                }
+
+                // TODO: save/restore with selection bounds
+
+                if cursor.index > 0 && index == 0 {
+                    index = password.real_text.len();
+                }
+
                 buffer.set_text(
                     &mut font_system,
                     password.real_text.as_str(),
                     attrs.as_attrs(),
                     Shaping::Advanced,
-                )
+                );
             });
 
-            let mut cursor = editor.cursor();
-            cursor.index /= password.glyph.len_utf8(); // HACK: restore cursor position assuming no widechars are input
+            cursor.index = index;
+
             editor.set_cursor(cursor);
 
             continue;