The Record of Breaking Through CORS and Proxy in Vue 3 + Vite + Spring Boot
Introduction
When developing with the frontend separated as Vue 3 + Vite (port 5173) and the backend as Spring Boot (port 8082),
the first thing you’re guaranteed to run into is a CORS error.
Access to XMLHttpRequest at 'http://localhost:8082/api/films'
from origin 'http://localhost:5173' has been blocked by CORS policy
This article is a record of breaking through this error, along with the correct workaround for each environment.
What CORS Actually Is
CORS (Cross-Origin Resource Sharing) is a browser security feature.
The short version: “a request from http://localhost:5173 going to http://localhost:8082 counts as a different origin, so the browser blocks it.”
Key point: CORS is a restriction enforced by the browser.
Server-to-server communication (tools like curl or Postman) never triggers CORS.
Solution During Development: Vite’s devProxy
During development, using Vite’s proxy feature is the simplest approach.
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8082',
changeOrigin: true,
// rewrite isn't needed this time (the backend also uses the /api prefix)
}
}
}
})
With this setting:
- The browser sends its request to
http://localhost:5173/api/films - The Vite server receives it and forwards it to
http://localhost:8082/api/films - From the browser’s perspective this is “communication to the same origin (port 5173),” so CORS never triggers
API Calls on the Frontend Side
// api/films.ts
export async function fetchFilms(): Promise<Film[]> {
// Switch the base URL depending on the environment
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
const res = await fetch(`${baseUrl}/api/films`)
if (!res.ok) throw new Error('API error')
return res.json()
}
During development VITE_API_BASE_URL isn’t set, so it resolves to an empty string, and /api/films reaches Spring Boot via the Vite proxy.
Solution in Production: Spring Boot’s CORS Configuration
In production, the frontend and backend may end up on different domains (or different ports).
Method 1: Global CORS Configuration
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origins}")
private String[] allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
# application.yml
app:
cors:
allowed-origins: http://localhost:5173
# application-prod.yml
app:
cors:
allowed-origins: https://your-production-frontend.com
Method 2: The @CrossOrigin Annotation (Per Controller)
@RestController
@CrossOrigin(origins = "${app.cors.allowed-origins}")
@RequestMapping("/api/films")
public class FilmController {
// ...
}
Points Where I Got Stuck
① Forgetting the Preflight Request (OPTIONS)
In CORS, before the actual request, an OPTIONS method comes in first as a preflight check asking “is this request allowed?”
If you’re using Spring Security, that OPTIONS request can get rejected by authentication.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // Disable CSRF for a REST API
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // Allow OPTIONS unconditionally
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
② allowedOrigins("*") and allowCredentials(true) Can’t Coexist
A wildcard and sending credentials can’t be used together.
When allowCredentials is true, allowedOrigins cannot contain the special value "*"
If you’re using credentials (like session cookies), specify a concrete origin.
③ Watch the Trailing Slash on Production URLs
# This one can end up NG
allowed-origins: https://example.com/
# No trailing slash
allowed-origins: https://example.com
Switching via Environment Variables
# .env (during development)
VITE_API_BASE_URL=
# .env.production (at production build time)
VITE_API_BASE_URL=https://api.example.com
In a production build, putting the production API’s URL into VITE_API_BASE_URL calls the backend directly, with no proxy needed.
Summary
| Environment | Solution |
|---|---|
| Development (localhost) | Vite’s server.proxy setting |
| Production (same domain) | Route /api through a reverse proxy such as nginx |
| Production (different domain) | Spring Boot’s CORS setting + allowed-origins pointed at the production URL |
A CORS error is “a browser security feature,” so what matters is allowing it properly rather than working around it.
Handling it with the Vite proxy in development and on the Spring Boot side in production is the correct division of responsibility.
Implementation in This App
vite.config.ts (Actual Code)
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8082', // Spring Boot's port
changeOrigin: true
// rewrite isn't needed (the path is forwarded as-is)
}
}
}
})
Every request starting with /api is forwarded to http://localhost:8082.
changeOrigin: true rewrites the request’s Origin header to match the target.
CorsConfig.java (Actual Code)
// config/CorsConfig.java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
/**
* Registers CORS rules under the API path, for the local dev frontend.
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
Point: Because allowCredentials(true) is specified, allowedOrigins("*") can’t be used.
A concrete origin, http://localhost:5173, is specified instead.
Why both are configured in development: the Vite proxy avoids CORS through the “browser → Vite → Spring Boot” flow, but having a CORS setting on the Spring Boot side as well means it can also handle future cases where the frontend and backend run on separate origins, or direct access such as Swagger UI.