Skip to main content

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

TypeDurationUsage
Access Token1 hourAuthenticate API requests
Refresh Token7 daysRenew 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
}
FieldDescription
iatIssued At - Creation timestamp
expExpiration - Expiration timestamp
rolesUser roles
emailUser email
user_idUser 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

  1. Secure keys

    • Use a strong passphrase
    • Never commit keys to Git
    • Rotate keys regularly
  2. Short lifetime

    • Access token: 1 hour maximum
    • Refresh token: 7 days maximum
  3. HTTPS required

    • Always use HTTPS in production
    • Token travels in clear text in headers

Token revocation

JWT does not natively support revocation. Solutions:

  1. Blacklist: Store revoked tokens in Redis
  2. Short duration: Short-lived tokens + refresh
  3. 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

  1. Generate a new key pair
  2. Configure system to accept both keys
  3. Wait for old tokens to expire
  4. 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

Next steps