Repository: symfony/ux
Pull request #2297 (2024-10-23 21:40:46)

[Autocomplete] Adjust index.rst format
Enforce max line-width
Fix some minor code examples



Patch:
diff --git a/src/Autocomplete/doc/index.rst b/src/Autocomplete/doc/index.rst
index 69ade32a..83df4c96 100644
--- a/src/Autocomplete/doc/index.rst
+++ b/src/Autocomplete/doc/index.rst
@@ -68,7 +68,7 @@ Tom Select-powered UI control by adding the ``autocomplete`` option:
     }
 
 That's all you need! When you refresh, the Autocomplete Stimulus controller
-will transform your select element into a smart UI control:
+will transform your ``<select>`` element into a smart UI control:
 
 .. image:: food-non-ajax.png
    :alt: Screenshot of a Food select with Tom Select
@@ -99,7 +99,8 @@ Or, create the field by hand::
     // src/Form/FoodAutocompleteField.php
     // ...
 
-    use Symfony\Bundle\SecurityBundle\Security;
+    use Symfony\Component\Form\AbstractType;
+    use Symfony\Component\OptionsResolver\OptionsResolver;
     use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
     use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;
 
@@ -283,16 +284,22 @@ Passing Extra Options to the Ajax-powered Autocomplete
 
     The ability to pass extra options was added in Autocomplete 2.14.
 
-Autocomplete field options are not preserved when the field is rendered on an Ajax call. So, features like exclude some options
-based on the current form data are not possible by default. To partially avoid this limitation, the `extra_options` option was added.
+Autocomplete field options are **not preserved** when the field is rendered
+on an Ajax call. So, features like exclude some options based on the current
+form data are not possible by default. 
+
+To partially avoid this limitation, the ``extra_options`` option was added.
 
 .. warning::
 
-    Only scalar values (``string``, ``integer``, ``float``, ``boolean``), ``null`` and ``array`` (consisted from the same types as mentioned before) can be passed as extra options.
+    Only scalar values (``string``, ``integer``, ``float``, ``boolean``),
+    ``null`` and ``array`` (consisted from the same types as mentioned before)
+    can be passed as extra options.
 
-Considering the following example, when the form type is rendered for the first time, it will use the ``query_builder`` defined
-while adding a ``food`` field to the ``FoodForm``. However, when the Ajax is used to fetch the results, on the consequent renders,
-the default ``query_builder`` will be used::
+Considering the following example, when the form type is rendered for the first
+time, it will use the ``query_builder`` defined while adding a ``food`` field 
+to the ``FoodForm``. However, when the Ajax is used to fetch the results, on
+the consequent renders, the default ``query_builder`` will be used::
 
     // src/Form/FoodForm.php
     // ...
@@ -306,21 +313,18 @@ the default ``query_builder`` will be used::
             $builder
                 ->add('food', FoodAutocompleteField::class, [
                     'query_builder' => function (EntityRepository $er) {
-                            $qb = $er->createQueryBuilder('o');
-
-                            $qb->andWhere($qb->expr()->notIn('o.id', [$currentFoodId]));
-
-                            return $qb;
-                        };
-                    }
+                        return $er->createQueryBuilder('o')
+                            ->andWhere($qb->expr()->notIn('o.id', [$currentFoodId]));
+                    };
                 ])
             ;
         }
     }
 
-If some food can be consisted of other foods, we might want to exclude the "root" food from the list of available foods.
-To achieve this, we can remove the ``query_builder`` option from the above example and pass the ``excluded_foods`` extra option
-to the ``FoodAutocompleteField``::
+If some food can be consisted of other foods, we might want to exclude the 
+"root" food from the list of available foods. To achieve this, we can remove 
+the ``query_builder`` option from the above example and pass the ``excluded_foods``
+extra option to the ``FoodAutocompleteField``::
 
     // src/Form/FoodForm.php
     // ...
@@ -341,13 +345,13 @@ to the ``FoodAutocompleteField``::
         }
     }
 
-The magic of the ``extra_options`` is that it will be passed to the ``FoodAutocompleteField`` every time an Ajax call is made.
-So now, we can just use the ``excluded_foods`` extra option in the default ``query_builder`` of the ``FoodAutocompleteField``::
+The magic of the ``extra_options`` is that it will be passed to the ``FoodAutocompleteField``
+every time an Ajax call is made. So now, we can just use the ``excluded_foods``
+extra option in the default ``query_builder`` of the ``FoodAutocompleteField``::
 
     // src/Form/FoodAutocompleteField.php
     // ...
 
-    use Symfony\Bundle\SecurityBundle\Security;
     use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
     use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;
 
@@ -593,12 +597,14 @@ Passing Extra Options to the Autocompleter
 
     The ability to pass extra options was added in Autocomplete 2.14.
 
-If you need to pass extra options to the autocompleter, you can do so by implementing the
-``\Symfony\UX\Autocomplete\OptionsAwareEntityAutocompleterInterface`` interface.
+If you need to pass extra options to the autocompleter, you can do so by 
+implementing the ``\Symfony\UX\Autocomplete\OptionsAwareEntityAutocompleterInterface`` 
+interface.
 
 .. tip::
 
-    If you want to know **why** you might need to use the ``extra_options`` feature, see :ref:`passing-extra-options-to-the-ajax-powered-autocomplete`.
+    If you want to know **why** you might need to use the ``extra_options`` 
+    feature, see :ref:`passing-extra-options-to-the-ajax-powered-autocomplete`.
 
 .. code-block:: diff
 
@@ -634,13 +640,13 @@ If you need to pass extra options to the autocompleter, you can do so by impleme
     +       return $qb;
     +   }
 
-    +/**
-    + * @param array<string, mixed> $options
-    + */
-    +public function setOptions(array $options): void
-    +{
-    +    $this->options = $options;
-    +}
+    +   /**
+    +   * @param array<string, mixed> $options
+    +   */
+    +   public function setOptions(array $options): void
+    +   {
+    +       $this->options = $options;
+    +   }
 
 .. _manual-stimulus-controller:
 
diff --git a/src/Autocomplete/translations/AutocompleteBundle.tr.php b/src/Autocomplete/translations/AutocompleteBundle.tr.php
index 5f5afeba..326d80b4 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.tr.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.tr.php
@@ -13,5 +13,5 @@ return [
     'Loading more results...' => 'Daha fazla sonuç yükleniyor...',
     'No results found' => 'Sonuç bulunamadı',
     'No more results' => 'Başka sonuç yok',
-    'Add %placeholder%...' => '%placeholder% Ekle...',
+    // 'Add %placeholder%...' => 'Add %placeholder%...',
 ];
diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md
index 5674d75e..112a1f9e 100644
--- a/src/LiveComponent/CHANGELOG.md
+++ b/src/LiveComponent/CHANGELOG.md
@@ -1,11 +1,5 @@
 # CHANGELOG
 
-## 2.22.0
-
--   Remove CSRF tokens - rely on same-origin/CORS instead
-
-## 2.19.0
-
 -   Add `submitForm()` to `TestLiveComponent`.
 -   Add `live_action` Twig function
 
diff --git a/src/LiveComponent/assets/dist/Backend/Backend.d.ts b/src/LiveComponent/assets/dist/Backend/Backend.d.ts
index bf934808..c664be92 100644
--- a/src/LiveComponent/assets/dist/Backend/Backend.d.ts
+++ b/src/LiveComponent/assets/dist/Backend/Backend.d.ts
@@ -13,6 +13,7 @@ export interface BackendInterface {
     }, files: {
         [key: string]: FileList;
     }): BackendRequest;
