Le message que tout le monde a déjà vu
Ton front tourne sur http://localhost:5173, ton API Spring Boot sur http://localhost:8080. Tu fais un fetch vers ton API, et la console t’affiche :
Access to fetch at 'http://localhost:8080/api/mesures' from origin 'http://localhost:5173'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
Réflexe classique : tu testes la même URL dans Postman ou avec curl… et ça marche. Donc ton API n’est pas cassée. Alors qui bloque ? Le navigateur. Et uniquement lui.
L’origine, c’est quoi exactement ?
Une origine, c’est le trio schéma + domaine + port. Si un seul des trois change, c’est une autre origine :
http://localhost:5173→http://localhost:8080: autre origine (port différent)https://fallou.dev→http://fallou.dev: autre origine (schéma différent)https://fallou.dev→https://api.fallou.dev: autre origine (domaine différent)https://fallou.dev/blog→https://fallou.dev/api: même origine (seul le chemin change)
Le cas du localhost avec deux ports différents piège tout le monde : pour toi c’est “la même machine”, pour le navigateur ce sont deux sites différents.
Pourquoi le navigateur bloque
Par défaut, le navigateur applique la Same-Origin Policy : un script chargé depuis un site ne peut pas lire les réponses d’un autre site.
Imagine sans cette règle : tu es connecté à ta banque dans un onglet, tu ouvres un site douteux dans un autre. Ce site lance un fetch("https://ta-banque.fr/comptes") en JavaScript. Ton navigateur envoie tes cookies de session avec, et le site douteux lit tes comptes. La Same-Origin Policy empêche exactement ça.
Le problème, c’est que cette règle bloque aussi les cas légitimes, comme ton front et ton API sur deux ports différents. CORS sert à ça : c’est le serveur qui indique au navigateur quelles origines ont le droit de lire ses réponses.
NB : CORS n’est pas une protection de ton serveur. C’est une protection de l’utilisateur, appliquée par son navigateur. curl, Postman, un script Python ou un autre serveur ignorent complètement CORS. Si ton API ne doit pas être appelée par n’importe qui, c’est à l’authentification de s’en charger, pas à CORS.
Comment ça marche ?
Tout passe par des headers HTTP. Le navigateur ajoute l’origine de la page à sa requête :
GET /api/mesures HTTP/1.1
Host: localhost:8080
Origin: http://localhost:5173
Et le serveur répond en disant qui il autorise :
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Content-Type: application/json
Si l’origine de la page correspond, le navigateur laisse ton JavaScript lire la réponse. Sinon, il la cache et affiche l’erreur rouge.
Point important : dans ce cas, la requête est bien arrivée au serveur. Elle a été exécutée, la réponse est revenue, c’est juste le navigateur qui refuse de la montrer à ton code. Si c’était un POST qui crée une ligne en base, la ligne existe.
La requête “preflight” (OPTIONS)
Pour les requêtes qui peuvent modifier des choses, le navigateur est plus prudent : avant d’envoyer la vraie requête, il envoie une requête de vérification, appelée preflight, avec la méthode OPTIONS :
OPTIONS /api/mesures HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
En gros : “est-ce que j’ai le droit d’envoyer un POST avec ces headers ?”. Le serveur répond :
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 3600
Si la réponse convient, le navigateur envoie la vraie requête. Sinon, la vraie requête ne part jamais.
Quand est-ce qu’il y a un preflight ? Presque tout le temps en pratique. Une requête est dite “simple” (sans preflight) seulement si :
- La méthode est GET, HEAD ou POST
- Le Content-Type est
application/x-www-form-urlencoded,multipart/form-dataoutext/plain - Il n’y a pas de header personnalisé
Dès que tu envoies du JSON (Content-Type: application/json), un header Authorization avec un token, ou que tu fais un PUT/DELETE : preflight.
Access-Control-Max-Age dit au navigateur combien de secondes il peut garder la réponse du preflight en cache, pour éviter un OPTIONS avant chaque requête.
Et les cookies ?
Par défaut, un fetch vers une autre origine n’envoie pas les cookies. Si ton authentification repose sur un cookie de session, il faut le demander côté front :
fetch("http://localhost:8080/api/mesures", { credentials: "include" });
Et côté serveur, deux conditions :
- Répondre
Access-Control-Allow-Credentials: true - Indiquer l’origine exacte dans
Access-Control-Allow-Origin. Le joker*est interdit avec les credentials, le navigateur rejette la réponse.
Configurer CORS dans Spring Boot
Option 1 — Sur un contrôleur
Le plus rapide, pour un seul contrôleur :
@RestController
@RequestMapping("/api/mesures")
@CrossOrigin(origins = "http://localhost:5173")
public class MesureController {
// ...
}
Pratique pour tester, mais vite pénible quand tu as dix contrôleurs.
Option 2 — Une config globale
Une seule classe pour toute l’API :
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
Spring gère tout seul les requêtes OPTIONS du preflight sur les chemins déclarés.
Option 3 — Avec Spring Security
C’est là que la plupart des gens se font avoir. Spring Security passe avant Spring MVC : si une requête OPTIONS de preflight arrive sans token, Security la rejette en 401 ou 403 avant même que ta config CORS ne soit lue. Le navigateur, lui, affiche… une erreur CORS. Tu passes une heure à modifier ta config CORS alors que le problème est dans la config de sécurité.
La solution : activer CORS dans la chaîne de sécurité, avec une source de configuration :
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll());
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:5173"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Avec .cors(...), Spring Security traite le preflight en premier et le laisse passer, sans demander de token.
Les pièges classiques
* avec les credentials. Si tu mets allowedOrigins("*") et allowCredentials(true), Spring lève une IllegalArgumentException dès la première requête cross-origin : “When allowCredentials is true, allowedOrigins cannot contain the special value ““…”*. Liste tes origines explicitement, ou utilise allowedOriginPatterns si tu as vraiment besoin d’un motif (par exemple https://*.fallou.dev).
Le slash final. http://localhost:5173/ n’est pas la même chaîne que http://localhost:5173. L’origine n’a jamais de slash à la fin, ta config non plus.
L’erreur CORS qui cache une autre erreur. Si ton API plante (500) et que la réponse d’erreur ne passe pas par la config CORS, le navigateur ne voit pas le header et affiche une erreur CORS. Regarde toujours le code HTTP de la réponse dans l’onglet Network avant de toucher à CORS.
Ouvrir en grand “pour que ça marche”. allowedOriginPatterns("*") + allowCredentials(true) fait disparaître l’erreur… et autorise n’importe quel site à faire des requêtes authentifiées avec les cookies de tes utilisateurs. Exactement ce que la Same-Origin Policy voulait empêcher.
En dev : le proxy, ou comment ne plus avoir de CORS
Si le front et l’API sont servis depuis la même origine, il n’y a plus de CORS du tout. En développement, les outils front savent faire ça avec un proxy. Avec Vite :
// vite.config.js
export default {
server: {
proxy: {
"/api": "http://localhost:8080",
},
},
};
Ton front appelle /api/mesures sur localhost:5173, Vite transmet en coulisse à localhost:8080. Pour le navigateur, tout vient de la même origine. En production, un reverse proxy (Nginx par exemple) qui sert le front et l’API sur le même domaine donne le même résultat.
Déboguer une erreur CORS
- Ouvre l’onglet Network des outils de développement
- Regarde s’il y a une requête OPTIONS avant ta requête : si oui, c’est elle qu’il faut examiner en premier
- Vérifie son code HTTP : un 401/403 sur un OPTIONS, c’est presque toujours Spring Security
- Vérifie les headers de réponse :
Access-Control-Allow-Originest-il présent ? Contient-il exactement ton origine ? - Si la vraie requête renvoie un 500, corrige d’abord l’erreur serveur
Pour résumer
- Une origine = schéma + domaine + port
- C’est le navigateur qui bloque, pas le serveur : curl et Postman ne sont pas concernés
- Le serveur autorise des origines via les headers
Access-Control-Allow-* - JSON, token ou PUT/DELETE = preflight OPTIONS avant la vraie requête
- Pas de
*avec les cookies - Avec Spring Security, active
.cors()dans la chaîne de sécurité, sinon le preflight se fait rejeter - En dev, un proxy évite le problème complètement
CORS n’est pas un bug à contourner, c’est le navigateur qui protège tes utilisateurs. Une fois qu’on a compris qui bloque et pourquoi, l’erreur rouge devient beaucoup moins mystérieuse.