The Security component is one of the most powerful and at the same time most complex parts of Symfony. One of the most commonly misunderstood aspects: CSRF protection. In this article, we dive deep into practice — including Shopware's controversial decision to drop CSRF entirely.
Basic Security Component Configuration
The security.yaml defines firewalls, providers, and access control. The configuration follows the onion principle: requests pass through multiple security layers.
security:
password_hashers:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }
What CSRF Actually Is — and Why It Becomes Dangerous
Cross-Site Request Forgery exploits the fact that the browser automatically sends all cookies with every request. An attacker can place a hidden form on a third-party site that causes the authenticated user to unknowingly perform an action on the target site — such as making a transfer, changing their email address, or deleting data.
Symfony's CSRF protection works as follows:
- When rendering a form, Symfony generates a random token
- The token is embedded as a hidden field in the form
- On submit, Symfony checks whether the token is valid
- Without a valid token, the request is rejected
// In the controller: manual CSRF check
if (!$this->isCsrfTokenValid('delete-item', $request->get('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
In Twig forms, this happens automatically via {{ form_widget(form._token) }}.
The Problem: CSRF and Modern JavaScript SPAs
CSRF tokens become problematic when the application relies heavily on JavaScript-based requests. Typical problems:
1. Parallel AJAX Requests and Session Locking
When multiple JavaScript fetch requests fire simultaneously, each sends the same CSRF token. Symfony does not rotate CSRF tokens after each validated request — the token persists in the session. The actual problem with parallel requests is session locking: PHP's default session handler exclusively locks the session file, so parallel requests are serialized and must wait for each other.
// Problem: 3 requests simultaneously, all with the same token
Promise.all([
fetch('/api/cart/add', { method: 'POST', body: formData1 }),
fetch('/api/cart/add', { method: 'POST', body: formData2 }),
fetch('/api/wishlist/add', { method: 'POST', body: formData3 }),
]);
// Only the first request validates — the other two get HTTP 403
2. MySQL Row-Level Locking with Sessions
When the session is stored in the database (common with horizontal scaling), parallel requests lead to row-level locks:
Request 1: SELECT ... FROM sessions WHERE id = 'abc' FOR UPDATE → Lock acquired
Request 2: SELECT ... FROM sessions WHERE id = 'abc' FOR UPDATE → Waiting for lock
Request 3: SELECT ... FROM sessions WHERE id = 'abc' FOR UPDATE → Waiting for lock
This effectively serializes all parallel requests from a user and destroys the performance of an SPA.
3. Token Refresh with Long Sessions
For SPAs that stay open for hours, CSRF tokens expire. You need a token refresh endpoint that itself needs to be CSRF-protected — a chicken-and-egg problem.
Shopware's Decision: No CSRF
Shopware 6 deliberately decided against CSRF protection. The reasons are instructive:
1. Store API Architecture: Shopware 6 cleanly separates frontend (Storefront) and backend (Admin) via APIs. The Store API uses an sw-access-key and an sw-context-token for authentication. These tokens are not automatically sent by the browser — they must be explicitly set as headers in JavaScript code. This means the fundamental prerequisite for CSRF attacks is not present.
2. Performance in Headless Setups: Shopware is increasingly used headless (Vue Storefront, Next.js, etc.). In these scenarios, CSRF protection would be not only unnecessary but an active obstacle.
3. SameSite Cookies as an Alternative: Modern browsers support the SameSite cookie attribute. With SameSite=Lax (default since 2020), cookies are not sent with cross-origin requests — this eliminates most CSRF attack vectors at the browser level.
// Shopware relies on instead:
// 1. SameSite=Lax Cookies (Default since 2020)
// 2. sw-context-token Header (cannot be set by third-party sites)
// 3. Content-Type: application/json (prevents Simple Requests)
When CSRF Still Matters
Shopware's approach works because their entire architecture is designed for it. For classic Symfony applications with server-side rendered forms, CSRF protection remains essential:
- Forms without JavaScript: Login, contact forms, admin panels
- Older browsers: SameSite is not supported everywhere
- Cookie-based authentication: When the browser automatically sends auth cookies
Custom Authenticator for API Tokens
For API token authentication (similar to Shopware's approach), you create your own authenticator:
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class ApiTokenAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
return $request->headers->has('X-API-TOKEN');
}
public function authenticate(Request $request): Passport
{
$apiToken = $request->headers->get('X-API-TOKEN');
return new SelfValidatingPassport(
new UserBadge($apiToken)
);
}
public function onAuthenticationSuccess(
Request $request,
TokenInterface $token,
string $firewallName
): ?Response {
return null;
}
public function onAuthenticationFailure(
Request $request,
AuthenticationException $exception
): ?Response {
return new JsonResponse(
['error' => 'Authentication failed'],
Response::HTTP_UNAUTHORIZED
);
}
}
Voters for Fine-Grained Permissions
Voters enable permission checks at the object level — much more flexible than simple roles:
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, ['EDIT', 'DELETE'])
&& $subject instanceof Article;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token
): bool {
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Article $article */
$article = $subject;
return match ($attribute) {
'EDIT' => $article->getAuthor() === $user || $user->isAdmin(),
'DELETE' => $user->isAdmin(),
default => false,
};
}
}
Security Checklist
- Passwords — Always use
password_hashers: auto(Argon2id or bcrypt) - Rate Limiting — Limit login attempts with Symfony's
RateLimitercomponent - HTTPS — Enforce HTTPS in production (
access_controlwithrequires_channel: https) - Content Security Policy — Set CSP headers to prevent XSS attacks
- SameSite Cookies — Set
SameSite=StrictorLaxfor APIs - CSRF — Enable for server-side forms, critically evaluate for pure APIs
The Symfony Security component requires careful configuration but then provides comprehensive protection. The key is to choose the right strategy for your own architecture — Shopware's example shows that there is no one-size-fits-all solution.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.