+    updateCsrfToken(csrfToken: string): void;
 }
 export interface BackendAction {
     name: string;
@@ -20,7 +21,7 @@ export interface BackendAction {
 }
 export default class implements BackendInterface {
     private readonly requestBuilder;
-    constructor(url: string, method?: 'get' | 'post');
+    constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null);
     makeRequest(props: any, actions: BackendAction[], updated: {
         [key: string]: any;
     }, children: ChildrenFingerprints, updatedPropsFromParent: {
@@ -28,4 +29,5 @@ export default class implements BackendInterface {
     }, files: {
         [key: string]: FileList;
     }): BackendRequest;
+    updateCsrfToken(csrfToken: string): void;
 }
diff --git a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts
index 45821c56..8a29af3b 100644
--- a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts
+++ b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts
@@ -2,7 +2,8 @@ import type { BackendAction, ChildrenFingerprints } from './Backend';
 export default class {
     private url;
     private method;
-    constructor(url: string, method?: 'get' | 'post');
+    private csrfToken;
+    constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null);
     buildRequest(props: any, actions: BackendAction[], updated: {
         [key: string]: any;
     }, children: ChildrenFingerprints, updatedPropsFromParent: {
@@ -14,4 +15,5 @@ export default class {
         fetchOptions: RequestInit;
     };
     private willDataFitInUrl;
+    updateCsrfToken(csrfToken: string): void;
 }
diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts
index b25a5d06..9b365643 100644
--- a/src/LiveComponent/assets/dist/live_controller.d.ts
+++ b/src/LiveComponent/assets/dist/live_controller.d.ts
@@ -25,6 +25,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
             type: ObjectConstructor;
             default: {};
         };
+        csrf: StringConstructor;
         listeners: {
             type: ArrayConstructor;
             default: never[];
@@ -58,6 +59,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
     readonly urlValue: string;
     readonly propsValue: any;
     propsUpdatedFromParentValue: any;
+    readonly csrfValue: string;
     readonly listenersValue: Array<{
         event: string;
         action: string;
diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js
index 94c9f8dd..22b6b68f 100644
--- a/src/LiveComponent/assets/dist/live_controller.js
+++ b/src/LiveComponent/assets/dist/live_controller.js
@@ -2052,6 +2052,9 @@ class Component {
                 return response;
             }
             this.processRerender(html, backendResponse);
+            if (this.element.dataset.liveCsrfValue) {
+                this.backend.updateCsrfToken(this.element.dataset.liveCsrfValue);
+            }
             this.backendRequest = null;
             thisPromiseResolve(backendResponse);
             if (this.isRequestPending) {
@@ -2252,9 +2255,10 @@ class BackendRequest {
 }
 
 class RequestBuilder {
-    constructor(url, method = 'post') {
+    constructor(url, method = 'post', csrfToken = null) {
         this.url = url;
         this.method = method;
+        this.csrfToken = csrfToken;
     }
     buildRequest(props, actions, updated, children, updatedPropsFromParent, files) {
         const splitUrl = this.url.split('?');
@@ -2291,6 +2295,9 @@ class RequestBuilder {
             if (hasFingerprints) {
                 requestData.children = children;
             }
+            if (this.csrfToken && (actions.length || totalFiles)) {
+                fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
+            }
             if (actions.length > 0) {
                 if (actions.length === 1) {
                     requestData.args = actions[0].args;
@@ -2321,16 +2328,22 @@ class RequestBuilder {
         const urlEncodedJsonData = new URLSearchParams(propsJson + updatedJson + childrenJson + propsFromParentJson).toString();
         return (urlEncodedJsonData + params.toString()).length < 1500;
     }
+    updateCsrfToken(csrfToken) {
+        this.csrfToken = csrfToken;
+    }
 }
 
 class Backend {
-    constructor(url, method = 'post') {
-        this.requestBuilder = new RequestBuilder(url, method);
+    constructor(url, method = 'post', csrfToken = null) {
+        this.requestBuilder = new RequestBuilder(url, method, csrfToken);
     }
     makeRequest(props, actions, updated, children, updatedPropsFromParent, files) {
         const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files);
         return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated));
     }
+    updateCsrfToken(csrfToken) {
+        this.requestBuilder.updateCsrfToken(csrfToken);
+    }
 }
 
 class StimulusElementDriver {
@@ -3190,6 +3203,7 @@ LiveControllerDefault.values = {
     url: String,
     props: { type: Object, default: {} },
     propsUpdatedFromParent: { type: Object, default: {} },
+    csrf: String,
     listeners: { type: Array, default: [] },
     eventsToEmit: { type: Array, default: [] },
     eventsToDispatch: { type: Array, default: [] },
@@ -3198,6 +3212,6 @@ LiveControllerDefault.values = {
     requestMethod: { type: String, default: 'post' },
     queryMapping: { type: Object, default: {} },
 };
-LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue);
+LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue, controller.csrfValue);
 
 export { Component, LiveControllerDefault as default, getComponent };
diff --git a/src/LiveComponent/assets/src/Backend/Backend.ts b/src/LiveComponent/assets/src/Backend/Backend.ts
index 1f456906..b3d019a4 100644
--- a/src/LiveComponent/assets/src/Backend/Backend.ts
+++ b/src/LiveComponent/assets/src/Backend/Backend.ts
@@ -15,6 +15,7 @@ export interface BackendInterface {
         updatedPropsFromParent: { [key: string]: any },
         files: { [key: string]: FileList }
     ): BackendRequest;
+    updateCsrfToken(csrfToken: string): void;
 }
 
 export interface BackendAction {
@@ -25,8 +26,8 @@ export interface BackendAction {
 export default class implements BackendInterface {
     private readonly requestBuilder: RequestBuilder;
 
-    constructor(url: string, method: 'get' | 'post' = 'post') {
-        this.requestBuilder = new RequestBuilder(url, method);
+    constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) {
+        this.requestBuilder = new RequestBuilder(url, method, csrfToken);
     }
 
     makeRequest(
@@ -52,4 +53,8 @@ export default class implements BackendInterface {
             Object.keys(updated)
         );
     }
+
+    updateCsrfToken(csrfToken: string) {
+        this.requestBuilder.updateCsrfToken(csrfToken);
+    }
 }
diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts
index 533e34fe..b9bd2fc1 100644
--- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts
+++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts
@@ -3,10 +3,12 @@ import type { BackendAction, ChildrenFingerprints } from './Backend';
 export default class {
     private url: string;
     private method: 'get' | 'post';
+    private csrfToken: string | null;
 
-    constructor(url: string, method: 'get' | 'post' = 'post') {
+    constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) {
         this.url = url;
         this.method = method;
+        this.csrfToken = csrfToken;
     }
 
     buildRequest(
@@ -62,6 +64,10 @@ export default class {
                 requestData.children = children;
             }
 
+            if (this.csrfToken && (actions.length || totalFiles)) {
+                fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
+            }
+
             if (actions.length > 0) {
                 // one or more ACTIONs
 
@@ -111,4 +117,8 @@ export default class {
         // if the URL gets remotely close to 2000 chars, it may not fit
         return (urlEncodedJsonData + params.toString()).length < 1500;
     }
+
+    updateCsrfToken(csrfToken: string) {
+        this.csrfToken = csrfToken;
+    }
 }
diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts
index 28807c57..18c6a5de 100644
--- a/src/LiveComponent/assets/src/Component/index.ts
+++ b/src/LiveComponent/assets/src/Component/index.ts
@@ -329,6 +329,11 @@ export default class Component {
 
             this.processRerender(html, backendResponse);
 
+            // Store updated csrf token
+            if (this.element.dataset.liveCsrfValue) {
+                this.backend.updateCsrfToken(this.element.dataset.liveCsrfValue);
+            }
+
             // finally resolve this promise
             this.backendRequest = null;
             thisPromiseResolve(backendResponse);
diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts
index 3902d56f..aca5b195 100644
--- a/src/LiveComponent/assets/src/live_controller.ts
+++ b/src/LiveComponent/assets/src/live_controller.ts
@@ -36,6 +36,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
         url: String,
         props: { type: Object, default: {} },
         propsUpdatedFromParent: { type: Object, default: {} },
+        csrf: String,
         listeners: { type: Array, default: [] },
         eventsToEmit: { type: Array, default: [] },
         eventsToDispatch: { type: Array, default: [] },
@@ -49,6 +50,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
     declare readonly urlValue: string;
     declare readonly propsValue: any;
     declare propsUpdatedFromParentValue: any;
+    declare readonly csrfValue: string;
     declare readonly listenersValue: Array<{ event: string; action: string }>;
     declare readonly eventsToEmitValue: Array<{
         event: string;
@@ -77,7 +79,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
     private pendingFiles: { [key: string]: HTMLInputElement } = {};
 
     static backendFactory: (controller: LiveControllerDefault) => BackendInterface = (controller) =>
-        new Backend(controller.urlValue, controller.requestMethodValue);
+        new Backend(controller.urlValue, controller.requestMethodValue, controller.csrfValue);
 
     initialize() {
         this.mutationObserver = new MutationObserver(this.onMutations.bind(this));
diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json
index 7359a8b6..4647b494 100644
--- a/src/LiveComponent/composer.json
+++ b/src/LiveComponent/composer.json
@@ -27,7 +27,6 @@
     },
     "require": {
         "php": ">=8.1",
-        "symfony/deprecation-contracts": "^2.5|^3.0",
         "symfony/property-access": "^5.4.5|^6.0|^7.0",
         "symfony/stimulus-bundle": "^2.9",
         "symfony/ux-twig-component": "^2.8",
diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst
index 64143096..02bf4465 100644
--- a/src/LiveComponent/doc/index.rst
+++ b/src/LiveComponent/doc/index.rst
@@ -1200,14 +1200,28 @@ Actions and CSRF Protection
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 When you trigger an action, a POST request is sent that contains a
-custom ``Accept`` header. This header is automatically populated and
-validated. In other words… you get CSRF protection without any work
-thanks to same-origin / CORS policies implemented by browsers.
+``X-CSRF-TOKEN`` header. This header is automatically populated and
+validated. In other words… you get CSRF protection without any work.
 
-If you want this built-in CSRF protection to be effective, mind your
-CORS headers (e.g. *DO NOT* use `Access-Control-Allow-Origin: *`).
+Your only job is to make sure that the CSRF component is installed:
 
-(In test-mode, the CSRF protection is disabled to make testing easier.)
+.. code-block:: terminal
+
+    $ composer require symfony/security-csrf
+
+If you want to disable CSRF for a single component you can set
+``csrf`` option to ``false``::
+
+    namespace App\Twig\Components;
+
+    use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+    use Symfony\UX\LiveComponent\Attribute\LiveProp;
+
+    #[AsLiveComponent(csrf: false)]
+    class MyLiveComponent
+    {
+        // ...
+    }
 
 Actions, Redirecting and AbstractController
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/src/LiveComponent/src/Attribute/AsLiveComponent.php b/src/LiveComponent/src/Attribute/AsLiveComponent.php
index 497eed31..5f03e0b9 100644
--- a/src/LiveComponent/src/Attribute/AsLiveComponent.php
+++ b/src/LiveComponent/src/Attribute/AsLiveComponent.php
@@ -24,62 +24,33 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
 #[\Attribute(\Attribute::TARGET_CLASS)]
 final class AsLiveComponent extends AsTwigComponent
 {
-    public string $route;
-    public string $method;
-    public int $urlReferenceType;
-
-    private ?string $defaultAction;
-
     /**
-     * @param string|null              $name              The component name (ie: TodoList)
-     * @param string|null              $template          The template path of the component (ie: components/TodoList.html.twig).
-     * @param string|null              $defaultAction     The default action to call when the component is mounted (ie: __invoke)
-     * @param bool                     $exposePublicProps Whether to expose every public property as a Twig variable
-     * @param string                   $attributesVar     The name of the special "attributes" variable in the template
-     * @param string                   $route             The route used to render the component & handle actions
-     * @param string                   $method            The HTTP method to use
-     * @param UrlGeneratorInterface::* $urlReferenceType  Which type of URL should be generated for the given route
+     * @param string|null $name              The component name (ie: TodoList)
+     * @param string|null $template          The template path of the component (ie: components/TodoList.html.twig).
+     * @param string|null $defaultAction     The default action to call when the component is mounted (ie: __invoke)
+     * @param bool        $exposePublicProps Whether to expose every public property as a Twig variable
+     * @param string      $attributesVar     The name of the special "attributes" variable in the template
+     * @param bool        $csrf              Whether to enable CSRF protection (default: true)
+     * @param string      $route             The route used to render the component & handle actions (default: ux_live_component)
+     * @param int         $urlReferenceType  Which type of URL should be generated for the given route. Use the constants from UrlGeneratorInterface (default: absolute path, e.g. "/dir/file").
      */
     public function __construct(
         ?string $name = null,
         ?string $template = null,
-        ?string $defaultAction = null,
+        private ?string $defaultAction = null,
         bool $exposePublicProps = true,
         string $attributesVar = 'attributes',
-        string|bool $route = 'ux_live_component',
-        string $method = 'post',
-        int|string $urlReferenceType = UrlGeneratorInterface::ABSOLUTE_PATH,
-        public bool|int $csrf = true, // @deprecated
+        public bool $csrf = true,
+        public string $route = 'ux_live_component',
+        public string $method = 'post',
+        public int $urlReferenceType = UrlGeneratorInterface::ABSOLUTE_PATH,
     ) {
-        if (8 < \func_num_args() || \is_bool($route)) {
-            trigger_deprecation('symfony/ux-live-component', '2.21', 'Argument "$csrf" of "#[%s]" has no effect anymore and is deprecated.', static::class);
-        }
-        if (\is_bool($route)) {
-            $this->csrf = $route;
-            $route = $method;
-            $method = $urlReferenceType;
-            $urlReferenceType = $csrf;
-
-            switch (\func_num_args()) {
-                case 6: $route = 'ux_live_component';
-                    // no break
-                case 7: $method = 'post';
-                    // no break
-                case 8: $urlReferenceType = UrlGeneratorInterface::ABSOLUTE_PATH;
-                    // no break
-                default:
-            }
-        }
-
         parent::__construct($name, $template, $exposePublicProps, $attributesVar);
 
-        $this->defaultAction = $defaultAction;
-        $this->route = $route;
-        $this->method = strtolower($method);
-        $this->urlReferenceType = $urlReferenceType;
+        $this->method = strtolower($this->method);
 
-        if (!\in_array($method, ['get', 'post'])) {
-            throw new \UnexpectedValueException('$method must be either \'get\' or \'post\'.');
+        if (!\in_array($this->method, ['get', 'post'])) {
+            throw new \UnexpectedValueException('$method must be either \'get\' or \'post\'');
         }
     }
 
diff --git a/src/LiveComponent/src/DependencyInjection/Compiler/OptionalDependencyPass.php b/src/LiveComponent/src/DependencyInjection/Compiler/OptionalDependencyPass.php
index 14bcc18c..95180356 100644
--- a/src/LiveComponent/src/DependencyInjection/Compiler/OptionalDependencyPass.php
+++ b/src/LiveComponent/src/DependencyInjection/Compiler/OptionalDependencyPass.php
@@ -33,10 +33,5 @@ final class OptionalDependencyPass implements CompilerPassInterface
                 ->addTag(LiveComponentBundle::HYDRATION_EXTENSION_TAG)
             ;
         }
-
-        if (!$container->hasDefinition('test.client')) {
-            $container->getDefinition('ux.live_component.event_subscriber')
-                ->setArgument(1, false);
-        }
     }
 }
diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
index 28979e13..c45b8409 100644
--- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
+++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
@@ -127,6 +127,7 @@ final class LiveComponentExtension extends Extension implements PrependExtension
             ->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer'])
             ->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator'])
             ->addTag('container.service_subscriber', ['key' => LiveComponentMetadataFactory::class, 'id' => 'ux.live_component.metadata_factory'])
+            ->addTag('container.service_subscriber') // csrf
         ;
 
         $container->register('ux.live_component.live_responder', LiveResponder::class);
@@ -203,6 +204,7 @@ final class LiveComponentExtension extends Extension implements PrependExtension
                 new Reference('ux.live_component.fingerprint_calculator'),
                 new Reference('router'),
                 new Reference('ux.live_component.live_responder'),
+                new Reference('security.csrf.token_manager', ContainerInterface::NULL_ON_INVALID_REFERENCE),
                 new Reference('ux.live_component.twig.template_mapper'),
             ])
         ;
diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
index 58b5df6d..5d2480f4 100644
--- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
+++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
@@ -21,14 +21,18 @@ use Symfony\Component\HttpKernel\Event\ExceptionEvent;
 use Symfony\Component\HttpKernel\Event\RequestEvent;
 use Symfony\Component\HttpKernel\Event\ResponseEvent;
 use Symfony\Component\HttpKernel\Event\ViewEvent;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
 use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\Security\Csrf\CsrfToken;
+use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
 use Symfony\Contracts\Service\ServiceSubscriberInterface;
 use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
 use Symfony\UX\LiveComponent\Attribute\LiveArg;
 use Symfony\UX\LiveComponent\LiveComponentHydrator;
 use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
+use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
 use Symfony\UX\TwigComponent\ComponentFactory;
 use Symfony\UX\TwigComponent\ComponentMetadata;
 use Symfony\UX\TwigComponent\ComponentRenderer;
@@ -45,10 +49,8 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr
     private const HTML_CONTENT_TYPE = 'application/vnd.live-component+html';
     private const REDIRECT_HEADER = 'X-Live-Redirect';
 
-    public function __construct(
-        private ContainerInterface $container,
-        private bool $testMode = true,
-    ) {
+    public function __construct(private ContainerInterface $container)
+    {
     }
 
     public static function getSubscribedServices(): array
@@ -58,6 +60,7 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr
             ComponentFactory::class,
             LiveComponentHydrator::class,
             LiveComponentMetadataFactory::class,
+            '?'.CsrfTokenManagerInterface::class,
         ];
     }
 
@@ -104,6 +107,13 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr
             throw new MethodNotAllowedHttpException(['POST']);
         }
 
+        if (
+            $this->container->has(CsrfTokenManagerInterface::class)
+            && $metadata->get('csrf')
+            && !$this->container->get(CsrfTokenManagerInterface::class)->isTokenValid(new CsrfToken(LiveControllerAttributesCreator::getCsrfTokeName($componentName), $request->headers->get('X-CSRF-TOKEN')))) {
+            throw new BadRequestHttpException('Invalid CSRF token.');
+        }
+
         if ('_batch' === $action) {
             // use batch controller
             $data = $this->parseDataFor($request);
@@ -289,12 +299,11 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr
             return;
         }
 
-        if (!$response->isRedirection()) {
+        if (!\in_array(self::HTML_CONTENT_TYPE, $request->getAcceptableContentTypes(), true)) {
             return;
         }
 
-        if ($this->testMode && !\in_array(self::HTML_CONTENT_TYPE, $request->getAcceptableContentTypes(), true)) {
-            // Make testing redirections easier
+        if (!$response->isRedirection()) {
             return;
         }
 
@@ -335,17 +344,7 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr
 
     private function isLiveComponentRequest(Request $request): bool
     {
-        if (!$request->attributes->has('_live_component')) {
-            return false;
-        }
-
-        if ($this->testMode) {
-            return true;
-        }
-
-        // Except when testing, require the correct content-type in the Accept header.
-        // This also acts as a CSRF protection since this can only be set in accordance with same-origin/CORS policies.
-        return \in_array(self::HTML_CONTENT_TYPE, $request->getAcceptableContentTypes(), true);
+        return $request->attributes->has('_live_component');
     }
 
     private function hydrateComponent(object $component, string $componentName, Request $request): MountedComponent
diff --git a/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php b/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php
index 79e9c6eb..7920747b 100644
--- a/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php
+++ b/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php
@@ -72,6 +72,7 @@ class ChildComponentPartialRenderer implements ServiceSubscriberInterface
         // optional, but these just aren't needed by the frontend at this point
         unset($attributes['data-controller']);
         unset($attributes['data-live-url-value']);
+        unset($attributes['data-live-csrf-value']);
         unset($attributes['data-live-props-value']);
 
         return $this->createHtml($attributes, $childTag);
diff --git a/src/LiveComponent/src/Util/LiveAttributesCollection.php b/src/LiveComponent/src/Util/LiveAttributesCollection.php
index 71449419..64d6e734 100644
--- a/src/LiveComponent/src/Util/LiveAttributesCollection.php
+++ b/src/LiveComponent/src/Util/LiveAttributesCollection.php
@@ -83,6 +83,11 @@ final class LiveAttributesCollection
         $this->attributes['data-live-url-value'] = $url;
     }
 
+    public function setCsrf(string $csrf): void
+    {
+        $this->attributes['data-live-csrf-value'] = $csrf;
+    }
+
     public function setListeners(array $listeners): void
     {
         $this->attributes['data-live-listeners-value'] = $listeners;
diff --git a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php
index 64d7866e..cb1d8167 100644
--- a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php
+++ b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php
@@ -12,6 +12,7 @@
 namespace Symfony\UX\LiveComponent\Util;
 
 use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
 use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
 use Symfony\UX\LiveComponent\LiveComponentHydrator;
 use Symfony\UX\LiveComponent\LiveResponder;
@@ -44,6 +45,7 @@ class LiveControllerAttributesCreator
         private FingerprintCalculator $fingerprintCalculator,
         private UrlGeneratorInterface $urlGenerator,
         private LiveResponder $liveResponder,
+        private ?CsrfTokenManagerInterface $csrfTokenManager,
         private TemplateMap $templateMap,
     ) {
     }
@@ -129,9 +131,20 @@ class LiveControllerAttributesCreator
         );
         $attributesCollection->setProps($dehydratedProps->getProps());
 
+        if ($this->csrfTokenManager && $metadata->get('csrf')) {
+            $attributesCollection->setCsrf(
+                $this->csrfTokenManager->getToken(self::getCsrfTokeName($mounted->getName()))->getValue(),
+            );
+        }
+
         return $attributesCollection;
     }
 
+    public static function getCsrfTokeName(string $componentName): string
+    {
+        return 'live_component_'.$componentName;
+    }
+
     private function dehydrateComponent(string $name, object $component, ComponentAttributes $attributes): DehydratedProps
     {
         $liveMetadata = $this->metadataFactory->getMetadata($name);


Test patch:
diff --git a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts
index 44521271..22f0e049 100644
--- a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts
+++ b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts
@@ -2,7 +2,7 @@ import RequestBuilder from '../../src/Backend/RequestBuilder';
 
 describe('buildRequest', () => {
     it('sets basic data on GET request', () => {
-        const builder = new RequestBuilder('/_components?existing_param=1', 'get');
+        const builder = new RequestBuilder('/_components?existing_param=1', 'get', '_the_csrf_token');
         const { url, fetchOptions } = builder.buildRequest(
             { firstName: 'Ryan' },
             [],
@@ -23,7 +23,7 @@ describe('buildRequest', () => {
     });
 
     it('sets basic data on POST request', () => {
-        const builder = new RequestBuilder('/_components', 'post');
+        const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
         const { url, fetchOptions } = builder.buildRequest(
             { firstName: 'Ryan' },
             [
@@ -42,6 +42,7 @@ describe('buildRequest', () => {
         expect(fetchOptions.method).toEqual('POST');
         expect(fetchOptions.headers).toEqual({
             Accept: 'application/vnd.live-component+html',
+            'X-CSRF-TOKEN': '_the_csrf_token',
             'X-Requested-With': 'XMLHttpRequest',
         });
         const body = <FormData>fetchOptions.body;
@@ -57,7 +58,7 @@ describe('buildRequest', () => {
     });
 
     it('sets basic data on POST request with batch actions', () => {
-        const builder = new RequestBuilder('/_components', 'post');
+        const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
         const { url, fetchOptions } = builder.buildRequest(
             { firstName: 'Ryan' },
             [
@@ -100,7 +101,7 @@ describe('buildRequest', () => {
 
     // when data is too long it makes a post request
     it('makes a POST request when data is too long', () => {
-        const builder = new RequestBuilder('/_components', 'get');
+        const builder = new RequestBuilder('/_components', 'get', '_the_csrf_token');
         const { url, fetchOptions } = builder.buildRequest(
             { firstName: 'Ryan'.repeat(1000) },
             [],
@@ -128,7 +129,7 @@ describe('buildRequest', () => {
     });
 
     it('makes a POST request when method is post', () => {
-        const builder = new RequestBuilder('/_components', 'post');
+        const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
         const { url, fetchOptions } = builder.buildRequest(
             {
                 firstName: 'Ryan',
@@ -160,7 +161,7 @@ describe('buildRequest', () => {
     });
 
     it('sends propsFromParent when specified', () => {
-        const builder = new RequestBuilder('/_components?existing_param=1', 'get');
+        const builder = new RequestBuilder('/_components?existing_param=1', 'get', '_the_csrf_token');
         const { url } = builder.buildRequest({ firstName: 'Ryan' }, [], { firstName: 'Kevin' }, {}, { count: 5 }, {});
 
         expect(url).toEqual(
@@ -215,7 +216,7 @@ describe('buildRequest', () => {
     };
 
     it('Sends file with request', () => {
-        const builder = new RequestBuilder('/_components', 'post');
+        const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
 
         const { url, fetchOptions } = builder.buildRequest(
             { firstName: 'Ryan' },
@@ -230,6 +231,7 @@ describe('buildRequest', () => {
         expect(fetchOptions.method).toEqual('POST');
         expect(fetchOptions.headers).toEqual({
             Accept: 'application/vnd.live-component+html',
+            'X-CSRF-TOKEN': '_the_csrf_token',
             'X-Requested-With': 'XMLHttpRequest',
         });
         const body = <FormData>fetchOptions.body;
@@ -239,7 +241,7 @@ describe('buildRequest', () => {
     });
 
     it('Sends multiple files with request', () => {
-        const builder = new RequestBuilder('/_components', 'post');
+        const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
 
         const { url, fetchOptions } = builder.buildRequest(
             { firstName: 'Ryan' },
@@ -254,6 +256,7 @@ describe('buildRequest', () => {
         expect(fetchOptions.method).toEqual('POST');
         expect(fetchOptions.headers).toEqual({
             Accept: 'application/vnd.live-component+html',
+            'X-CSRF-TOKEN': '_the_csrf_token',
             'X-Requested-With': 'XMLHttpRequest',
         });
         const body = <FormData>fetchOptions.body;
diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts
index efa875c0..0e328a61 100644
--- a/src/LiveComponent/assets/test/controller/render.test.ts
+++ b/src/LiveComponent/assets/test/controller/render.test.ts
@@ -630,4 +630,23 @@ describe('LiveController rendering Tests', () => {
         // verify the selectedIndex of the select option 2 is 0
         expect(selectOption2.selectedIndex).toBe(0);
     });
+
+    it('backend will have a new csrf token', async () => {
+        const test = await createTest(
+            {},
+            (data: any) => `
+            <div ${initComponent(data)} data-live-csrf-value="${data.csrf}">
+            </div>
+        `
+        );
+
+        test.expectsAjaxCall().serverWillChangeProps((data: any) => {
+            // change csrf token
+            data.csrf = 'Hello';
+        });
+
+        await test.component.render();
+
+        expect(test.mockedBackend.csrfToken).toEqual('Hello');
+    });
 });
diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts
index 791d2fe2..958fdcc8 100644
--- a/src/LiveComponent/assets/test/tools.ts
+++ b/src/LiveComponent/assets/test/tools.ts
@@ -98,6 +98,8 @@ class FunctionalTest {
 class MockedBackend implements BackendInterface {
     private expectedMockedAjaxCalls: Array<MockedAjaxCall> = [];
 
+    public csrfToken: string | null = null;
+
     addMockedAjaxCall(mock: MockedAjaxCall) {
         this.expectedMockedAjaxCalls.push(mock);
     }
@@ -139,6 +141,10 @@ class MockedBackend implements BackendInterface {
         return matchedMock.createBackendRequest();
     }
 
+    updateCsrfToken(csrfToken: string) {
+        this.csrfToken = csrfToken;
+    }
+
     getExpectedMockedAjaxCalls(): Array<MockedAjaxCall> {
         return this.expectedMockedAjaxCalls;
     }
@@ -463,6 +469,7 @@ export function initComponent(props: any = {}, controllerValues: any = {}) {
         data-live-url-value="http://localhost/components/_test_component_${Math.round(Math.random() * 1000)}"
         data-live-props-value="${dataToJsonAttribute(props)}"
         ${controllerValues.debounce ? `data-live-debounce-value="${controllerValues.debounce}"` : ''}
+        ${controllerValues.csrf ? `data-live-csrf-value="${controllerValues.csrf}"` : ''}
         ${controllerValues.id ? `id="${controllerValues.id}"` : ''}
         ${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''}
         ${controllerValues.listeners ? `data-live-listeners-value="${dataToJsonAttribute(controllerValues.listeners)}"` : ''}
diff --git a/src/LiveComponent/src/Test/TestLiveComponent.php b/src/LiveComponent/src/Test/TestLiveComponent.php
index 0e587a38..98def7aa 100644
--- a/src/LiveComponent/src/Test/TestLiveComponent.php
+++ b/src/LiveComponent/src/Test/TestLiveComponent.php
@@ -147,6 +147,8 @@ final class TestLiveComponent
 
     private function request(array $content = [], ?string $action = null, array $files = []): self
     {
+        $csrfToken = $this->csrfToken();
+
         $this->client()->request(
             'POST',
             $this->router->generate(
@@ -159,6 +161,7 @@ final class TestLiveComponent
             ),
             parameters: ['data' => json_encode(array_merge($content, ['props' => $this->props()]))],
             files: $files,
+            server: $csrfToken ? ['HTTP_X_CSRF_TOKEN' => $csrfToken] : [],
         );
 
         return $this;
@@ -175,6 +178,17 @@ final class TestLiveComponent
         return json_decode($node->attr('data-live-props-value'), true, flags: \JSON_THROW_ON_ERROR);
     }
 
+    private function csrfToken(): ?string
+    {
+        $crawler = $this->client()->getCrawler();
+
+        if (!\count($node = $crawler->filter('[data-live-csrf-value]'))) {
+            return null;
+        }
+
+        return $node->attr('data-live-csrf-value');
+    }
+
     private function client(): KernelBrowser
     {
         if ($this->performedInitialRequest) {
diff --git a/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php b/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php
index 2b8325c8..0c765ce2 100644
--- a/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php
+++ b/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php
@@ -18,7 +18,7 @@ use Symfony\UX\LiveComponent\ComponentToolsTrait;
 use Symfony\UX\LiveComponent\DefaultActionTrait;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1;
 
-#[AsLiveComponent('component_with_emit')]
+#[AsLiveComponent('component_with_emit', csrf: false)]
 final class ComponentWithEmit
 {
     use DefaultActionTrait;
diff --git a/src/LiveComponent/tests/Fixtures/Component/DisabledCsrf.php b/src/LiveComponent/tests/Fixtures/Component/DisabledCsrf.php
new file mode 100644
index 00000000..d3c7098f
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/Component/DisabledCsrf.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
+
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveAction;
+use Symfony\UX\LiveComponent\Attribute\LiveProp;
+
+/**
+ * @author Tomas Norkūnas <norkunas.tom@gmail.com>
+ */
+#[AsLiveComponent('disabled_csrf', defaultAction: 'defaultAction()', csrf: false)]
+final class DisabledCsrf
+{
+    #[LiveProp]
+    public int $count = 1;
+
+    #[LiveAction]
+    public function increase(): void
+    {
+        ++$this->count;
+    }
+
+    public function defaultAction(): void
+    {
+    }
+}
diff --git a/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php b/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php
index 1686b9a9..7ca5576c 100644
--- a/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php
+++ b/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php
@@ -25,6 +25,8 @@ use Symfony\UX\LiveComponent\Tests\Fixtures\Form\BlogPostFormType;
 #[AsLiveComponent('form_with_collection_type')]
 class FormWithCollectionTypeComponent extends AbstractController
 {
+    public bool $enableCsrf = false;
+
     use ComponentWithFormTrait;
     use DefaultActionTrait;
 
@@ -39,7 +41,9 @@ class FormWithCollectionTypeComponent extends AbstractController
 
     protected function instantiateForm(): FormInterface
     {
-        return $this->createForm(BlogPostFormType::class, $this->post);
+        return $this->createForm(BlogPostFormType::class, $this->post, [
+            'csrf_protection' => $this->enableCsrf,
+        ]);
     }
 
     #[LiveAction]
diff --git a/src/LiveComponent/tests/Fixtures/Component/ValidatingComponent.php b/src/LiveComponent/tests/Fixtures/Component/ValidatingComponent.php
index 899c2d15..bbf90549 100644
--- a/src/LiveComponent/tests/Fixtures/Component/ValidatingComponent.php
+++ b/src/LiveComponent/tests/Fixtures/Component/ValidatingComponent.php
@@ -10,7 +10,7 @@ use Symfony\UX\LiveComponent\Attribute\LiveProp;
 use Symfony\UX\LiveComponent\DefaultActionTrait;
 use Symfony\UX\LiveComponent\ValidatableComponentTrait;
 
-#[AsLiveComponent('validating_component')]
+#[AsLiveComponent('validating_component', csrf: false)]
 final class ValidatingComponent
 {
     use DefaultActionTrait;
diff --git a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormLiveCollectionType.php b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormLiveCollectionType.php
index e7898ee6..f0404323 100644
--- a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormLiveCollectionType.php
+++ b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormLiveCollectionType.php
@@ -37,6 +37,7 @@ class BlogPostFormLiveCollectionType extends AbstractType
     public function configureOptions(OptionsResolver $resolver)
     {
         $resolver->setDefaults([
+            'csrf_protection' => false,
             'data_class' => BlogPost::class,
         ]);
     }
diff --git a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php
index 584bcdbc..b99f789b 100644
--- a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php
+++ b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php
@@ -38,6 +38,7 @@ class BlogPostFormType extends AbstractType
     {
         $resolver->setDefaults([
             'data_class' => BlogPost::class,
+            'csrf_protection' => false,
         ]);
     }
 }
diff --git a/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php b/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php
index e7b5e5e5..bca009e1 100644
--- a/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php
+++ b/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php
@@ -29,6 +29,7 @@ class CommentFormType extends AbstractType
     public function configureOptions(OptionsResolver $resolver)
     {
         $resolver->setDefaults([
+            'csrf_protection' => false,
             'data_class' => Comment::class,
         ]);
     }
diff --git a/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php b/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php
index ea69b138..7362cab2 100644
--- a/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php
+++ b/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php
@@ -71,4 +71,11 @@ class FormWithManyDifferentFieldsType extends AbstractType
             ->add('complexType', ComplexFieldType::class)
         ;
     }
+
+    public function configureOptions(OptionsResolver $resolver)
+    {
+        $resolver->setDefaults([
+            'csrf_protection' => false,
+        ]);
+    }
 }
diff --git a/src/LiveComponent/tests/Fixtures/Kernel.php b/src/LiveComponent/tests/Fixtures/Kernel.php
index 508dd242..4e6b412f 100644
--- a/src/LiveComponent/tests/Fixtures/Kernel.php
+++ b/src/LiveComponent/tests/Fixtures/Kernel.php
@@ -91,7 +91,6 @@ final class Kernel extends BaseKernel
     protected function configureContainer(ContainerConfigurator $c): void
     {
         $frameworkConfig = [
-            'csrf_protection' => ['enabled' => false],
             'secret' => 'S3CRET',
             'test' => true,
             'router' => ['utf8' => true],
diff --git a/src/LiveComponent/tests/Fixtures/templates/csrf.html.twig b/src/LiveComponent/tests/Fixtures/templates/csrf.html.twig
new file mode 100644
index 00000000..314377bc
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/templates/csrf.html.twig
@@ -0,0 +1,1 @@
+{{ component('disabled_csrf') }}
diff --git a/src/LiveComponent/tests/Fixtures/templates/render_form_with_collection_type.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_form_with_collection_type.html.twig
index 9c8eb4fc..5ce0cc1a 100644
--- a/src/LiveComponent/tests/Fixtures/templates/render_form_with_collection_type.html.twig
+++ b/src/LiveComponent/tests/Fixtures/templates/render_form_with_collection_type.html.twig
@@ -1,1 +1,3 @@
-{{ component('form_with_collection_type') }}
+{{ component('form_with_collection_type', {
+    enableCsrf: true,
+}) }}
diff --git a/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php b/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php
index 6151cff6..0de3b868 100644
--- a/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php
+++ b/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php
@@ -52,6 +52,7 @@ final class BatchActionControllerTest extends KernelTestCase
                             'args' => ['what' => 'first'],
                         ]),
                     ],
+                    'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')],
                 ]);
             })
             ->assertSee('initial')
@@ -71,6 +72,7 @@ final class BatchActionControllerTest extends KernelTestCase
                             ],
                         ]),
                     ],
+                    'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')],
                 ]);
             })
             ->assertSee('initial')
@@ -111,6 +113,7 @@ final class BatchActionControllerTest extends KernelTestCase
                             ],
                         ]),
                     ],
+                    'headers' => ['X-CSRF-TOKEN' => $rootElement->attr('data-live-csrf-value')],
                 ]);
             })
             ->assertOn('/alt/alternate_route/_batch')
@@ -119,6 +122,19 @@ final class BatchActionControllerTest extends KernelTestCase
         ;
     }
 
+    public function testCsrfTokenIsChecked(): void
+    {
+        $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions'));
+
+        $this->browser()
+            ->post('/_components/with_actions/_batch', ['json' => [
+                'props' => $dehydrated->getProps(),
+                'actions' => [],
+            ]])
+            ->assertStatus(400)
+        ;
+    }
+
     public function testRedirect(): void
     {
         $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions'));
@@ -149,6 +165,7 @@ final class BatchActionControllerTest extends KernelTestCase
                             ],
                         ]),
                     ],
+                    'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')],
                 ]);
             })
             ->assertRedirectedTo('/')
@@ -186,6 +203,7 @@ final class BatchActionControllerTest extends KernelTestCase
                     ],
                     'headers' => [
                         'Accept' => ['application/vnd.live-component+html'],
+                        'X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value'),
                     ],
                 ]);
             })
@@ -223,6 +241,7 @@ final class BatchActionControllerTest extends KernelTestCase
                             ],
                         ]),
                     ],
+                    'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')],
                 ]);
             })
         ;
