diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44a95d744..bc2657ad1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - `SelectionList` option IDs are usable as soon as the widget is instantiated https://github.com/Textualize/textual/issues/3903
 - Fix issue with `Strip.crop` when crop window start aligned with strip end https://github.com/Textualize/textual/pull/3998
 - Fixed Strip.crop_extend https://github.com/Textualize/textual/pull/4011
+- ID and class validation was too lenient https://github.com/Textualize/textual/issues/3954
 - Fixed a crash if the `TextArea` language was set but tree-sitter lanuage binaries were not installed https://github.com/Textualize/textual/issues/4045
 
 
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 461e9acea..2664881d9 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -80,7 +80,7 @@ def check_identifiers(description: str, *names: str) -> None:
         description: Description of where identifier is used for error message.
         *names: Identifiers to check.
     """
-    match = _re_identifier.match
+    match = _re_identifier.fullmatch
     for name in names:
         if match(name) is None:
             raise BadIdentifier(

diff --git a/tests/test_dom.py b/tests/test_dom.py
index 6f06fb809..ea14a25bf 100644
--- a/tests/test_dom.py
+++ b/tests/test_dom.py
@@ -259,3 +259,24 @@ def test_walk_children_with_self_breadth(search):
     ]
 
     assert children == ["f", "e", "d", "c", "b", "a"]
+
+
+@pytest.mark.parametrize(
+    "identifier",
+    [
+        " bad",
+        "  terrible  ",
+        "worse!  ",
+        "&ampersand",
+        "amper&sand",
+        "ampersand&",
+        "2_leading_digits",
+        "água",  # water
+        "cão",  # dog
+        "@'/.23",
+    ],
+)
+def test_id_validation(identifier: str):
+    """Regression tests for https://github.com/Textualize/textual/issues/3954."""
+    with pytest.raises(BadIdentifier):
+        DOMNode(id=identifier)

It is possible to give a widget an ID that can't be queried back
It is possible to give a widget an ID that can't then be queried back; for example:

```python
from textual.app import App, ComposeResult
from textual.widgets import Label

class BadIDApp(App[None]):

    def compose(self) -> ComposeResult:
        yield Label("Hello, World!", id="foo&")

    def on_mount(self) -> None:
        self.notify(f"{self.query_one('#foo&')}")

if __name__ == "__main__":
    BadIDApp().run()
```

Perhaps we should validate widget IDs when they are assigned?
tests/test_dom.py::test_id_validation[worse! tests/test_dom.py::test_id_validation[amper&sand] tests/test_dom.py::test_id_validation[ampersand&] tests/test_dom.py::test_id_validation[c\xe3o]
tests/test_dom.py::test_display_default tests/test_dom.py::test_display_set_bool[True-block] tests/test_dom.py::test_display_set_bool[False-none] tests/test_dom.py::test_display_set_bool[block-block] tests/test_dom.py::test_display_set_bool[none-none] tests/test_dom.py::test_display_set_invalid_value tests/test_dom.py::test_validate tests/test_dom.py::test_classes_setter tests/test_dom.py::test_classes_setter_iterable tests/test_dom.py::test_classes_set_classes tests/test_dom.py::test_inherited_bindings tests/test_dom.py::test__get_default_css tests/test_dom.py::test_component_classes_inheritance tests/test_dom.py::test_walk_children_depth tests/test_dom.py::test_walk_children_with_self_depth tests/test_dom.py::test_walk_children_breadth tests/test_dom.py::test_walk_children_with_self_breadth tests/test_dom.py::test_id_validation[ tests/test_dom.py::test_id_validation[&ampersand] tests/test_dom.py::test_id_validation[2_leading_digits] tests/test_dom.py::test_id_validation[\xe1gua] tests/test_dom.py::test_id_validation[@'/.23]