JWT and HttpOnly Cookies
This page documents the JWT authentication implementation with HttpOnly cookies.
Overview
The system uses JWT (JSON Web Tokens) for authentication, with tokens stored in HttpOnly cookies for enhanced security.
Why HttpOnly?
localStorage Storage Risks
| Risk | Description |
|---|---|
| XSS | Malicious scripts can read localStorage |
| Session theft | Tokens accessible via JavaScript |
| No auto-expiration | Tokens persist even after browser close |
HttpOnly Advantages
| Advantage | Description |
|---|---|
| XSS protection | Cookies not accessible via JavaScript |
| Auto-expiration | Cookies can expire automatically |
| SameSite | CSRF protection with SameSite attribute |
Architecture
┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Frontend │ Login │ Backend │ Store │ Cookie │
│ React │ ------> │ Symfony │ ------> │ HttpOnly │
│ │ │ │ │ │
│ │ <------ │ │ <------ │ │
│ │ Set- │ │ │ │
│ │ Cookie │ │ │ │
└─────────────────┘ └─────────────────┘ └──────────────┘
Configuration
Backend (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
# Cookie configuration
set_cookies:
access_token:
name: jwt_access
httpOnly: true
secure: true
sameSite: strict
path: /
refresh_token:
name: jwt_refresh
httpOnly: true
secure: true
sameSite: strict
path: /api/token/refresh
Environment Variables
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=your_passphrase
JWT_TOKEN_TTL=3600
JWT_REFRESH_TOKEN_TTL=2592000 # 30 days
Authentication Flow
Login
POST /api/auth/login
Content-Type: application/json
{
"username": "user@example.com",
"password": "password123"
}
Response
HTTP/1.1 200 OK
Set-Cookie: jwt_access=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/
Set-Cookie: jwt_refresh=abc...; HttpOnly; Secure; SameSite=Strict; Path=/api/token/refresh
{
"success": true,
"data": {
"user": {
"id": "...",
"email": "user@example.com",
"nom": "John Doe"
}
}
}
Authenticated Requests
Cookies are automatically sent with each request:
fetch('/api/users/me', {
method: 'GET',
credentials: 'include' // Important!
})
Refresh Token
Endpoint
POST /api/token/refresh
Behavior
- The
jwt_refreshcookie is automatically sent - The backend validates the refresh token
- New access + refresh cookies are generated
Token Rotation
On each refresh:
- New access token generated
- New refresh token generated (rotation)
- Old refresh token invalidated
Logout
Endpoint
POST /api/auth/logout
Behavior
- Cookie deletion (Max-Age=0)
- Refresh token invalidation in database
- Redirect to login page
Security
Cookie Attributes
| Attribute | Value | Description |
|---|---|---|
| HttpOnly | true | Not accessible via JavaScript |
| Secure | true | HTTPS only |
| SameSite | Strict | No cross-origin sending |
| Path | / | Application path |
| Max-Age | 3600 | Expiration in seconds |
CSRF Protection
SameSite=Strict prevents the cookie from being sent from other domains.
For sensitive actions, an additional CSRF token can be used:
fetch('/api/action', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken()
},
credentials: 'include'
})
Frontend (Axios)
Global Configuration
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: true, // Sends cookies
});
// Interceptor for automatic refresh
api.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
try {
await api.post('/token/refresh');
return api.request(error.config);
} catch (refreshError) {
// Redirect to login
window.location.href = '/#/auth/login';
}
}
return Promise.reject(error);
}
);
Error Handling
| Error | HTTP | Description |
|---|---|---|
| Token expired | 401 | Access token expired, refresh needed |
| Invalid token | 401 | Token corrupted or tampered |
| Refresh expired | 401 | Session ended, login needed |
| Invalid CSRF | 403 | CSRF token missing or invalid |
Migration from localStorage
If migrating from localStorage:
- Phase 1: Accept both modes (localStorage + cookies)
- Phase 2: Migrate existing tokens to cookies
- Phase 3: Remove localStorage support
// Migration check
if (localStorage.getItem('token')) {
// Old mode - force re-login
localStorage.removeItem('token');
window.location.href = '/#/auth/login';
}
Best Practices
:::tip Recommendations
- HTTPS mandatory: Secure cookies only work in HTTPS
- SameSite=Strict: Use Strict unless specific need
- Short duration: Short access token (1h), long refresh token (30d)
- Rotation: Rotate refresh tokens on each use
- Clean logout: Invalidate tokens server-side on logout :::