@@ -257,6 +276,7 @@ final class BatchActionControllerTest extends KernelTestCase
                             ],
                         ]),
                     ],
+                    'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')],
                 ]);
             })
         ;
diff --git a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php
index 74d5f975..564a83c6 100644
--- a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php
+++ b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php
@@ -44,6 +44,7 @@ final class AddLiveAttributesSubscriberTest extends KernelTestCase
 
         $this->assertSame('live', $div->attr('data-controller'));
         $this->assertSame('/_components/component_with_writable_props', $div->attr('data-live-url-value'));
+        $this->assertNotNull($div->attr('data-live-csrf-value'));
         $this->assertCount(4, $props);
         $this->assertSame(5, $props['max']);
         $this->assertSame(1, $props['count']);
@@ -65,9 +66,24 @@ final class AddLiveAttributesSubscriberTest extends KernelTestCase
 
         $this->assertSame('live', $div->attr('data-controller'));
         $this->assertSame('/_components/custom_attributes', $div->attr('data-live-url-value'));
+        $this->assertNotNull($div->attr('data-live-csrf-value'));
         $this->assertArrayHasKey('@checksum', $props);
     }
 
+    public function testCanDisableCsrf(): void
+    {
+        $div = $this->browser()
+            ->visit('/render-template/csrf')
+            ->assertSuccessful()
+            ->crawler()
+            ->filter('div')
+        ;
+
+        $this->assertSame('live', $div->attr('data-controller'));
+        $this->assertSame('/_components/disabled_csrf', $div->attr('data-live-url-value'));
+        $this->assertNull($div->attr('data-live-csrf-value'));
+    }
+
     public function testItAddsIdAndFingerprintToChildComponent(): void
     {
         $templateName = 'components/todo_list.html.twig';
@@ -158,6 +174,7 @@ final class AddLiveAttributesSubscriberTest extends KernelTestCase
 
         $this->assertSame('live', $div->attr('data-controller'));
         $this->assertSame('http://localhost/_components/with_absolute_url', $div->attr('data-live-url-value'));
+        $this->assertNotNull($div->attr('data-live-csrf-value'));
         $this->assertCount(3, $props);
         $this->assertArrayHasKey('@checksum', $props);
         $this->assertArrayHasKey('@attributes', $props);
@@ -168,16 +185,19 @@ final class AddLiveAttributesSubscriberTest extends KernelTestCase
 
     public function testAbsoluteUrlWithLiveQueryProp()
     {
+        $token = null;
         $props = [];
         $div = $this->browser()
             ->get('/render-template/render_with_absolute_url?count=1')
             ->assertSuccessful()
             ->assertContains('Count: 1')
-            ->use(function (Crawler $crawler) use (&$props) {
+            ->use(function (Crawler $crawler) use (&$token, &$props) {
                 $div = $crawler->filter('div')->first();
+                $token = $div->attr('data-live-csrf-value');
                 $props = json_decode($div->attr('data-live-props-value'), true);
             })
             ->post('http://localhost/_components/with_absolute_url/increase', [
+                'headers' => ['X-CSRF-TOKEN' => $token],
                 'body' => ['data' => json_encode(['props' => $props])],
             ])
             ->assertContains('Count: 2')
@@ -189,6 +209,7 @@ final class AddLiveAttributesSubscriberTest extends KernelTestCase
 
         $this->assertSame('live', $div->attr('data-controller'));
         $this->assertSame('http://localhost/_components/with_absolute_url', $div->attr('data-live-url-value'));
+        $this->assertNotNull($div->attr('data-live-csrf-value'));
         $this->assertCount(3, $props);
         $this->assertArrayHasKey('@checksum', $props);
         $this->assertArrayHasKey('@attributes', $props);
diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php
index 9ac59da9..e4a87734 100644
--- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php
+++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php
@@ -108,6 +108,7 @@ final class LiveComponentSubscriberTest extends KernelTestCase
                 ]
             )
         );
+        $token = null;
 
         $this->browser()
             ->throwExceptions()
@@ -121,7 +122,12 @@ final class LiveComponentSubscriberTest extends KernelTestCase
             ->assertSuccessful()
             ->assertHeaderContains('Content-Type', 'html')
             ->assertContains('Count: 1')
+            ->use(function (Crawler $crawler) use (&$token) {
+                // get a valid token to use for actions
+                $token = $crawler->filter('div')->first()->attr('data-live-csrf-value');
+            })
             ->post('/_components/component2/increase', [
+                'headers' => ['X-CSRF-TOKEN' => $token],
                 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])],
             ])
             ->assertSuccessful()
