JWT Authentication
This guide explains how JWT authentication works and how to configure it in PCH-SIG.
How it works
Authentication flow
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │ │ Backend │ │ PostgreSQL │
│ (Browser) │ │ (Symfony) │ │ │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ POST /api/login │ │
│ {email, password} │ │
│──────────────────▶│ │
│ │ Verify user │
│ │──────────────────▶│
│ │◀──────────────────│
│ │ │
│ {token, refresh} │ │
│◀──────────────────│ │
│ │ │
│ GET /api/menages │ │
│ Authorization: │ │
│ Bearer <token> │ │
│──────────────────▶│ │
│ │ Verify token │
│ │ (public key) │
│ {data} │ │
│◀──────────────────│ │
Tokens
| Type | Duration | Usage |
|---|---|---|
| Access Token | 1 hour | Authenticate API requests |
| Refresh Token | 7 days | Renew the access token |
Configuration
Environment variables
File: backend/.env
# Key paths
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
# Passphrase for the private key
JWT_PASSPHRASE=your_secure_passphrase
# Validity duration (in seconds)
JWT_TTL=3600
LexikJWT configuration
File: backend/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
token_extractors:
authorization_header:
enabled: true
prefix: Bearer
name: Authorization
Key generation
Create JWT keys
# Via Symfony command
docker exec pch_backend php bin/console lexik:jwt:generate-keypair
# Or manually with OpenSSL
openssl genrsa -out config/jwt/private.pem -aes256 4096
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
Verify keys
# Verify that files exist
docker exec pch_backend ls -la config/jwt/
# Test private key
docker exec pch_backend openssl rsa -in config/jwt/private.pem -check
# Verify public key
docker exec pch_backend openssl rsa -in config/jwt/public.pem -pubin -text
Permissions
# Private key must be protected
docker exec pch_backend chmod 600 config/jwt/private.pem
docker exec pch_backend chmod 644 config/jwt/public.pem
API usage
Authentication
# Get a token
curl -X POST http://localhost:8000/api/login_check \
-H "Content-Type: application/json" \
-d '{"email":"admin@pch-sig.sn","password":"Admin123!"}'
Response:
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
"refresh_token": "abc123..."
}
Use the token
# Authenticated request
curl http://localhost:8000/api/menages \
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
Refresh the token
curl -X POST http://localhost:8000/api/token/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"abc123..."}'
Token structure
JWT payload
{
"iat": 1705312345,
"exp": 1705315945,
"roles": ["ROLE_ADMIN"],
"email": "admin@pch-sig.sn",
"user_id": 1
}
| Field | Description |
|---|---|
iat | Issued At - Creation timestamp |
exp | Expiration - Expiration timestamp |
roles | User roles |
email | User email |
user_id | User ID |
Decode a token
# From command line
echo "eyJ0eXAi..." | cut -d'.' -f2 | base64 -d | jq
# Via jwt.io (do not use in production with real tokens)
Customization
Add data to token
File: src/EventListener/JWTCreatedListener.php
namespace App\EventListener;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
class JWTCreatedListener
{
public function onJWTCreated(JWTCreatedEvent $event): void
{
$user = $event->getUser();
$payload = $event->getData();
// Add custom data
$payload['user_id'] = $user->getId();
$payload['permissions'] = $user->getPermissions();
$event->setData($payload);
}
}
Listener configuration
# config/services.yaml
services:
App\EventListener\JWTCreatedListener:
tags:
- { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_created }
Security
Best practices
-
Secure keys
- Use a strong passphrase
- Never commit keys to Git
- Rotate keys regularly
-
Short lifetime
- Access token: 1 hour maximum
- Refresh token: 7 days maximum
-
HTTPS required
- Always use HTTPS in production
- Token travels in clear text in headers
Token revocation
JWT does not natively support revocation. Solutions:
- Blacklist: Store revoked tokens in Redis
- Short duration: Short-lived tokens + refresh
- User version: Invalidate all tokens if version changes
Blacklist example
// Revoke a token
public function revokeToken(string $token): void
{
$payload = $this->jwtManager->parse($token);
$expiration = $payload['exp'] - time();
$this->redis->setex(
'blacklist:' . hash('sha256', $token),
$expiration,
'1'
);
}
// Check if revoked
public function isRevoked(string $token): bool
{
return $this->redis->exists('blacklist:' . hash('sha256', $token));
}
Troubleshooting
Invalid token
# Check configuration
docker exec pch_backend php bin/console debug:config lexik_jwt_authentication
# Regenerate keys
docker exec pch_backend php bin/console lexik:jwt:generate-keypair --overwrite
# Clear cache
docker exec pch_backend php bin/console cache:clear
Expired token
# Check server time
docker exec pch_backend date
# Synchronize time if needed
Signature error
Possible causes:
- Different keys between environments
- Incorrect passphrase
- Corrupted key file
# Test the key
docker exec pch_backend openssl rsa -in config/jwt/private.pem -check
# If error, regenerate
docker exec pch_backend php bin/console lexik:jwt:generate-keypair --overwrite
Key rotation
Procedure
- Generate a new key pair
- Configure system to accept both keys
- Wait for old tokens to expire
- Remove old key
Rotation script
# Backup old keys
cp config/jwt/private.pem config/jwt/private.pem.old
cp config/jwt/public.pem config/jwt/public.pem.old
# Generate new ones
php bin/console lexik:jwt:generate-keypair --overwrite
# Restart application
docker restart pch_backend