Skip to main content

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

RiskDescription
XSSMalicious scripts can read localStorage
Session theftTokens accessible via JavaScript
No auto-expirationTokens persist even after browser close

HttpOnly Advantages

AdvantageDescription
XSS protectionCookies not accessible via JavaScript
Auto-expirationCookies can expire automatically
SameSiteCSRF 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

  1. The jwt_refresh cookie is automatically sent
  2. The backend validates the refresh token
  3. 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

  1. Cookie deletion (Max-Age=0)
  2. Refresh token invalidation in database
  3. Redirect to login page

Security

AttributeValueDescription
HttpOnlytrueNot accessible via JavaScript
SecuretrueHTTPS only
SameSiteStrictNo cross-origin sending
Path/Application path
Max-Age3600Expiration 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

ErrorHTTPDescription
Token expired401Access token expired, refresh needed
Invalid token401Token corrupted or tampered
Refresh expired401Session ended, login needed
Invalid CSRF403CSRF token missing or invalid

Migration from localStorage

If migrating from localStorage:

  1. Phase 1: Accept both modes (localStorage + cookies)
  2. Phase 2: Migrate existing tokens to cookies
  3. 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

  1. HTTPS mandatory: Secure cookies only work in HTTPS
  2. SameSite=Strict: Use Strict unless specific need
  3. Short duration: Short access token (1h), long refresh token (30d)
  4. Rotation: Rotate refresh tokens on each use
  5. Clean logout: Invalidate tokens server-side on logout :::