@@ -134,6 +140,7 @@ final class LiveComponentSubscriberTest extends KernelTestCase
     public function testCanExecuteComponentActionWithAlternateRoute(): void
     {
         $dehydrated = $this->dehydrateComponent($this->mountComponent('alternate_route'));
+        $token = null;
 
         $this->browser()
             ->throwExceptions()
@@ -146,7 +153,12 @@ final class LiveComponentSubscriberTest extends KernelTestCase
             ])
             ->assertSuccessful()
             ->assertContains('count: 0')
+            ->use(function (Crawler $crawler) use (&$token) {
+                // get a valid token to use for actions
+                $token = $crawler->filter('div')->first()->attr('data-live-csrf-value');
+            })
             ->post('/alt/alternate_route/increase', [
+                'headers' => ['X-CSRF-TOKEN' => $token],
                 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])],
             ])
             ->assertSuccessful()
@@ -171,6 +183,77 @@ final class LiveComponentSubscriberTest extends KernelTestCase
         ;
     }
 
+    public function testMissingCsrfTokenForComponentActionFails(): void
+    {
+        $this->browser()
+            ->post('/_components/component2/increase')
+            ->assertStatus(400)
+        ;
+
+        try {
+            $this->browser()
+                ->throwExceptions()
+                ->post('/_components/component2/increase')
+            ;
+        } catch (BadRequestHttpException $e) {
+            $this->assertSame('Invalid CSRF token.', $e->getMessage());
+
+            return;
+        }
+
+        $this->fail('Expected exception not thrown.');
+    }
+
+    public function testInvalidCsrfTokenForComponentActionFails(): void
+    {
+        $this->browser()
+            ->post('/_components/component2/increase', [
+                'headers' => ['X-CSRF-TOKEN' => 'invalid'],
+            ])
+            ->assertStatus(400)
+        ;
+
+        try {
+            $this->browser()
+                ->throwExceptions()
+                ->post('/_components/component2/increase', [
+                    'headers' => ['X-CSRF-TOKEN' => 'invalid'],
+                ])
+            ;
+        } catch (BadRequestHttpException $e) {
+            $this->assertSame('Invalid CSRF token.', $e->getMessage());
+
+            return;
+        }
+
+        $this->fail('Expected exception not thrown.');
+    }
+
+    public function testDisabledCsrfTokenForComponentDoesNotFail(): void
+    {
+        $dehydrated = $this->dehydrateComponent($this->mountComponent('disabled_csrf'));
+
+        $this->browser()
+            ->throwExceptions()
+            ->post('/_components/disabled_csrf', [
+                'body' => [
+                    'data' => json_encode([
+                        'props' => $dehydrated->getProps(),
+                    ]),
+                ],
+            ])
+            ->assertSuccessful()
+            ->assertHeaderContains('Content-Type', 'html')
+            ->assertContains('Count: 1')
+            ->post('/_components/disabled_csrf/increase', [
+                'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])],
+            ])
+            ->assertSuccessful()
+            ->assertHeaderContains('Content-Type', 'html')
+            ->assertContains('Count: 2')
+        ;
+    }
+
     public function testPreReRenderHookOnlyExecutedDuringAjax(): void
     {
         $dehydrated = $this->dehydrateComponent($this->mountComponent('component2'));
@@ -258,13 +341,20 @@ final class LiveComponentSubscriberTest extends KernelTestCase
             )
         );
 
