in Symfony Compared
JWT or sessions for Symfony REST APIs? The answer depends on scaling requirements, revocation needs, client type, and security model. Anyone who understands the difference between stateless and stateful authentication can make the right decision for their specific use case.
Table of Contents
- 1. The Fundamental Question: Stateless or Stateful?
- 2. JWT: Structure, Signing, and Validation
- 3. Session Authentication: How Symfony Manages Sessions
- 4. Implementation in Symfony: JWT vs. Session in Code
- 5. Security Risks: XSS, CSRF, Token Theft, and Revocation
- 6. Scaling and Infrastructure: When JWT Really Helps
- 7. Refresh Tokens and Token Rotation in Symfony
- 8. JWT vs. Session: Direct Comparison
- 9. Summary: Which Method for Which Case?
- 10. FAQ
1. The Fundamental Question: Stateless or Stateful?
At the core of the debate between JWT and sessions lies the question of stateless vs. stateful authentication. With session-based authentication, the server stores the authentication state in a session store (file, Redis, database). The client gets a session ID as a cookie, and the server looks up the current state on every request. This means the server fully controls the state. A session can be invalidated at any time without the client needing to be informed. The price is a server-side database access (or Redis lookup) on every authenticated request.
With JWT-based authentication, the token itself is the authentication document. It contains all necessary information (user ID, roles, expiry time) and is cryptographically signed. The server does not need to query an external store during validation, it only checks the signature and the claims. This makes JWT authentication stateless: any API instance can validate a token independently, without coordinating with other instances. The price is that a token cannot easily be revoked before it expires, which is the fundamental problem of all stateless token systems. The choice between JWT and sessions is therefore primarily a choice between scalability and controllability.
2. JWT: Structure, Signing, and Validation
A JSON Web Token (JWT) consists of three Base64-URL-encoded parts separated by dots: header, payload, and signature. The header contains the token type (JWT) and the signature algorithm (RS256 for RSA with SHA-256, or HS256 for HMAC with SHA-256). The payload contains standardized claims (sub for subject/user ID, exp for expiry time, iat for issued-at time, iss for issuer) and custom claims (e.g. roles, tenant ID). The signature is generated from the header and payload using the secret key and prevents tampering.
For production Symfony APIs, RS256 (asymmetric) is recommended over HS256 (symmetric). With RS256, the authentication server holds the private key for signing, while all API instances only know the public key for validation. This enables a real separation between token issuance and token validation, and prevents a compromised API instance from issuing new tokens. HS256 with a shared secret is simpler to set up, but risky in distributed systems: any instance that can validate can also issue. The expiry time (exp) should be chosen short, 15 minutes to 1 hour for access tokens is a good starting point.
3. Session Authentication: How Symfony Manages Sessions
Symfony's session system is based on PHP sessions with a configurable handler. By default, sessions are stored as files, in production environments a Redis or database handler is recommended. The session mechanism for REST APIs works with a session cookie that the browser automatically sends with every request. This means session authentication is primarily suited for browser-based clients, not for mobile apps or server-side API clients that would have to implement their own cookie management.
The advantage of sessions is complete server-side control: a user session can be invalidated immediately, through logout, after a suspicious action, or by an administrator. All active sessions of a user can be listed and managed. In Symfony this is done with $tokenStorage->setToken(null) or by directly deleting the session entry in the session handler. With JWT, invalidation without a blocklist mechanism is only possible once the token naturally expires. For applications where fast revocation is a security requirement (financial applications, government applications), this is a decisive drawback of JWT without additional complexity.
<?php
// Symfony 7: JWT authentication with LexikJWTAuthenticationBundle
// security.yaml (excerpt)
// JWT configuration
// 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
// Login controller: issue a JWT
declare(strict_types=1);
namespace App\Controller\Api;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use App\Repository\UserRepository;
/**
* Issues JWT tokens on successful login.
*/
#[Route('/api/auth/login', name: 'api_auth_login', methods: ['POST'])]
final class LoginController extends AbstractController
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly UserPasswordHasherInterface $hasher,
private readonly JWTTokenManagerInterface $jwtManager,
) {}
public function __invoke(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
$user = $this->userRepository->findOneByEmail($email);
if ($user === null || !$this->hasher->isPasswordValid($user, $password)) {
throw new BadCredentialsException('Invalid credentials.');
}
$token = $this->jwtManager->create($user);
return $this->json([
'token' => $token,
'expiresIn' => 3600,
'tokenType' => 'Bearer',
]);
}
}
4. Implementation in Symfony: JWT vs. Session in Code
In Symfony, JWT authentication is most easily configured with the LexikJWTAuthenticationBundle. The bundle handles token issuance, validation, and integration into the Symfony Security system. The firewall in security.yaml is set to stateless: true, meaning Symfony does not start a session for API requests. Every request is validated independently by the JWT authenticator. The result: no session overhead, no session cookie requirements, and the API can be scaled horizontally without configuring session sharing.
Session-based authentication in Symfony for REST APIs uses the same security stack, but with stateless: false and a cookie-based authenticator. For mobile and API clients that have no cookie management, you additionally need an X-AUTH-TOKEN header mechanism, or you accept that sessions are not suited for these clients. The practical difference in code: JWT APIs return a token string at POST /login, which the client sends in the Authorization header. Session APIs set a cookie that the browser sends automatically. For SPAs (Single Page Applications), JWT with localStorage or sessionStorage is common, with the risk of being vulnerable to XSS. For traditional browser apps, the session cookie with httpOnly: true is safer.
5. Security Risks: XSS, CSRF, Token Theft, and Revocation
The most important security difference between JWT and sessions lies in the attack vector. Session cookies with httpOnly: true are protected from JavaScript access, an XSS attack cannot read the cookie. The attack on sessions is CSRF (Cross-Site Request Forgery): a malicious tab can trigger a request carrying the victim's cookie. Symfony protects against CSRF with a synchronizer token pattern, which is enabled by default and well proven. JWT in browser storage, on the other hand, is vulnerable to XSS: any XSS script can read the token from localStorage and use it until it expires. This is a fundamental difference: session cookies are vulnerable to CSRF, JWT in storage is vulnerable to XSS. In both cases there are countermeasures, but neither approach is inherently more secure.
The biggest operational security problem with JWT is token revocation. When a JWT token is stolen or a user needs to be logged out immediately (e.g. after account takeover), the token cannot be invalidated right away, it only expires at exp. The solution is a blocklist (deny list) in Redis or the database that stores invalidated token IDs (jti claim) until their natural expiry. This reintroduces a server-side lookup on every request, partially giving up the stateless advantage of JWT. Short token lifetimes (15 minutes) combined with refresh token rotation minimize the risk without requiring a full blocklist.
<?php
// JWT blocklist for immediate token revocation in Symfony
declare(strict_types=1);
namespace App\Service;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Token blocklist using Symfony Cache (Redis or APCu).
* Stores revoked JWT IDs (jti) until their natural expiry.
*/
final readonly class JwtBlocklist
{
public function __construct(
private CacheInterface $cache,
) {}
/**
* Add a JWT ID to the blocklist until its expiry timestamp.
*/
public function revoke(string $jti, int $expiresAt): void
{
$ttl = max(0, $expiresAt - time());
$this->cache->get('jwt_blocklist_' . $jti, function (ItemInterface $item) use ($ttl): bool {
$item->expiresAfter($ttl);
return true; // Value is irrelevant, presence means revoked
});
}
/**
* Check if a JWT ID is in the blocklist (i.e., revoked).
*/
public function isRevoked(string $jti): bool
{
return $this->cache->hasItem('jwt_blocklist_' . $jti);
}
}
// Usage in JWT validation event listener:
// if ($blocklist->isRevoked($payload['jti'])) {
// throw new InvalidTokenException('Token has been revoked.');
// }
6. Scaling and Infrastructure: When JWT Really Helps
The scaling advantage of JWT is real, but often overestimated. JWT authentication helps when the API runs on multiple independent instances that do not share a session store. Any instance can validate a JWT token without communicating with other instances, an RSA public key loaded at startup is enough for all validations. This reduces latency (no Redis round trip per request) and makes the API horizontally scalable without configuring session affinity (sticky sessions).
Session authentication requires a shared session store when multiple instances are running. In practice this is not an insurmountable problem: Redis as a session handler is well proven and typically adds less than 1ms of latency. For small to mid-sized applications behind a load balancer, session authentication with a Redis session store is entirely sufficient. JWT makes more sense for microservices than for monolithic APIs: if service A issues a token and services B and C need to validate it without contacting service A, JWT is the elegant solution. If everything runs in a single Symfony monolith, the scaling advantage of JWT is minimal.
7. Refresh Tokens and Token Rotation in Symfony
Short access token lifetimes (15 to 60 minutes) largely solve the revocation problem: even if a token is stolen, it becomes worthless within a short time. The price: the client must re-authenticate every 15 to 60 minutes, which is unacceptable for users. The solution is the refresh token pattern: a short-lived access token (15 minutes) and a long-lived refresh token (7 to 30 days) are issued during the first authentication. The access token is used for API requests. When it expires, the client exchanges the refresh token for a new access token and a new refresh token (token rotation).
In Symfony this is implemented with the gesdinet/jwt-refresh-token-bundle or a custom implementation. Refresh tokens are stored in the database and can be revoked individually. Token rotation means: on every refresh, the old refresh token becomes invalid and a new one is issued. If a refresh token is used twice (which only happens if it was stolen and the attacker refreshed first), both tokens, the one just issued and the one used in parallel, should be revoked immediately. This is the refresh token reuse detection pattern, and an essential security feature of any production-ready JWT implementation.
8. JWT vs. Session: Direct Comparison
The decision depends on concrete requirements, not on hype or convention. Here is a direct comparison of the most relevant properties:
| Property | JWT (Stateless) | Session (Stateful) |
|---|---|---|
| Server state | No state needed (without blocklist) | Session store required (Redis) |
| Horizontal scaling | Simple, no shared store | Shared Redis store needed |
| Immediate revocation | Only with blocklist (Redis lookup) | Immediate, no extra effort |
| CSRF risk | None (no cookie required) | Yes, CSRF protection needed |
| XSS risk for token | Yes, if stored in localStorage | No, httpOnly cookie |
| Microservices | Ideal, no central auth server needed | Complex, session sharing between services |
| Client type | Mobile, API clients, SPA | Browser apps with httpOnly cookie |
An important point missing from many comparisons: for Symfony applications that serve both a web app and a REST API, a hybrid solution is often the most pragmatic choice. The web app uses sessions with httpOnly cookies, the REST API endpoints for external clients use JWT. Symfony supports multiple security firewalls that can configure different authentication mechanisms for different paths. This avoids being forced to choose between the two approaches when different client types need different security profiles.
9. Summary: Which Method for Which Case?
JWT is the right choice for mobile apps and server-side API clients (no cookie management), microservice architectures with multiple services validating the same token, horizontally scaled APIs without a central session store, and APIs where short token lifetimes and refresh token rotation sufficiently mitigate the revocation problem. The LexikJWTAuthenticationBundle makes Symfony integration straightforward. The critical implementation details: RS256 instead of HS256, short access token lifetimes (15 to 60 minutes), refresh token rotation, and reuse detection as a safety net.
Session authentication is the right choice for browser-based applications with an httpOnly cookie and CSRF protection, applications with immediate revocation needs (financial and security applications), Symfony monoliths without microservice distribution, and teams that prefer the simpler setup. Redis as a session handler is production-ready and well proven. The latency of a Redis lookup per request (under 1ms) is not a measurable drawback for most applications. The starting point for the decision: client type and revocation requirements matter more than theoretical scalability, which never becomes relevant in many projects.
JWT vs. Session in Symfony, the key points at a glance
JWT: when to use
Mobile apps, microservices, horizontal scaling. RS256 signature, short lifetime, refresh token rotation. LexikJWTAuthenticationBundle for Symfony.
Session: when to use
Browser apps with httpOnly cookie, immediate revocation needs, Symfony monoliths. Redis as session handler. CSRF protection active by default in Symfony.
JWT security
RS256 instead of HS256. Never store tokens in localStorage without XSS protection. Blocklist for immediate revocation. Reuse detection for refresh token rotation.
Hybrid solution
Symfony supports multiple security firewalls. Web app with sessions, REST API with JWT. No need to force one approach on all clients.