Real World Example

One Origin for SPA and API

Most production setups (Swiggy, Razorpay Dashboard, Zerodha Kite web) put the SPA and the API behind the same domain with a reverse proxy, so the browser sees one origin: no CORS in production, cookies are first-party, and CSRF cookies are readable. In development, Vite's proxy does the same job.

Browser --> https://shopease.in/            --> nginx --> React build (static files)
        --> https://shopease.in/api/...     --> nginx --> Spring Boot :8080
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': { target: 'http://localhost:8080', changeOrigin: true },
    },
  },
});

ShopEase Final Config

Bearer header for the API (no CSRF needed there), cookie for the refresh/logout endpoints (CSRF token required there), CORS kept for other front-ends like the seller portal on another domain:

@Bean
SecurityFilterChain api(HttpSecurity http, JwtService jwtService) throws Exception {
    // only the cookie-authenticated endpoints need a CSRF token
    RequestMatcher cookieEndpoints = request ->
            "POST".equals(request.getMethod())
            && (request.getRequestURI().equals("/api/auth/refresh")
                || request.getRequestURI().equals("/api/auth/logout"));

    http
        .cors(Customizer.withDefaults())
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
            .requireCsrfProtectionMatcher(cookieEndpoints))
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/**").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/products/**", "/api/categories/**").permitAll()
            .anyRequest().authenticated())
        .addFilterBefore(new JwtAuthFilter(jwtService), UsernamePasswordAuthenticationFilter.class);
    return http.build();
}
DefenceProtects against
Access token in memory, short TTLToken theft from storage, long misuse
Refresh cookie HttpOnly + SecureXSS reading the refresh token, sniffing on HTTP
SameSite=Strict + Path=/api/authCross-site requests carrying the cookie
CSRF token on refresh/logoutCSRF from sub-domains or old browsers
Exact CORS originsOther websites reading API responses with the user's cookies