+        $token = null;
+
         $this->browser()
             ->visit('/render-template/render_multiple_embedded_with_blocks')
             ->assertSuccessful()
             ->assertSeeIn('#component1', 'Overridden content from component 1')
             ->assertSeeIn('#component2', 'Overridden content from component 2 on same line - count: 1')
             ->assertSeeIn('#component3', 'PreReRenderCalled: No')
+            ->use(function (Crawler $crawler) use (&$token) {
+                // get a valid token to use for actions
+                $token = $crawler->filter('div')->eq(1)->attr('data-live-csrf-value');
+            })
             ->post('/_components/component2/increase', [
+                'headers' => ['X-CSRF-TOKEN' => $token],
                 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])],
             ])
             ->assertSuccessful()
@@ -290,13 +380,20 @@ final class LiveComponentSubscriberTest extends KernelTestCase
             )
         );
 
+        $token = null;
+
         $this->browser()
             ->visit('/render-namespaced-template/render_multiple_embedded_with_blocks')
             ->assertSuccessful()
             ->assertSeeIn('#component1', 'Overridden content from component 1')
             ->assertSeeIn('#component2', 'Overridden content from component 2 on same line - count: 1')
             ->assertSeeIn('#component3', 'PreReRenderCalled: No')
+            ->use(function (Crawler $crawler) use (&$token) {
+                // get a valid token to use for actions
+                $token = $crawler->filter('div')->eq(1)->attr('data-live-csrf-value');
+            })
             ->post('/_components/component2/increase', [
+                'headers' => ['X-CSRF-TOKEN' => $token],
                 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])],
             ])
             ->assertSuccessful()
