# Seguridad en Symfony en 2026: Voters, Firewalls y Preguntas de Entrevista Técnica > Análisis profundo de la seguridad en Symfony que cubre voters, firewalls, access tokens y autenticación, con las preguntas de entrevista técnica más frecuentes para desarrolladores Symfony. - Published: 2026-06-18 - Updated: 2026-06-18 - Author: SharpSkill - Tags: symfony, security, php, voters, firewalls, authentication, interview - Reading time: 10 min --- El sistema de seguridad de Symfony se sustenta en dos mecanismos centrales: los **firewalls** gestionan la autenticación (¿quién eres?) y los **voters** gestionan la autorización (¿qué puedes hacer?). Comprender cómo interactúan estos componentes resulta esencial para construir aplicaciones Symfony seguras y para responder con solidez a las preguntas de entrevistas técnicas. > **Stack de seguridad de Symfony 7.4 LTS** > > Symfony 7.4 (la versión LTS actual, con soporte hasta noviembre de 2029) introdujo explicaciones en las decisiones de voters, nuevas funciones Twig de autorización (`access_decision()` y `access_decision_for_user()`) y la firma de mensajes para handlers de Messenger. Todos los ejemplos en este artículo apuntan a Symfony 7.4+. ## Cómo los Firewalls de Symfony controlan la autenticación Un firewall en Symfony no es un firewall de red. Es el punto de entrada del sistema de autenticación, definido en `config/packages/security.yaml`. Cada firewall declara qué parte de la aplicación protege y qué mecanismo de autenticación utiliza. El orden de los firewalls importa. Symfony los evalúa de arriba hacia abajo y enruta cada solicitud al **primer** firewall cuyo `pattern` coincida. Una configuración típica separa los endpoints de API de las rutas web: ```yaml # config/packages/security.yaml security: firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false api: pattern: ^/api stateless: true access_token: token_handler: App\Security\ApiTokenHandler main: lazy: true form_login: login_path: app_login check_path: app_login enable_csrf: true logout: path: app_logout remember_me: secret: '%kernel.secret%' login_throttling: max_attempts: 5 interval: '15 minutes' ``` El firewall `dev` desactiva la seguridad por completo para las rutas del profiler y los assets. El firewall `api` usa `stateless: true` porque los clientes API envían un access token en cada solicitud en lugar de depender de sesiones. El firewall `main` maneja la autenticación basada en navegador con formulario de inicio de sesión, protección CSRF, cookies remember-me y [limitación de intentos de inicio de sesión](https://symfony.com/doc/current/security.html#limiting-login-attempts). ## Firewalls Stateless vs Stateful y cuándo usar cada uno Los firewalls stateful (el comportamiento por defecto) almacenan al usuario autenticado en la sesión. Esto es adecuado para aplicaciones web tradicionales donde el navegador envía cookies en cada solicitud. La opción `lazy: true` retrasa la carga del usuario desde la sesión hasta que la autorización realmente lo requiera, mejorando el rendimiento en páginas públicas. Los firewalls stateless (`stateless: true`) nunca leen ni escriben sesiones. Cada solicitud debe llevar sus propias credenciales, típicamente un JWT o un token API en el header `Authorization`. Este es el enfoque estándar para APIs REST, backends móviles y comunicación entre microservicios. Combinar ambos en la misma aplicación es habitual. La configuración anterior demuestra este patrón: los consumidores de API se autentican mediante tokens, mientras que los usuarios humanos se autentican mediante formulario de inicio de sesión con persistencia en sesión. ## Construir un Access Token Handler personalizado Symfony 7.4 proporciona un sistema integrado de autenticación por access token. Para tokens API almacenados en base de datos, basta con implementar `AccessTokenHandlerInterface`: ```php // src/Security/ApiTokenHandler.php namespace App\Security; use App\Repository\ApiTokenRepository; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; class ApiTokenHandler implements AccessTokenHandlerInterface { public function __construct( private readonly ApiTokenRepository $repository, ) {} public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge { $token = $this->repository->findOneByValue($accessToken); if ($token === null || $token->isExpired()) { throw new BadCredentialsException('Invalid or expired API token.'); } return new UserBadge($token->getUser()->getUserIdentifier()); } } ``` El atributo `#[\SensitiveParameter]` impide que el valor del token aparezca en stack traces y dumps de depuración. El handler recibe el token sin procesar desde el header `Authorization: Bearer `, lo busca en la base de datos y devuelve un `UserBadge` que resuelve al usuario asociado. ## Voters: autorización granular basada en lógica de negocio Los roles (`ROLE_ADMIN`, `ROLE_EDITOR`) funcionan para permisos estáticos. Para autorización dinámica dependiente del contexto — "¿puede este usuario editar **este** post en particular?" — los voters son la solución. Un voter es una clase PHP que implementa `VoterInterface` y decide otorgar, denegar o abstenerse en una verificación de permiso dada: ```php // src/Security/Voter/PostVoter.php namespace App\Security\Voter; use App\Entity\Post; use App\Entity\User; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; class PostVoter extends Voter { public const EDIT = 'POST_EDIT'; public const DELETE = 'POST_DELETE'; protected function supports(string $attribute, mixed $subject): bool { return in_array($attribute, [self::EDIT, self::DELETE]) && $subject instanceof Post; } protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { $user = $token->getUser(); if (!$user instanceof User) { return false; } /** @var Post $post */ $post = $subject; return match ($attribute) { self::EDIT => $this->canEdit($post, $user), self::DELETE => $this->canDelete($post, $user), default => false, }; } private function canEdit(Post $post, User $user): bool { return $post->getAuthor() === $user; } private function canDelete(Post $post, User $user): bool { return $post->getAuthor() === $user || in_array('ROLE_ADMIN', $user->getRoles()); } } ``` Gracias a la autoconfiguración de Symfony, esta clase se detecta y registra automáticamente como voter. No se necesita configuración adicional. ## Uso del atributo #[IsGranted] en controladores El atributo `#[IsGranted]` permite declarar los requisitos de autorización directamente en los métodos del controlador, eliminando las llamadas manuales a `$this->denyAccessUnlessGranted()`: ```php // src/Controller/PostController.php namespace App\Controller; use App\Entity\Post; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; class PostController extends AbstractController { #[Route('/post/{id}/edit', name: 'post_edit')] #[IsGranted(PostVoter::EDIT, subject: 'post')] public function edit(Post $post): Response { // La autorización ya está verificada — el código del controlador queda limpio return $this->render('post/edit.html.twig', [ 'post' => $post, ]); } #[Route('/post/{id}', name: 'post_delete', methods: ['DELETE'])] #[IsGranted(PostVoter::DELETE, subject: 'post')] public function delete(Post $post): Response { // Lógica de eliminación return $this->redirectToRoute('post_list'); } } ``` El parámetro `subject` corresponde al nombre del argumento del controlador. Symfony resuelve automáticamente la entidad `Post` desde el parámetro de ruta `{id}` y la pasa al voter. Si el voter deniega el acceso, se lanza una `AccessDeniedException` antes de que el cuerpo del método se ejecute. ## Decisiones de Voters con explicaciones (Symfony 7.3+) Desde Symfony 7.3, los voters pueden proporcionar explicaciones legibles para sus decisiones a través del parámetro `?Vote $vote`: ```php // src/Security/Voter/DocumentVoter.php namespace App\Security\Voter; use App\Entity\Document; use App\Entity\User; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; class DocumentVoter extends Voter { public const VIEW = 'DOCUMENT_VIEW'; protected function supports(string $attribute, mixed $subject): bool { return $attribute === self::VIEW && $subject instanceof Document; } protected function voteOnAttribute( string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null, ): bool { $user = $token->getUser(); if (!$user instanceof User) { $vote?->addReason('User is not authenticated.'); return false; } /** @var Document $document */ $document = $subject; if ($document->isPublic()) { $vote?->addReason('Document is public — access granted to all authenticated users.'); return true; } if ($document->getOwner() === $user) { $vote?->addReason('User is the document owner.'); return true; } $vote?->addReason('User is neither the owner nor the document public.'); return false; } } ``` Estas razones aparecen en el Symfony Profiler bajo el panel de Security, mostrando exactamente por qué se tomó cada decisión. Esto elimina las conjeturas al depurar plantillas que muestran condicionalmente botones de edición o eliminación. ## Preguntas de entrevista técnica frecuentes sobre seguridad en Symfony Estas preguntas aparecen con regularidad en entrevistas para puestos de desarrollo Symfony, abarcando desde conceptos fundamentales hasta decisiones de arquitectura. ### ¿Cuál es la diferencia entre autenticación y autorización en Symfony? La autenticación verifica la identidad (gestionada por firewalls y authenticators). La autorización verifica los permisos (gestionada por voters, roles y reglas de access control). Un usuario puede estar autenticado pero no autorizado para realizar una acción específica. Symfony impone esta separación a nivel arquitectónico: el firewall establece *quién* es el usuario, y la capa de autorización determina *qué* puede hacer. ### ¿Cuándo se debe usar un voter en lugar de una verificación de rol? Las verificaciones de roles (`ROLE_ADMIN`, `ROLE_EDITOR`) funcionan para permisos estáticos a nivel de usuario. Los voters manejan la autorización dinámica y dependiente del contexto: "¿Puede este usuario editar *este post en particular*?" La respuesta depende del autor del post, del estado de publicación o de la pertenencia a un equipo, información que un rol no codifica. La regla general: si la decisión requiere inspeccionar el sujeto, se debe usar un voter. ### ¿Cómo mejora el rendimiento `CacheableVoterInterface`? Cada llamada a `isGranted()` invoca a todos los voters registrados. En una aplicación con 40 voters llamados 500 veces por solicitud, esto genera una sobrecarga significativa. `CacheableVoterInterface` agrega un método `supportsAttribute()` que permite a Symfony omitir los voters que no pueden manejar el atributo dado *sin* llamar a `supports()` en cada voter. [Los benchmarks muestran una mejora del 40% en el rendimiento](https://symfony.com/blog/new-in-symfony-5-4-faster-security-voters) del procesamiento de autorizaciones. ### ¿Cómo se prueban los voters de forma aislada? Los voters son clases PHP estándar sin dependencias del framework más allá de las interfaces. Probarlos requiere crear un mock de `TokenInterface` con un usuario y llamar a `vote()` directamente: ```php // tests/Security/Voter/PostVoterTest.php namespace App\Tests\Security\Voter; use App\Entity\Post; use App\Entity\User; use App\Security\Voter\PostVoter; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; class PostVoterTest extends TestCase { private PostVoter $voter; protected function setUp(): void { $this->voter = new PostVoter(); } public function testAuthorCanEdit(): void { $user = new User(); $post = (new Post())->setAuthor($user); $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); $result = $this->voter->vote($token, $post, [PostVoter::EDIT]); $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); } public function testNonAuthorCannotEdit(): void { $author = new User(); $otherUser = new User(); $post = (new Post())->setAuthor($author); $token = new UsernamePasswordToken($otherUser, 'main', $otherUser->getRoles()); $result = $this->voter->vote($token, $post, [PostVoter::EDIT]); $this->assertSame(VoterInterface::ACCESS_DENIED, $result); } } ``` Sin arranque del kernel, sin base de datos, sin solicitud HTTP. Los voters son uno de los componentes más testeables del stack de seguridad de Symfony. ### ¿Qué sucede cuando se establece `security: false` en un firewall? El firewall se desactiva por completo para las rutas coincidentes. No se realiza autenticación, no se carga ningún usuario desde la sesión y no se despachan eventos de seguridad. Esto es apropiado para assets estáticos y herramientas de desarrollo (profiler, web debug toolbar) pero peligroso si se aplica a rutas que sirven datos de usuario. En la [documentación de seguridad de Symfony](https://symfony.com/doc/current/security.html), el firewall `dev` utiliza este patrón exclusivamente para rutas no sensibles. > **No usar security: false en rutas API** > > Establecer `security: false` en un firewall que coincide con rutas API elimina toda autenticación. A diferencia de una regla de access control que devuelve 401/403, un firewall desactivado no proporciona ningún contexto de usuario. El registro, la auditoría y la limitación de tasa basados en el usuario autenticado dejan de funcionar por completo. ## Reforzar la seguridad de Symfony más allá de la configuración por defecto La configuración de seguridad por defecto de Symfony es razonable pero no está endurecida para producción. Tres áreas merecen atención: **La limitación de intentos de inicio de sesión** restringe los fallos por combinación IP+nombre de usuario. La opción `login_throttling` en la configuración del firewall (mostrada anteriormente) limita los intentos a 5 por ventana de 15 minutos. Sin esto, los ataques de fuerza bruta no encuentran resistencia. **La protección CSRF** debe estar siempre activada en el formulario de inicio de sesión (`enable_csrf: true`). Symfony 7.4 también introdujo un [SameOriginCsrfTokenManager](https://symfony.com/blog/hardening-symfony-recent-security-improvements) que aprovecha el header del navegador `Sec-Fetch-Site` para verificación adicional sin requerir gestión de tokens en configuraciones con reverse proxy. **Los UserCheckers personalizados** se ejecutan después de que la autenticación tiene éxito pero antes de que el usuario obtenga acceso. Validan condiciones como la suspensión de cuenta, la verificación de email o el estado de suscripción: ```php // src/Security/UserEnabledChecker.php namespace App\Security; use App\Entity\User; use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException; use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; class UserEnabledChecker implements UserCheckerInterface { public function checkPreAuth(UserInterface $user): void { if (!$user instanceof User) { return; } if ($user->isBanned()) { throw new CustomUserMessageAccountStatusException( 'This account has been suspended.' ); } } public function checkPostAuth(UserInterface $user): void { if (!$user instanceof User) { return; } if (!$user->isEmailVerified()) { throw new CustomUserMessageAccountStatusException( 'Please verify your email address before logging in.' ); } } } ``` Se registra en la configuración del firewall con `user_checker: App\Security\UserEnabledChecker`. Symfony detecta automáticamente las clases que implementan `UserCheckerInterface`, pero el enlace con el firewall debe ser explícito. > **Eventos de seguridad para auditoría** > > Symfony despacha `LoginSuccessEvent`, `LoginFailureEvent` y `LogoutEvent` durante el ciclo de vida de la autenticación. Suscribirse a estos eventos permite construir registros de auditoría, disparar alertas ante patrones de inicio de sesión sospechosos o sincronizar datos de sesión con sistemas de monitoreo externos. ## Conclusión - Los firewalls definen las fronteras de autenticación. Se deben separar los firewalls API stateless de los firewalls web stateful, y siempre establecer `lazy: true` en los firewalls basados en sesión para diferir la carga del usuario - Los voters encapsulan las reglas de autorización de negocio. Deben usarse para cualquier verificación de permiso que dependa del sujeto (la entidad accedida), no solo del rol del usuario - El parámetro `?Vote $vote` (Symfony 7.3+) agrega explicaciones a las decisiones de los voters, haciendo transparente la depuración de autorizaciones en el profiler y los logs - El atributo `#[IsGranted]` mantiene los controladores limpios al declarar los requisitos de autorización de forma declarativa, con resolución automática del sujeto desde los argumentos del controlador - Las estrategias de decisión de acceso (`affirmative`, `consensus`, `unanimous`, `priority`) controlan cómo se resuelven las respuestas conflictivas de los voters. `affirmative` por defecto es adecuado para la mayoría de los casos; `unanimous` se recomienda para rutas de alta seguridad - El endurecimiento para producción requiere limitación de intentos de inicio de sesión, protección CSRF y UserCheckers personalizados para validación de cuentas específica del negocio - Los voters son completamente testeables unitariamente sin arrancar el kernel de Symfony, lo que los convierte en uno de los componentes más limpios de la arquitectura de seguridad --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/es/blog/symfony/symfony-security-voters-firewalls-interview-questions