# Symfony REST API Security: JWT Authentication and Interview Questions 2026 > Master Symfony REST API security with JWT authentication using LexikJWTAuthenticationBundle. Covers token generation, refresh tokens, security voters, and common interview questions. - Published: 2026-08-26 - Updated: 2026-08-26 - Author: Anthony Fillion-Maillet - Tags: symfony, jwt, api-security, authentication, rest-api - Reading time: 9 min --- Symfony REST API security requires a combination of authentication, authorization, and proper token management. [LexikJWTAuthenticationBundle](https://github.com/lexik/LexikJWTAuthenticationBundle) version 3.2, compatible with Symfony 7.2 and PHP 8.3, provides a robust foundation for JWT-based authentication in production APIs. > **JWT Authentication Flow** > > A client sends credentials to `/api/login_check`. The server validates them, generates a signed JWT with a private key, and returns it. Subsequent requests include this token in the Authorization header for stateless authentication. ## Installing LexikJWTAuthenticationBundle in Symfony 7.2 The bundle integrates with Symfony's security component and handles token generation, validation, and user loading from token payloads. ```bash # Install the bundle composer require lexik/jwt-authentication-bundle # Generate RSA keys for token signing php bin/console lexik:jwt:generate-keypair ``` The keypair command creates `config/jwt/private.pem` and `config/jwt/public.pem`. These keys sign and verify tokens. Store the passphrase in `JWT_PASSPHRASE` environment variable. ```yaml # config/packages/lexik_jwt_authentication.yaml lexik_jwt_authentication: secret_key: '%env(resolve:JWT_SECRET_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%' pass_phrase: '%env(JWT_PASSPHRASE)%' token_ttl: 3600 # 1 hour ``` The `token_ttl` setting controls token lifetime. Short-lived tokens reduce the window for token theft exploitation. ## Configuring Security Firewalls for API Authentication Symfony's [security component](https://symfony.com/doc/current/security.html) requires firewall configuration to protect API routes. The `json_login` authenticator handles credential validation, while `jwt` secures subsequent requests. ```yaml # config/packages/security.yaml security: enable_authenticator_manager: true providers: app_user_provider: entity: class: App\Entity\User property: email firewalls: login: pattern: ^/api/login stateless: true json_login: check_path: /api/login_check success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure api: pattern: ^/api stateless: true jwt: ~ access_control: - { path: ^/api/login, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } ``` The `stateless: true` setting prevents session creation. Each request authenticates independently via the JWT in the Authorization header. ## Creating a User Entity with Password Hashing The User entity implements `UserInterface` and `PasswordAuthenticatedUserInterface`. Symfony 7.2 uses these interfaces to integrate with the security system. ```php // src/Entity/User.php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity] #[ORM\Table(name: 'users')] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] private ?string $email = null; #[ORM\Column] private array $roles = []; #[ORM\Column] private ?string $password = null; public function getUserIdentifier(): string { return (string) $this->email; } public function getRoles(): array { $roles = $this->roles; $roles[] = 'ROLE_USER'; // Every user has ROLE_USER return array_unique($roles); } public function getPassword(): ?string { return $this->password; } public function eraseCredentials(): void { // Clear temporary sensitive data if stored } } ``` Password hashing uses Symfony's `password_hashers` configuration. The `auto` algorithm selects the strongest available hasher. ## Implementing Refresh Tokens for Long Sessions JWT tokens expire. Without refresh tokens, users must re-authenticate frequently. The [JWTRefreshTokenBundle](https://github.com/markitosgv/JWTRefreshTokenBundle) extends authentication with long-lived refresh tokens stored in the database. ```bash # Install refresh token bundle composer require gesdinet/jwt-refresh-token-bundle ``` ```yaml # config/packages/gesdinet_jwt_refresh_token.yaml gesdinet_jwt_refresh_token: refresh_token_lifetime: 2592000 # 30 days user_identity_field: email ttl_update: true # Extend TTL on each use ``` The refresh endpoint exchanges a valid refresh token for a new access token without requiring credentials. ```yaml # config/packages/security.yaml (add to firewalls) refresh: pattern: ^/api/token/refresh stateless: true ``` ```yaml # config/routes.yaml api_refresh_token: path: /api/token/refresh controller: gesdinet.jwtrefreshtoken::refresh ``` Client applications store the refresh token securely and use it when the access token expires. ## Security Voters for Fine-Grained Authorization Firewall rules handle authentication. [Voters](https://symfony.com/doc/current/security/voters.html) handle authorization, determining whether an authenticated user can perform specific actions on specific resources. ```php // src/Security/Voter/ArticleVoter.php 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 { public const EDIT = 'ARTICLE_EDIT'; public const DELETE = 'ARTICLE_DELETE'; protected function supports(string $attribute, mixed $subject): bool { return in_array($attribute, [self::EDIT, self::DELETE]) && $subject instanceof Article; } protected function voteOnAttribute( string $attribute, mixed $subject, TokenInterface $token ): bool { $user = $token->getUser(); if (!$user instanceof User) { return false; // Not logged in } /** @var Article $article */ $article = $subject; return match ($attribute) { self::EDIT => $this->canEdit($article, $user), self::DELETE => $this->canDelete($article, $user), default => false, }; } private function canEdit(Article $article, User $user): bool { // Authors can edit their own articles return $article->getAuthor() === $user; } private function canDelete(Article $article, User $user): bool { // Only admins can delete return in_array('ROLE_ADMIN', $user->getRoles(), true); } } ``` Controllers use `$this->isGranted()` or the `#[IsGranted]` attribute to enforce voter decisions. ```php // src/Controller/ArticleController.php #[Route('/api/articles/{id}', methods: ['PUT'])] public function update(Article $article, Request $request): JsonResponse { $this->denyAccessUnlessGranted(ArticleVoter::EDIT, $article); // Update logic here return $this->json($article); } ``` ## Rate Limiting Login Attempts Brute force attacks target login endpoints. Symfony's [rate limiter component](https://symfony.com/doc/current/rate_limiter.html) restricts the number of attempts per IP or username. ```yaml # config/packages/rate_limiter.yaml framework: rate_limiter: login_limiter: policy: 'sliding_window' limit: 5 interval: '1 minute' ``` ```php // src/EventListener/LoginAttemptListener.php namespace App\EventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\Security\Http\Event\CheckPassportEvent; #[AsEventListener] class LoginAttemptListener { public function __construct( private RateLimiterFactory $loginLimiter ) {} public function __invoke(CheckPassportEvent $event): void { $request = $event->getRequest(); $limiter = $this->loginLimiter->create($request->getClientIp()); if (!$limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException( 60, 'Too many login attempts. Try again in 1 minute.' ); } } } ``` The listener triggers before password validation, preventing timing attacks that reveal valid usernames. ## Customizing JWT Payload with Event Listeners The default JWT payload contains minimal user data. Custom claims add roles, permissions, or application-specific data without additional database queries. ```php // src/EventListener/JWTCreatedListener.php namespace App\EventListener; use App\Entity\User; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener(event: 'lexik_jwt_authentication.on_jwt_created')] class JWTCreatedListener { public function __invoke(JWTCreatedEvent $event): void { $user = $event->getUser(); if (!$user instanceof User) { return; } $payload = $event->getData(); $payload['userId'] = $user->getId(); $payload['roles'] = $user->getRoles(); $payload['permissions'] = $user->getPermissions(); // Custom method $event->setData($payload); } } ``` Avoid storing sensitive data in the payload. JWTs are signed, not encrypted, so clients can decode and read the payload. ## Common Interview Questions on Symfony API Security Technical interviews for Symfony positions frequently cover API security. These questions assess understanding of authentication flows, token management, and security best practices. **What is the difference between authentication and authorization?** Authentication verifies identity: "Who is this user?" The JWT login flow handles authentication. Authorization determines permissions: "Can this user perform this action?" [Voters](/technologies/symfony/interview-questions/events-subscribers) and access control rules handle authorization. **Why use JWTs instead of session-based authentication for APIs?** JWTs enable stateless authentication. The server validates the token signature without storing session data. This simplifies horizontal scaling, as any server instance can validate tokens independently. Mobile and SPA clients benefit from token-based flows that work across domains. **How do you handle JWT token expiration?** Short-lived access tokens (15 minutes to 1 hour) limit exposure if stolen. Refresh tokens, stored securely by the client, obtain new access tokens without re-authentication. The refresh endpoint validates the refresh token against the database and issues a new access/refresh pair. **What security risks exist with JWTs?** Token theft through XSS attacks allows attackers to impersonate users until expiration. Weak signing algorithms (like `none` or `HS256` with short secrets) enable token forgery. Storing tokens in localStorage exposes them to XSS. HttpOnly cookies provide better protection for web applications. **How do voters differ from access control rules?** Access control rules in `security.yaml` apply to URL patterns with role checks. Voters evaluate object-level permissions dynamically. A rule might grant all admins access to `/api/admin/*`, while a voter determines if a specific admin can edit a specific resource based on ownership or other business logic. ## Testing JWT Authentication in Symfony Functional tests verify that authentication and authorization work correctly. Symfony's test client supports token injection for authenticated requests. ```php // tests/Controller/ArticleControllerTest.php namespace App\Tests\Controller; use App\Entity\User; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class ArticleControllerTest extends WebTestCase { public function testAuthenticatedUserCanCreateArticle(): void { $client = static::createClient(); $container = static::getContainer(); // Get a test user from the database $user = $container->get('doctrine') ->getRepository(User::class) ->findOneBy(['email' => 'test@example.com']); // Generate JWT for this user $jwtManager = $container->get(JWTTokenManagerInterface::class); $token = $jwtManager->create($user); // Make authenticated request $client->request( 'POST', '/api/articles', [], [], [ 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, 'CONTENT_TYPE' => 'application/json', ], json_encode(['title' => 'Test Article', 'content' => 'Content']) ); $this->assertResponseStatusCodeSame(201); } public function testUnauthenticatedUserCannotAccessApi(): void { $client = static::createClient(); $client->request('GET', '/api/articles'); $this->assertResponseStatusCodeSame(401); } } ``` The test generates a real JWT using the bundle's `JWTTokenManagerInterface`, ensuring the full authentication flow is exercised. ## Production Security Checklist for Symfony APIs Deploying a secure API requires attention to configuration, key management, and monitoring. - Store RSA keys outside the web root with restricted file permissions (600) - Use environment variables for sensitive configuration, never commit secrets - Enable HTTPS only, redirect HTTP requests - Set appropriate CORS headers for cross-origin clients - Log authentication failures for security monitoring - Rotate signing keys periodically, supporting multiple valid keys during transition - Validate all input data with Symfony's [serializer and validator](/technologies/symfony/interview-questions/serializer) - Use short token lifetimes and implement refresh token rotation ## Key Takeaways for Symfony REST API Security - LexikJWTAuthenticationBundle 3.2 provides production-ready JWT authentication for Symfony 7.2 with RSA key signing - Configure stateless firewalls with `json_login` for credentials and `jwt` for protected routes - Implement refresh tokens with JWTRefreshTokenBundle to maintain long sessions without storing credentials client-side - Use voters for object-level authorization decisions that access control rules cannot express - Rate limit login endpoints to prevent brute force attacks, triggering before password validation - Add custom claims to JWT payloads through event listeners, avoiding sensitive data in the readable payload - Test authentication flows with Symfony's test client and injected JWT tokens - Follow the production checklist: key rotation, HTTPS only, input validation, and security logging --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/symfony/symfony-rest-api-security-jwt-authentication