@@ -308,6 +405,7 @@ final class LiveComponentSubscriberTest extends KernelTestCase
     public function testCanRedirectFromComponentAction(): void
     {
         $dehydrated = $this->dehydrateComponent($this->mountComponent('component2'));
+        $token = null;
 
         $this->browser()
             ->throwExceptions()
@@ -319,9 +417,14 @@ final class LiveComponentSubscriberTest extends KernelTestCase
                 ],
             ])
             ->assertSuccessful()
+            ->use(function (Crawler $crawler) use (&$token) {
+                // get a valid token to use for actions
+                $token = $crawler->filter('div')->first()->attr('data-live-csrf-value');
+            })
             ->interceptRedirects()
             // with no custom header, it redirects like a normal browser
             ->post('/_components/component2/redirect', [
+                'headers' => ['X-CSRF-TOKEN' => $token],
                 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])],
             ])
             ->assertRedirectedTo('/')
@@ -330,6 +433,7 @@ final class LiveComponentSubscriberTest extends KernelTestCase
             ->post('/_components/component2/redirect', [
                 'headers' => [
                     'Accept' => 'application/vnd.live-component+html',
+                    'X-CSRF-TOKEN' => $token,
                 ],
                 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])],
             ])
@@ -343,6 +447,7 @@ final class LiveComponentSubscriberTest extends KernelTestCase
     public function testInjectsLiveArgs(): void
     {
         $dehydrated = $this->dehydrateComponent($this->mountComponent('component6'));
+        $token = null;
 
         $arguments = ['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3'];
         $this->browser()
@@ -359,7 +464,12 @@ final class LiveComponentSubscriberTest extends KernelTestCase
             ->assertContains('Arg1: not provided')
             ->assertContains('Arg2: not provided')
             ->assertContains('Arg3: not provided')
+            ->use(function (Crawler $crawler) use (&$token) {
+                // get a valid token to use for actions
+                $token = $crawler->filter('div')->first()->attr('data-live-csrf-value');
+            })
             ->post('/_components/component6/inject', [
+                'headers' => ['X-CSRF-TOKEN' => $token],
                 'body' => [
                     'data' => json_encode([
                         'props' => $dehydrated->getProps(),
@@ -414,6 +524,7 @@ final class LiveComponentSubscriberTest extends KernelTestCase
     public function testCanInjectSecurityUserIntoAction(): void
     {
         $dehydrated = $this->dehydrateComponent($this->mountComponent('with_security'));
+        $token = null;
 
         $this->browser()
             ->actingAs(new InMemoryUser('kevin', 'pass', ['ROLE_USER']))
@@ -426,8 +537,13 @@ final class LiveComponentSubscriberTest extends KernelTestCase
             ])
             ->assertSuccessful()
             ->assertNotSee('username: kevin')
+            ->use(function (Crawler $crawler) use (&$token) {
+                // get a valid token to use for actions
+                $token = $crawler->filter('div')->first()->attr('data-live-csrf-value');
+            })
             ->throwExceptions()
             ->post('/_components/with_security/setUsername', [
+                'headers' => ['X-CSRF-TOKEN' => $token],
                 'body' => [
                     'data' => json_encode([
                         'props' => $dehydrated->getProps(),
diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
index ca0bb583..59f886fd 100644
--- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
+++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
@@ -46,11 +46,13 @@ class ComponentWithFormTest extends KernelTestCase
             'blog_post_form.content' => 'changed description by user',
             'validatedFields' => ['blog_post_form.content'],
         ];
+        $token = $crawler->filter('div')->first()->attr('data-live-csrf-value');
 
         $crawler = $browser
             // post to action, which will add a new embedded comment
             ->post('/_components/form_with_collection_type/addComment', [
                 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps])],
+                'headers' => ['X-CSRF-TOKEN' => $token],
             ])
             ->assertStatus(422)
             // look for original embedded form
@@ -80,11 +82,13 @@ class ComponentWithFormTest extends KernelTestCase
         // fake that this field was being validated
         $updatedProps = ['validatedFields' => $dehydratedProps['validatedFields']];
         $updatedProps['validatedFields'][] = 'blog_post_form.comments.0.content';
+        $token = $div->attr('data-live-csrf-value');
 
         $crawler = $browser
             // post to action, which will remove the original embedded comment
             ->post('/_components/form_with_collection_type/removeComment', [
                 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['index' => '0']])],
+                'headers' => ['X-CSRF-TOKEN' => $token],
             ])
             ->assertStatus(422)
             // the original embedded form should be gone
@@ -106,6 +110,7 @@ class ComponentWithFormTest extends KernelTestCase
             // empty the collection
             ->post('/_components/form_with_collection_type/removeComment', [
                 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'args' => ['index' => '1']])],
+                'headers' => ['X-CSRF-TOKEN' => $token],
             ])
             ->assertStatus(422)
             ->assertNotContains('<textarea id="blog_post_form_comments_')
