From 104a6c9865d37d7f4cb2f80c604e69a9f967320a Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 02:12:53 +0300 Subject: [PATCH 1/9] Switch default CSRF token to HMAC --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++- config/di-web.php | 6 ++--- config/params.php | 4 ++-- tests/ConfigTest.php | 32 +++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5664696..a44d7fc 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,43 @@ return [ ## CSRF Tokens In case Yii framework is used along with config plugin, the package is [configured](./config/di-web.php) -automatically to use synchronizer token and masked decorator. You can change that depending on your needs. +automatically to use HMAC based token and masked decorator. You can change that depending on your needs. + +HMAC token is the default because it is stateless and avoids session storage I/O on every protected request: + +| Factor | Synchronizer | HMAC | +|--------|--------------|------| +| I/O per request | Session read and write | No token storage I/O | +| File based session GC | May scan session files | Not triggered by CSRF token storage | +| Token storage growth | Depends on session storage | Nothing to store | +| Token revocation | Possible by removing stored token | Not possible before token expiration | +| Replay within lifetime | Prevented by storage policy | Possible until the token expires | + +Use HMAC when protected forms are available only to authenticated users, token revocation on logout is not required, +and every environment has its own secret key. The default config reads the key from `YII_CSRF_SECRET_KEY`. +Set it to a high-entropy value and keep `yiisoft/csrf` `hmacToken` `lifetime` short, typically a few minutes. + +Use synchronizer token instead when unauthenticated users submit sensitive forms such as payments, registration with +personal data, or password reset, when guaranteed single-use or revocable tokens are required, or when the session I/O +cost is acceptable for your application. + +To switch the config-plugin default back to synchronizer token: + +```php +use Yiisoft\Csrf\CsrfTokenInterface; +use Yiisoft\Csrf\MaskedCsrfToken; +use Yiisoft\Csrf\Synchronizer\SynchronizerCsrfToken; +use Yiisoft\Definitions\Reference; + +return [ + CsrfTokenInterface::class => [ + 'class' => MaskedCsrfToken::class, + '__construct()' => [ + 'token' => Reference::to(SynchronizerCsrfToken::class), + ], + ], +]; +``` ### Synchronizer CSRF token @@ -177,6 +213,20 @@ Parameters set via the `HmacCsrfToken` constructor are: - `$algorithm` — hash algorithm for message authentication. `sha256`, `sha384` or `sha512` are recommended; - `$lifetime` — number of seconds that the token is valid for. +With the config plugin, these constructor arguments are configured through parameters: + +```php +return [ + 'yiisoft/csrf' => [ + 'hmacToken' => [ + 'secretKey' => (string) getenv('YII_CSRF_SECRET_KEY'), + 'algorithm' => 'sha256', + 'lifetime' => 300, + ], + ], +]; +``` + To learn more about HMAC based token pattern [check OWASP CSRF cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#hmac-based-token-pattern). diff --git a/config/di-web.php b/config/di-web.php index 200d03a..921f491 100644 --- a/config/di-web.php +++ b/config/di-web.php @@ -4,11 +4,11 @@ use Yiisoft\Csrf\MaskedCsrfToken; use Yiisoft\Csrf\CsrfTokenInterface; +use Yiisoft\Csrf\Hmac\HmacCsrfToken; +use Yiisoft\Csrf\Hmac\IdentityGenerator\SessionCsrfTokenIdentityGenerator; use Yiisoft\Csrf\Synchronizer\Generator\RandomCsrfTokenGenerator; use Yiisoft\Csrf\Synchronizer\Storage\SessionCsrfTokenStorage; use Yiisoft\Csrf\Synchronizer\SynchronizerCsrfToken; -use Yiisoft\Csrf\Hmac\IdentityGenerator\SessionCsrfTokenIdentityGenerator; -use Yiisoft\Csrf\Hmac\HmacCsrfToken; use Yiisoft\Definitions\Reference; /* @var array $params */ @@ -17,7 +17,7 @@ CsrfTokenInterface::class => [ 'class' => MaskedCsrfToken::class, '__construct()' => [ - 'token' => Reference::to(SynchronizerCsrfToken::class), + 'token' => Reference::to(HmacCsrfToken::class), ], ], diff --git a/config/params.php b/config/params.php index 815a6e9..3b2b0e8 100644 --- a/config/params.php +++ b/config/params.php @@ -5,9 +5,9 @@ return [ 'yiisoft/csrf' => [ 'hmacToken' => [ - 'secretKey' => '', + 'secretKey' => (string) getenv('YII_CSRF_SECRET_KEY'), 'algorithm' => 'sha256', - 'lifetime' => null, + 'lifetime' => 300, ], ], ]; diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 690bb30..58e7d06 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -5,6 +5,7 @@ namespace Yiisoft\Csrf\Tests; use PHPUnit\Framework\TestCase; +use ReflectionProperty; use Yiisoft\Csrf\CsrfTokenInterface; use Yiisoft\Csrf\Hmac\HmacCsrfToken; use Yiisoft\Csrf\MaskedCsrfToken; @@ -15,6 +16,8 @@ use Yiisoft\Session\SessionInterface; use function dirname; +use function getenv; +use function putenv; final class ConfigTest extends TestCase { @@ -27,10 +30,31 @@ public function testBase(): void $hmacCsrfToken = $container->get(HmacCsrfToken::class); $this->assertInstanceOf(MaskedCsrfToken::class, $csrfToken); + $this->assertInstanceOf(HmacCsrfToken::class, $this->getDecoratedToken($csrfToken)); $this->assertInstanceOf(SynchronizerCsrfToken::class, $synchronizerCsrfToken); $this->assertInstanceOf(HmacCsrfToken::class, $hmacCsrfToken); } + public function testHmacSecretKeyCanBeSetViaEnvironment(): void + { + $oldSecretKey = getenv('YII_CSRF_SECRET_KEY'); + + try { + putenv('YII_CSRF_SECRET_KEY=test-secret-key'); + + $params = $this->getParams(); + + $this->assertSame('test-secret-key', $params['yiisoft/csrf']['hmacToken']['secretKey']); + $this->assertSame(300, $params['yiisoft/csrf']['hmacToken']['lifetime']); + } finally { + if ($oldSecretKey === false) { + putenv('YII_CSRF_SECRET_KEY'); + } else { + putenv('YII_CSRF_SECRET_KEY=' . $oldSecretKey); + } + } + } + private function createContainer(?array $params = null): Container { return new Container( @@ -53,4 +77,12 @@ private function getParams(): array { return require dirname(__DIR__) . '/config/params.php'; } + + private function getDecoratedToken(MaskedCsrfToken $csrfToken): CsrfTokenInterface + { + $property = new ReflectionProperty(MaskedCsrfToken::class, 'token'); + $property->setAccessible(true); + + return $property->getValue($csrfToken); + } } From 620b2486af12eaaa7b684fc0de26c8f4514e3e6c Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 10:12:15 +0300 Subject: [PATCH 2/9] Add CSRF token decision graph --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index a44d7fc..406b726 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,21 @@ HMAC token is the default because it is stateless and avoids session storage I/O | Token revocation | Possible by removing stored token | Not possible before token expiration | | Replay within lifetime | Prevented by storage policy | Possible until the token expires | +Use this decision graph to choose a token validation method: + +```mermaid +flowchart TD + A{Do unauthenticated users submit sensitive forms?} + A -- Yes --> S[Synchronizer token] + A -- No --> B{Need guaranteed single-use tokens or token revocation?} + B -- Yes --> S + B -- No --> C{Can every environment provide YII_CSRF_SECRET_KEY?} + C -- No --> S + C -- Yes --> D{Is replay within a short lifetime acceptable?} + D -- No --> S + D -- Yes --> H[HMAC token] +``` + Use HMAC when protected forms are available only to authenticated users, token revocation on logout is not required, and every environment has its own secret key. The default config reads the key from `YII_CSRF_SECRET_KEY`. Set it to a high-entropy value and keep `yiisoft/csrf` `hmacToken` `lifetime` short, typically a few minutes. From 32b19ba8d9f88777d73d979832f5f0ed3790e5cf Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 10:19:50 +0300 Subject: [PATCH 3/9] Shorten CSRF decision graph labels --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 406b726..8cb426d 100644 --- a/README.md +++ b/README.md @@ -158,15 +158,15 @@ Use this decision graph to choose a token validation method: ```mermaid flowchart TD - A{Do unauthenticated users submit sensitive forms?} - A -- Yes --> S[Synchronizer token] - A -- No --> B{Need guaranteed single-use tokens or token revocation?} + A{Anon sensitive forms?} + A -- Yes --> S[Synchronizer] + A -- No --> B{Single-use or revocation?} B -- Yes --> S - B -- No --> C{Can every environment provide YII_CSRF_SECRET_KEY?} + B -- No --> C{Env secret?} C -- No --> S - C -- Yes --> D{Is replay within a short lifetime acceptable?} + C -- Yes --> D{Short replay OK?} D -- No --> S - D -- Yes --> H[HMAC token] + D -- Yes --> H[HMAC] ``` Use HMAC when protected forms are available only to authenticated users, token revocation on logout is not required, From c81ed08046d3cf057442b369a57cee3c868c76c3 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 10:22:31 +0300 Subject: [PATCH 4/9] Clarify CSRF decision graph labels --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8cb426d..66589bf 100644 --- a/README.md +++ b/README.md @@ -158,13 +158,13 @@ Use this decision graph to choose a token validation method: ```mermaid flowchart TD - A{Anon sensitive forms?} + A{Anonymous sensitive forms?} A -- Yes --> S[Synchronizer] - A -- No --> B{Single-use or revocation?} + A -- No --> B{Need one-time or revocable tokens?} B -- Yes --> S - B -- No --> C{Env secret?} + B -- No --> C{Per-environment secret key?} C -- No --> S - C -- Yes --> D{Short replay OK?} + C -- Yes --> D{Token replay within lifetime OK?} D -- No --> S D -- Yes --> H[HMAC] ``` From c3297eb954544252f4588899a1eb0232c040bf89 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 10:26:00 +0300 Subject: [PATCH 5/9] Keep synchronizer as default CSRF token --- README.md | 16 ++++++++-------- config/di-web.php | 6 +++--- config/params.php | 4 ++-- tests/ConfigTest.php | 32 -------------------------------- 4 files changed, 13 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 66589bf..a1881b9 100644 --- a/README.md +++ b/README.md @@ -142,9 +142,9 @@ return [ ## CSRF Tokens In case Yii framework is used along with config plugin, the package is [configured](./config/di-web.php) -automatically to use HMAC based token and masked decorator. You can change that depending on your needs. +automatically to use synchronizer token and masked decorator. You can change that depending on your needs. -HMAC token is the default because it is stateless and avoids session storage I/O on every protected request: +HMAC token is stateless and avoids session storage I/O on every protected request: | Factor | Synchronizer | HMAC | |--------|--------------|------| @@ -170,26 +170,26 @@ flowchart TD ``` Use HMAC when protected forms are available only to authenticated users, token revocation on logout is not required, -and every environment has its own secret key. The default config reads the key from `YII_CSRF_SECRET_KEY`. -Set it to a high-entropy value and keep `yiisoft/csrf` `hmacToken` `lifetime` short, typically a few minutes. +and every environment has its own secret key. Set it to a high-entropy value and keep `yiisoft/csrf` `hmacToken` +`lifetime` short, typically a few minutes. Use synchronizer token instead when unauthenticated users submit sensitive forms such as payments, registration with personal data, or password reset, when guaranteed single-use or revocable tokens are required, or when the session I/O cost is acceptable for your application. -To switch the config-plugin default back to synchronizer token: +To switch the config-plugin token to HMAC: ```php use Yiisoft\Csrf\CsrfTokenInterface; +use Yiisoft\Csrf\Hmac\HmacCsrfToken; use Yiisoft\Csrf\MaskedCsrfToken; -use Yiisoft\Csrf\Synchronizer\SynchronizerCsrfToken; use Yiisoft\Definitions\Reference; return [ CsrfTokenInterface::class => [ 'class' => MaskedCsrfToken::class, '__construct()' => [ - 'token' => Reference::to(SynchronizerCsrfToken::class), + 'token' => Reference::to(HmacCsrfToken::class), ], ], ]; @@ -228,7 +228,7 @@ Parameters set via the `HmacCsrfToken` constructor are: - `$algorithm` — hash algorithm for message authentication. `sha256`, `sha384` or `sha512` are recommended; - `$lifetime` — number of seconds that the token is valid for. -With the config plugin, these constructor arguments are configured through parameters: +When using HMAC with the config plugin, configure these constructor arguments through parameters: ```php return [ diff --git a/config/di-web.php b/config/di-web.php index 921f491..200d03a 100644 --- a/config/di-web.php +++ b/config/di-web.php @@ -4,11 +4,11 @@ use Yiisoft\Csrf\MaskedCsrfToken; use Yiisoft\Csrf\CsrfTokenInterface; -use Yiisoft\Csrf\Hmac\HmacCsrfToken; -use Yiisoft\Csrf\Hmac\IdentityGenerator\SessionCsrfTokenIdentityGenerator; use Yiisoft\Csrf\Synchronizer\Generator\RandomCsrfTokenGenerator; use Yiisoft\Csrf\Synchronizer\Storage\SessionCsrfTokenStorage; use Yiisoft\Csrf\Synchronizer\SynchronizerCsrfToken; +use Yiisoft\Csrf\Hmac\IdentityGenerator\SessionCsrfTokenIdentityGenerator; +use Yiisoft\Csrf\Hmac\HmacCsrfToken; use Yiisoft\Definitions\Reference; /* @var array $params */ @@ -17,7 +17,7 @@ CsrfTokenInterface::class => [ 'class' => MaskedCsrfToken::class, '__construct()' => [ - 'token' => Reference::to(HmacCsrfToken::class), + 'token' => Reference::to(SynchronizerCsrfToken::class), ], ], diff --git a/config/params.php b/config/params.php index 3b2b0e8..815a6e9 100644 --- a/config/params.php +++ b/config/params.php @@ -5,9 +5,9 @@ return [ 'yiisoft/csrf' => [ 'hmacToken' => [ - 'secretKey' => (string) getenv('YII_CSRF_SECRET_KEY'), + 'secretKey' => '', 'algorithm' => 'sha256', - 'lifetime' => 300, + 'lifetime' => null, ], ], ]; diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 58e7d06..690bb30 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -5,7 +5,6 @@ namespace Yiisoft\Csrf\Tests; use PHPUnit\Framework\TestCase; -use ReflectionProperty; use Yiisoft\Csrf\CsrfTokenInterface; use Yiisoft\Csrf\Hmac\HmacCsrfToken; use Yiisoft\Csrf\MaskedCsrfToken; @@ -16,8 +15,6 @@ use Yiisoft\Session\SessionInterface; use function dirname; -use function getenv; -use function putenv; final class ConfigTest extends TestCase { @@ -30,31 +27,10 @@ public function testBase(): void $hmacCsrfToken = $container->get(HmacCsrfToken::class); $this->assertInstanceOf(MaskedCsrfToken::class, $csrfToken); - $this->assertInstanceOf(HmacCsrfToken::class, $this->getDecoratedToken($csrfToken)); $this->assertInstanceOf(SynchronizerCsrfToken::class, $synchronizerCsrfToken); $this->assertInstanceOf(HmacCsrfToken::class, $hmacCsrfToken); } - public function testHmacSecretKeyCanBeSetViaEnvironment(): void - { - $oldSecretKey = getenv('YII_CSRF_SECRET_KEY'); - - try { - putenv('YII_CSRF_SECRET_KEY=test-secret-key'); - - $params = $this->getParams(); - - $this->assertSame('test-secret-key', $params['yiisoft/csrf']['hmacToken']['secretKey']); - $this->assertSame(300, $params['yiisoft/csrf']['hmacToken']['lifetime']); - } finally { - if ($oldSecretKey === false) { - putenv('YII_CSRF_SECRET_KEY'); - } else { - putenv('YII_CSRF_SECRET_KEY=' . $oldSecretKey); - } - } - } - private function createContainer(?array $params = null): Container { return new Container( @@ -77,12 +53,4 @@ private function getParams(): array { return require dirname(__DIR__) . '/config/params.php'; } - - private function getDecoratedToken(MaskedCsrfToken $csrfToken): CsrfTokenInterface - { - $property = new ReflectionProperty(MaskedCsrfToken::class, 'token'); - $property->setAccessible(true); - - return $property->getValue($csrfToken); - } } From 1371a0ec431270cdfed5e2827e53b5b8aec70741 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 10:30:36 +0300 Subject: [PATCH 6/9] Reorder CSRF token selection docs --- README.md | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a1881b9..033ad21 100644 --- a/README.md +++ b/README.md @@ -144,17 +144,8 @@ return [ In case Yii framework is used along with config plugin, the package is [configured](./config/di-web.php) automatically to use synchronizer token and masked decorator. You can change that depending on your needs. -HMAC token is stateless and avoids session storage I/O on every protected request: - -| Factor | Synchronizer | HMAC | -|--------|--------------|------| -| I/O per request | Session read and write | No token storage I/O | -| File based session GC | May scan session files | Not triggered by CSRF token storage | -| Token storage growth | Depends on session storage | Nothing to store | -| Token revocation | Possible by removing stored token | Not possible before token expiration | -| Replay within lifetime | Prevented by storage policy | Possible until the token expires | - -Use this decision graph to choose a token validation method: +Use synchronizer token when you need stateful, revocable validation and HMAC token when protected forms are +authenticated-only and stateless validation with short-lived replay risk is acceptable. ```mermaid flowchart TD @@ -169,13 +160,15 @@ flowchart TD D -- Yes --> H[HMAC] ``` -Use HMAC when protected forms are available only to authenticated users, token revocation on logout is not required, -and every environment has its own secret key. Set it to a high-entropy value and keep `yiisoft/csrf` `hmacToken` -`lifetime` short, typically a few minutes. +Detailed comparison: -Use synchronizer token instead when unauthenticated users submit sensitive forms such as payments, registration with -personal data, or password reset, when guaranteed single-use or revocable tokens are required, or when the session I/O -cost is acceptable for your application. +| Factor | Synchronizer | HMAC | +|--------|--------------|------| +| I/O per request | Session read and write | No token storage I/O | +| File based session GC | May scan session files | Not triggered by CSRF token storage | +| Token storage growth | Depends on session storage | Nothing to store | +| Token revocation | Possible by removing stored token | Not possible before token expiration | +| Replay within lifetime | Prevented by storage policy | Possible until the token expires | To switch the config-plugin token to HMAC: From e615ec0694e1087c4aae8e1a7d0fc02d7c99b199 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 11:06:35 +0300 Subject: [PATCH 7/9] Simplify CSRF token selection wording --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 033ad21..087a195 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ return [ In case Yii framework is used along with config plugin, the package is [configured](./config/di-web.php) automatically to use synchronizer token and masked decorator. You can change that depending on your needs. -Use synchronizer token when you need stateful, revocable validation and HMAC token when protected forms are -authenticated-only and stateless validation with short-lived replay risk is acceptable. +Use synchronizer token for sensitive anonymous forms or tokens that must be one-time or revocable; use HMAC token for +authenticated-only forms when a short token replay window is acceptable. ```mermaid flowchart TD From ddc3942cceb7d86eac67a001e1a67f93e7c9b479 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 11:09:40 +0300 Subject: [PATCH 8/9] Apply suggestions from code review Co-authored-by: Alexander Makarov --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 087a195..2b9b4f3 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ authenticated-only forms when a short token replay window is acceptable. ```mermaid flowchart TD - A{Anonymous sensitive forms?} + A{Anonymous forms to protect?} A -- Yes --> S[Synchronizer] A -- No --> B{Need one-time or revocable tokens?} B -- Yes --> S @@ -170,7 +170,7 @@ Detailed comparison: | Token revocation | Possible by removing stored token | Not possible before token expiration | | Replay within lifetime | Prevented by storage policy | Possible until the token expires | -To switch the config-plugin token to HMAC: +To switch token to HMAC: ```php use Yiisoft\Csrf\CsrfTokenInterface; From a5b227e9d4d1b8e7d7ec3b7abb94e062c601d3cc Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 11:11:09 +0300 Subject: [PATCH 9/9] Clarify CSRF token reuse wording --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2b9b4f3..ba66755 100644 --- a/README.md +++ b/README.md @@ -144,14 +144,14 @@ return [ In case Yii framework is used along with config plugin, the package is [configured](./config/di-web.php) automatically to use synchronizer token and masked decorator. You can change that depending on your needs. -Use synchronizer token for sensitive anonymous forms or tokens that must be one-time or revocable; use HMAC token for -authenticated-only forms when a short token replay window is acceptable. +Use synchronizer token for sensitive anonymous forms; use HMAC token for authenticated-only forms when a submitted +token may stay valid for a few minutes. ```mermaid flowchart TD A{Anonymous forms to protect?} A -- Yes --> S[Synchronizer] - A -- No --> B{Need one-time or revocable tokens?} + A -- No --> B{Old or repeated submits must fail?} B -- Yes --> S B -- No --> C{Per-environment secret key?} C -- No --> S