REST API · Lesson 43 of 95
CORS in Spring Boot
CORS in Spring Boot explained: fix blocked browser calls with @CrossOrigin and addCorsMappings, learn how preflight works and see real response headers.
You build a shop API and it works perfectly in Postman. Then your friend builds a React page on another address, calls the same API, and the browser shows a red error: "blocked by CORS policy". The API is fine. The browser is protecting the user. CORS in Spring Boot is how you tell the browser, "Yes, this website is allowed to call me."
Let's see what CORS means, why browsers insist on it, and how to set it up for a small grocery shop API, with real headers as proof.
What is CORS in Spring Boot?
An origin is the trio of scheme, host and port. So http://localhost:3000 and http://localhost:8080 are different origins, even though the host is the same. https://shop.com and http://shop.com are different too.
Your React app at port 3000 calling your Spring Boot API at port 8080 is a cross-origin call. By default the browser lets the request go out, but it refuses to give the answer to the page unless the reply carries the right Access-Control-Allow-Origin header. Spring Boot's job is to add those headers for the origins you trust.
Why is it used?
Without this rule, any website you visit could quietly call your bank's API using your logged-in browser and read the answers. The same-origin policy blocks that. CORS is a controlled hole in that wall: the server owner decides which other origins may pass.
You need to set it up when:
- Your frontend and API run on different ports during development.
- The frontend is hosted on one domain and the API on another in production.
- A partner website should be allowed to read a public feed.
How it works
For simple GET requests the browser just sends the call with an Origin header. For calls that change data, such as PUT with JSON, it first sends a small question called a preflight.
textBrowser page (localhost:3000) | OPTIONS /products/rice/price | Origin: localhost:3000 | Request-Method: PUT v Spring Boot CORS check | is this origin allowed? | is PUT allowed? v Reply with Allow headers | v Browser sends the real PUT
The preflight is an OPTIONS request that asks, "May I send a PUT from this origin?" Spring answers from your CORS rules. If the answer is yes, the browser sends the real request. If the answer is no, the browser stops, and your PUT never leaves the user's machine. Spring Boot handles the preflight itself, so you do not write an OPTIONS method.
Real-Life Example
Think of a housing society gate. The guard has a list of flat numbers and visitors' companies that are allowed in. A delivery boy from an unknown company is stopped at the gate, even if he is holding a real parcel. He is not a criminal. His name is just not on the list. The guard also phones ahead before letting a plumber carry heavy tools inside. That phone call is the preflight, and the list is your allowedOrigins.
Code Example
FreshMart is a grocery shop with a product API. A React page at http://localhost:3000 should read and update prices. We set a global rule for /products and a single-method rule for /offers, both in this example. Only the web starter is needed.
textfreshmart-shop/ ├─ pom.xml └─ src/main/java/ └─ com/freshmart/shop/ ├─ ShopApplication.java ├─ CorsConfig.java └─ ProductController.java
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.freshmart</groupId> <artifactId>shop</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: ShopApplication.java in package com.freshmart.shop
javapackage com.freshmart.shop; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class ShopApplication { public static void main(String[] args) { SpringApplication.run(ShopApplication.class, args); } }
File: CorsConfig.java in package com.freshmart.shop
javapackage com.freshmart.shop; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/products/**") .allowedOrigins("http://localhost:3000") .allowedMethods("GET", "PUT") .allowedHeaders("*") .maxAge(3600); } }
File: ProductController.java in package com.freshmart.shop
javapackage com.freshmart.shop; import java.util.List; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class ProductController { record Product(String name, int priceInRupees) {} record PriceChange(int priceInRupees) {} @GetMapping("/products") public List<Product> products() { return List.of(new Product("Tomatoes 1 kg", 40), new Product("Basmati Rice 5 kg", 520)); } @PutMapping("/products/{name}/price") public String changePrice(@PathVariable String name, @RequestBody PriceChange change) { return name + " now costs Rs " + change.priceInRupees(); } @CrossOrigin("https://partner.example.com") @GetMapping("/offers") public List<String> offers() { return List.of("Buy 2 kg tomatoes, get coriander free"); } @GetMapping("/internal/stock") public String stock() { return "Only for our own servers"; } }
Start the app. Then pretend to be the browser by sending an Origin header with curl, and print the reply headers with -i:
bashmvn spring-boot:run curl -i localhost:8080/products -H "Origin: http://localhost:3000"
Output:
bashHTTP/1.1 200 Access-Control-Allow-Origin: http://localhost:3000 Content-Type: application/json [{"name":"Tomatoes 1 kg","priceInRupees":40},{"name":"Basmati Rice 5 kg","priceInRupees":520}]
The Access-Control-Allow-Origin header is the browser's permission slip. Now the same call from an origin that is not on the list:
bashcurl -i localhost:8080/products -H "Origin: http://evil.example.com"
Output:
bashHTTP/1.1 403 Invalid CORS request
Last, the preflight for a PUT. We send the OPTIONS call that a browser would send:
bashcurl -i -X OPTIONS localhost:8080/products/rice/price \ -H "Origin: http://localhost:3000" \ -H "Access-Control-Request-Method: PUT" \ -H "Access-Control-Request-Headers: content-type"
Output:
bashHTTP/1.1 200 Access-Control-Allow-Origin: http://localhost:3000 Access-Control-Allow-Methods: GET,PUT Access-Control-Allow-Headers: content-type Access-Control-Max-Age: 3600 Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH
Code Explained
CorsConfigimplementsWebMvcConfigurerand overridesaddCorsMappings. This is the global way, so one place holds all the rules.addMapping("/products/**")says these rules apply to every path under/products.allowedOriginslists the exact origins.allowedMethodslists the HTTP methods.allowedHeaders("*")accepts any request header.maxAge(3600)lets the browser remember the preflight answer for one hour, so it does not ask before every call. It is theAccess-Control-Max-Ageline above.@CrossOriginonoffers(), with the partner's address as its value, opens only that one method, for one partner./internal/stockhas no CORS rule at all, so no allow header is ever added. A browser page from another origin cannot read it.- The
403 Invalid CORS requestreply is Spring saying no. The origin was not on the list.
Three Ways to Configure CORS
| Way | Where | Best for |
|---|---|---|
@CrossOrigin | On a controller or method | One or two endpoints |
WebMvcConfigurer | A configuration class | Whole-app rules |
| Security config | Spring Security's CORS support | Apps that use Spring Security |
Common Mistakes
- *Using `
everywhere.**allowedOrigins("*")` lets every website read your responses. Fine for a truly public API, dangerous for one that uses cookies or tokens. - *Combining `
with cookies.** Spring rejectsallowCredentials(true)together with the*` origin. List real origins instead. - Trailing slash or wrong port.
http://localhost:3000/is not the same ashttp://localhost:3000. The origin must match exactly. - Blaming the server for a CORS error. The console message often appears when the API returned an error, such as 500, without the allow header. Check the network tab first.
- Thinking CORS is security for your API. Anyone with curl can call it. Real protection needs authentication and authorization.
Interview Questions
What is an origin?
Ans:The combination of scheme, host and port. Two URLs with any difference in one of them are different origins.
What is a preflight request?
Ans:An automatic OPTIONS request that the browser sends before a risky cross-origin call, such as PUT or a request with custom headers, to ask whether the server allows it.
How do you enable CORS in Spring Boot?
Ans:Use @CrossOrigin on controllers or methods, or implement the addCorsMappings method of WebMvcConfigurer for global rules. With Spring Security, also enable CORS in the security filter chain.
Does CORS stop a curl request?
Ans:No. CORS is only checked by browsers, so it never blocks curl, Postman or server-to-server calls.
Key Points to Remember
- CORS lets a server say which other origins may read its responses in a browser.
- An origin is scheme plus host plus port.
- Spring Boot answers preflight
OPTIONSrequests from your rules. - Use
@CrossOriginfor one endpoint andaddCorsMappingsfor global rules. - List exact origins; avoid
*when cookies are involved. - CORS is not authentication. It only guides browsers.
Frequently Asked Questions
How do I fix a CORS error in Spring Boot?
Add the origin of your frontend, such as http://localhost:3000, to allowedOrigins in a WebMvcConfigurer or @CrossOrigin, and restart. Check that the method you use is in allowedMethods.
Why does CORS work in Postman but fail in the browser?
Postman does not enforce the same-origin rule. Only browsers check the allow headers.
Should I allow all origins in production?
Only for public, read-only data. For anything with logins, list the exact frontend origins.
Where should CORS settings live in a real project?
In one configuration class, with the allowed origins read from application.properties, so each environment can differ.
Related Topics
- Spring MVC Architecture: see where CORS handling sits in the request path.
- GET POST PUT DELETE Mapping: learn the methods that trigger a preflight.
- Interceptors and Filters: understand the filters that run before your controller.
- Spring Security Basics: set CORS correctly when login rules are added.
Practice Problems
Try each problem on your own first. Both use only the web starter. We test with curl by adding an Origin header, the way a browser does.
Easy: PagePoint Bookshop Frontend
PagePoint's React page runs at http://localhost:5173 and calls GET /books on the Spring Boot API. Allow only that origin, on that controller.
Show answerHide answer
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.pagepoint</groupId> <artifactId>books</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: BooksApplication.java in package com.pagepoint.books
javapackage com.pagepoint.books; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class BooksApplication { public static void main(String[] args) { SpringApplication.run(BooksApplication.class, args); } }
File: BookController.java in package com.pagepoint.books
javapackage com.pagepoint.books; import java.util.List; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController @CrossOrigin(origins = "http://localhost:5173") public class BookController { @GetMapping("/books") public List<String> books() { return List.of("Monsoon Diaries", "Paper Boats"); } }
Send the request as the page would:
bashcurl -i localhost:8080/books -H "Origin: http://localhost:5173"
The reply headers and body:
bashHTTP/1.1 200 Access-Control-Allow-Origin: http://localhost:5173 Content-Type: application/json ["Monsoon Diaries","Paper Boats"]
Medium: QuickBite Orders from Properties
QuickBite serves /api/orders to two frontends: http://localhost:3000 and https://app.quickbite.example. Read the allowed origins from application.properties as a comma-separated list. Allow GET and POST, allow cookies to be sent, and let the browser read a custom X-Total-Count header. Any other origin must get 403.
Show answerHide answer
*.File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.quickbite</groupId> <artifactId>orders</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiescors.allowed-origins=http://localhost:3000,https://app.quickbite.example
File: OrdersApplication.java in package com.quickbite.orders
javapackage com.quickbite.orders; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class OrdersApplication { public static void main(String[] args) { SpringApplication.run(OrdersApplication.class, args); } }
File: CorsConfig.java in package com.quickbite.orders
javapackage com.quickbite.orders; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { private final String[] origins; public CorsConfig(@Value("${cors.allowed-origins}") String[] origins) { this.origins = origins; } @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins(origins) .allowedMethods("GET", "POST") .allowCredentials(true) .exposedHeaders("X-Total-Count"); } }
File: OrderController.java in package com.quickbite.orders
javapackage com.quickbite.orders; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/orders") public class OrderController { @GetMapping public ResponseEntity<List<String>> orders() { List<String> orders = List.of("Veg Biryani", "Masala Dosa", "Cold Coffee"); return ResponseEntity.ok() .header("X-Total-Count", String.valueOf(orders.size())) .body(orders); } }
Calling with the second origin:
bashcurl -i localhost:8080/api/orders -H "Origin: https://app.quickbite.example"
prints:
bashHTTP/1.1 200 Access-Control-Allow-Origin: https://app.quickbite.example Access-Control-Expose-Headers: X-Total-Count Access-Control-Allow-Credentials: true X-Total-Count: 3 Content-Type: application/json ["Veg Biryani","Masala Dosa","Cold Coffee"]
The same call with Origin: http://localhost:4000 prints status 403 and the text Invalid CORS request.