@@ -323,6 +328,7 @@ class ComponentWithFormTest extends KernelTestCase
 
         $div = $crawler->filter('[data-controller="live"]');
         $dehydratedProps = json_decode($div->attr('data-live-props-value'), true);
+        $token = $div->attr('data-live-csrf-value');
 
         $browser
             ->post('/_components/form_with_many_different_fields_type/submitAndResetForm', [
@@ -330,6 +336,7 @@ class ComponentWithFormTest extends KernelTestCase
                     'props' => $dehydratedProps,
                     'updated' => ['form.textarea' => 'short'],
                 ])],
+                'headers' => ['X-CSRF-TOKEN' => $token],
             ])
             ->assertStatus(200)
             ->assertContains('<textarea id="form_textarea" name="form[textarea]" required="required"></textarea>')
@@ -339,6 +346,7 @@ class ComponentWithFormTest extends KernelTestCase
         $browser
             ->post('/_components/form_with_many_different_fields_type/resetFormWithoutSubmitting', [
                 'body' => ['data' => json_encode(['props' => $dehydratedProps])],
+                'headers' => ['X-CSRF-TOKEN' => $token],
             ])
             ->assertStatus(200)
             ->assertNotContains('textarea is too long')
@@ -350,6 +358,7 @@ class ComponentWithFormTest extends KernelTestCase
     {
         $dehydratedProps = $this->dehydrateComponent($this->mountComponent('form_with_live_collection_type'))->getProps();
         $updatedProps = [];
+        $token = null;
 
         $this->browser()
             ->post('/_components/form_with_live_collection_type', [
@@ -359,16 +368,18 @@ class ComponentWithFormTest extends KernelTestCase
                     ]),
                 ],
             ])
