The Record of Breaking Through CORS and Proxy in Vue 3 + Vite + Spring Boot

Vue3 Spring Boot Vite CORS

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

EnvironmentSolution
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.


Article Map for This Series

Building an End-User DVD Rental App — Vue 3 + Spring Boot Paired with the Admin App, with Article Map

Feel free to send a message

Job offers, project referrals, feedback, questions — anything is welcome. I sincerely hope to connect with people who share high ambitions. I will keep taking on the challenges I have staked my life on. Thank you very much.