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.

Symfony REST API Security with JWT Authentication

Symfony REST API security requires a combination of authentication, authorization, and proper token management. 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 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.

src/Entity/User.phpphp
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 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.

Ready to ace your Symfony interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Security Voters for Fine-Grained Authorization

Firewall rules handle authentication. Voters handle authorization, determining whether an authenticated user can perform specific actions on specific resources.

src/Security/Voter/ArticleVoter.phpphp
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.

src/Controller/ArticleController.phpphp
#[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 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'
src/EventListener/LoginAttemptListener.phpphp
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.

src/EventListener/JWTCreatedListener.phpphp
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 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.

tests/Controller/ArticleControllerTest.phpphp
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
  • Use short token lifetimes and implement refresh token rotation

Start practicing!

Test your knowledge with our interview simulators and technical tests.

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
Daily challenge

Can you spot the bug in Symfony?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on August 26, 2026

Tags

#symfony
#jwt
#api-security
#authentication
#rest-api

Share

Related articles