diff --git a/maud/src/lib.rs b/maud/src/lib.rs
index beaaea2..cd7d13c 100644
--- a/maud/src/lib.rs
+++ b/maud/src/lib.rs
@@ -418,6 +418,67 @@ pub mod macro_private {
     use alloc::string::String;
     use core::fmt::Display;
 
+    pub fn strip_to_attr_name(input: &str, output: &mut String) {
+        for c in input.chars() {
+            match c {
+                ' '
+                | '"'
+                | '\''
+                | '>'
+                | '/'
+                | '='
+                | '\u{0000}'..='\u{001F}'
+                | '\u{FDD0}'..='\u{FDEF}'
+                | '\u{FFFE}'
+                | '\u{FFFF}'
+                | '\u{1FFFE}'
+                | '\u{1FFFF}'
+                | '\u{2FFFE}'
+                | '\u{2FFFF}'
+                | '\u{3FFFE}'
+                | '\u{3FFFF}'
+                | '\u{4FFFE}'
+                | '\u{4FFFF}'
+                | '\u{5FFFE}'
+                | '\u{5FFFF}'
+                | '\u{6FFFE}'
+                | '\u{6FFFF}'
+                | '\u{7FFFE}'
+                | '\u{7FFFF}'
+                | '\u{8FFFE}'
+                | '\u{8FFFF}'
+                | '\u{9FFFE}'
+                | '\u{9FFFF}'
+                | '\u{AFFFE}'
+                | '\u{AFFFF}'
+                | '\u{BFFFE}'
+                | '\u{BFFFF}'
+                | '\u{CFFFE}'
+                | '\u{CFFFF}'
+                | '\u{DFFFE}'
+                | '\u{DFFFF}'
+                | '\u{EFFFE}'
+                | '\u{EFFFF}'
+                | '\u{FFFFE}'
+                | '\u{FFFFF}'
+                | '\u{10FFFE}'
+                | '\u{10FFFF}' => (),
+                _ => output.push(c),
+            }
+        }
+    }
+
+    #[doc(hidden)]
+    #[macro_export]
+    macro_rules! render_attr_name {
+        ($x:expr, $buffer:expr) => {{
+            use $crate::macro_private::strip_to_attr_name;
+            strip_to_attr_name(($x), $buffer);
+        }};
+    }
+
+    pub use render_attr_name;
+
     #[doc(hidden)]
     #[macro_export]
     macro_rules! render_to {
diff --git a/maud/tests/splices.rs b/maud/tests/splices.rs
index 7e2b6d0..3769d61 100644
--- a/maud/tests/splices.rs
+++ b/maud/tests/splices.rs
@@ -83,6 +83,20 @@ fn attribute_name() {
     );
 }
 
+#[test]
+fn no_xss_from_spliced_attributes() {
+    let evil_tuple = (
+        "x onclick=\"alert(42);\" x",
+        "\" onclick=alert(24); href=\"",
+    );
+    let result =
+        html! { button (format!("data-{}", evil_tuple.0))=(evil_tuple.1) { "XSS be gone!" } };
+    assert_eq!(
+        result.into_string(),
+        r#"<button data-xonclickalert(42);x="&quot; onclick=alert(24); href=&quot;">XSS be gone!</button>"#
+    );
+}
+
 /// An example struct, for testing purposes only
 struct Creature {
     name: &'static str,
diff --git a/maud_macros/src/generate.rs b/maud_macros/src/generate.rs
index f432a50..d00157c 100644
--- a/maud_macros/src/generate.rs
+++ b/maud_macros/src/generate.rs
@@ -106,6 +106,13 @@ impl Generator {
         build.push_tokens(quote!(maud::macro_private::render_to!(&(#expr), &mut #output_ident);));
     }
 
+    fn splice_attr_name(&self, expr: TokenStream, build: &mut Builder) {
+        let output_ident = self.output_ident.clone();
+        build.push_tokens(
+            quote!(maud::macro_private::render_attr_name!(&(#expr), &mut #output_ident);),
+        );
+    }
+
     fn element(&self, name: TokenStream, attrs: Vec<Attr>, body: ElementBody, build: &mut Builder) {
         build.push_str("<");
         self.name(name.clone(), build);
@@ -126,7 +133,7 @@ impl Generator {
     fn attr_name(&self, name: AttrName, build: &mut Builder) {
         match name {
             AttrName::Fixed { value } => self.name(value, build),
-            AttrName::Splice { expr, .. } => self.splice(expr, build),
+            AttrName::Splice { expr, .. } => self.splice_attr_name(expr, build),
         }
     }