-            ->use(function (Crawler $crawler) use (&$updatedProps) {
+            ->use(function (Crawler $crawler) use (&$dehydratedProps, &$token, &$updatedProps) {
                 // mimic user typing
                 $updatedProps = [
                     'blog_post_form.content' => 'changed description by user',
                     'validatedFields' => ['blog_post_form.content'],
                 ];
+                $token = $crawler->filter('div')->first()->attr('data-live-csrf-value');
             })
             // post to action, which will add a new embedded comment
             ->post('/_components/form_with_live_collection_type/addCollectionItem', [
                 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['name' => 'blog_post_form[comments]']])],
+                'headers' => ['X-CSRF-TOKEN' => $token],
             ])
             ->assertStatus(422)
             // look for original embedded form
@@ -381,7 +392,7 @@ class ComponentWithFormTest extends KernelTestCase
             ->assertContains('The content field is too short')
             // make sure the title field did not suddenly become validated
             ->assertNotContains('The title field should not be blank')
-            ->use(function (Crawler $crawler) use (&$dehydratedProps) {
+            ->use(function (Crawler $crawler) use (&$dehydratedProps, &$token, $updatedProps) {
                 $div = $crawler->filter('[data-controller="live"]');
                 $dehydratedProps = json_decode($div->attr('data-live-props-value'), true);
                 // make sure the 2nd collection type was initialized, that it didn't
@@ -399,11 +410,13 @@ class ComponentWithFormTest extends KernelTestCase
                     $dehydratedProps['validatedFields'],
                     ['blog_post_form.0.comments.content']
                 )];
+                $token = $div->attr('data-live-csrf-value');
             })
 
             // post to action, which will remove the original embedded comment
             ->post('/_components/form_with_live_collection_type/removeCollectionItem', [
                 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['name' => 'blog_post_form[comments]', 'index' => '0']])],
+                'headers' => ['X-CSRF-TOKEN' => $token],
             ])
             ->assertStatus(422)
             // the original embedded form should be gone
diff --git a/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php b/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php
index 1d04d03d..34b1326a 100644
--- a/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php
+++ b/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php
@@ -22,6 +22,8 @@ final class DataModelPropsSubscriberTest extends KernelTestCase
 
     public function testDataModelPropsAreSharedToChild(): void
     {
+        $this->fakeSession();
+
         /** @var ComponentRenderer $renderer */
         $renderer = self::getContainer()->get('ux.twig_component.component_renderer');
 
@@ -41,6 +43,8 @@ final class DataModelPropsSubscriberTest extends KernelTestCase
 
     public function testDataModelPropsAreAvailableInEmbeddedComponents(): void
     {
+        $this->fakeSession();
+
         $templateName = 'components/parent_component_data_model.html.twig';
         $obscuredName = '684c45bf85d3461dbe587407892e59d8';
         $this->addTemplateMap($obscuredName, $templateName);
@@ -55,4 +59,14 @@ final class DataModelPropsSubscriberTest extends KernelTestCase
         $this->assertStringContainsString('<textarea data-model="content">default content on mount</textarea>', $html);
         $this->assertStringContainsString('<input data-model="content" value="default content on mount" />', $html);
     }
+
+    private function fakeSession(): void
+    {
+        // work around so that a session is available so CSRF doesn't fail
+        $session = self::getContainer()->get('session.factory')->createSession();
+        $request = Request::create('/');
+        $request->setSession($session);
+        $requestStack = self::getContainer()->get('request_stack');
+        $requestStack->push($request);
+    }
 }
diff --git a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php
index a8f9cbfa..ba933cf9 100644
--- a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php
+++ b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php
@@ -27,6 +27,7 @@ class LiveAttributesCollectionTest extends KernelTestCase
         $collection->setFingerprint('the-fingerprint');
         $collection->setProps(['the' => 'props']);
         $collection->setUrl('the-live-url');
+        $collection->setCsrf('the-csrf-token');
         $collection->setListeners(['event_name' => 'theActionName']);
         $collection->setEventsToEmit([
             [
@@ -54,6 +55,7 @@ class LiveAttributesCollectionTest extends KernelTestCase
             'data-live-fingerprint-value' => 'the-fingerprint',
             'data-live-props-value' => '&#x7B;&quot;the&quot;&#x3A;&quot;props&quot;&#x7D;',
             'data-live-url-value' => 'the-live-url',
+            'data-live-csrf-value' => 'the-csrf-token',
             'data-live-listeners-value' => '&#x7B;&quot;event_name&quot;&#x3A;&quot;theActionName&quot;&#x7D;',
             'data-live-events-to-emit-value' => '&#x5B;&#x7B;&quot;event&quot;&#x3A;&quot;event_name1&quot;,&quot;data&quot;&#x3A;&#x7B;&quot;the&quot;&#x3A;&quot;data&quot;&#x7D;,&quot;target&quot;&#x3A;&quot;up&quot;,&quot;componentName&quot;&#x3A;&quot;the-component&quot;&#x7D;,&#x7B;&quot;event&quot;&#x3A;&quot;event_name2&quot;,&quot;data&quot;&#x3A;&#x7B;&quot;the&quot;&#x3A;&quot;data&quot;&#x7D;,&quot;target&quot;&#x3A;null,&quot;componentName&quot;&#x3A;null&#x7D;&#x5D;',
             'data-live-query-mapping-value' => '&#x7B;&quot;foo&quot;&#x3A;&#x7B;&quot;name&quot;&#x3A;&quot;foo&quot;&#x7D;,&quot;bar&quot;&#x3A;&#x7B;&quot;name&quot;&#x3A;&quot;bar&quot;&#x7D;&#x7D;',