CORS, Content Negotiation and File Uploads in Spring Boot
Introduction
This tutorial covers three things every public REST API meets sooner or later: CORS, which decides whether JavaScript on other websites may call the API; content negotiation, which lets one endpoint answer in several formats; and file uploads and downloads. We will start with why browsers block cross-origin requests, and configure Spring Boot to allow exactly the origins that should get through - including the preflight request browsers send first. We will then serve a book list as JSON or CSV from the same endpoint, chosen by theAccept header or a query parameter, by adding our own HTTP message converter. Lastly, we will accept file uploads with MultipartFile, enforce a size limit, and serve a file for download with the right Content-Disposition header.(This tutorial closes the REST module. It builds on Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController and uses problem responses from REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail.)
Why CORS exists
Browsers enforce the same-origin policy: JavaScript loaded fromhttps://shop.example.com may freely call https://shop.example.com, but a call to another origin - a different scheme, host or port, such as https://api.example.com - is blocked, unless that server says it accepts it. Without this rule, any website you visit could use your logged-in sessions to call other sites' APIs.CORS (Cross-Origin Resource Sharing) is how a server says it accepts such calls. The browser sends the page's origin in an
Origin header, and the server answers with Access-Control-Allow-* headers saying which origins, methods and headers it allows. For requests that could change data - a PUT, a DELETE, a POST with a JSON body - the browser first sends a preflight request, an OPTIONS request asking permission, and only sends the real request if the answer allows it. CORS is enforced by browsers only; servers, scripts and tools such as curl are not affected by it.
The example application
One application demonstrates all three topics: a global CORS configuration, a CSV message converter, and upload and download endpoints -WebFeaturesApp.java - CORS, a CSV message converter, and file upload and download
//WebFeaturesApp.java
package com.javabrahman.rest.web;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
record Book(long id, String title, BigDecimal price) {
}
// A wrapper type, so that the CSV converter can recognise "a list of books"
record BookList(List<Book> books) {
}
// ---- 1. CORS for every /api endpoint ----
@Configuration
class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://shop.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Content-Type", "Authorization")
.exposedHeaders("X-Total-Count")
.maxAge(3600);
}
}
// ---- 2. A message converter that writes BookList as CSV ----
class BookListCsvConverter extends AbstractHttpMessageConverter<BookList> {
static final MediaType TEXT_CSV = MediaType.parseMediaType("text/csv");
BookListCsvConverter() {
super(StandardCharsets.UTF_8, TEXT_CSV);
}
@Override
protected boolean supports(Class<?> clazz) {
return BookList.class.equals(clazz);
}
@Override
protected BookList readInternal(Class<? extends BookList> clazz, HttpInputMessage input) {
throw new HttpMessageNotReadableException("Reading CSV is not supported", input);
}
@Override
protected void writeInternal(BookList list, HttpOutputMessage output) throws IOException {
Writer writer = new OutputStreamWriter(output.getBody(), StandardCharsets.UTF_8);
writer.write("id,title,price\n");
for (Book b : list.books()) {
writer.write(b.id() + ",\"" + b.title().replace("\"", "\"\"") + "\"," + b.price() + "\n");
}
writer.flush();
}
}
@RestController
@RequestMapping("/api")
class ApiController {
@GetMapping("/books")
ResponseEntity<BookList> books() {
BookList list = new BookList(List.of(
new Book(1, "Effective Java", new BigDecimal("45.99")),
new Book(2, "Java Concurrency in Practice", new BigDecimal("49.99"))));
return ResponseEntity.ok().header("X-Total-Count", "2").body(list);
}
// ---- 3. File upload and download ----
@PostMapping(path = "/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
String upload(@RequestParam("file") MultipartFile file, @RequestParam String description) throws IOException {
String firstLine = new String(file.getBytes(), StandardCharsets.UTF_8).lines().findFirst().orElse("");
return "received '" + file.getOriginalFilename() + "' (" + file.getContentType() + ", " + file.getSize()
+ " bytes), description '" + description + "', first line: " + firstLine;
}
@GetMapping("/files/{name}")
ResponseEntity<Resource> download(@PathVariable String name) {
byte[] content = ("Order report for " + name + "\nOrders: 42\nRevenue: 1890.50\n").getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(name + ".txt").build().toString())
.body(new ByteArrayResource(content));
}
}
@SpringBootApplication
public class WebFeaturesApp {
@Bean
BookListCsvConverter bookListCsvConverter() { // Spring Boot adds HttpMessageConverter beans to Spring MVC
return new BookListCsvConverter();
}
public static void main(String[] args) {
SpringApplication.run(WebFeaturesApp.class, args);
}
}
CORS in action
The requests below are what a browser would send: a simpleGET from the allowed origin, the same from another origin, and a preflight for a PUT with a JSON body -
OUTPUT of cross-origin requests> curl -i http://localhost:8080/api/books -H "Origin: https://shop.example.com"
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Expose-Headers: X-Total-Count
X-Total-Count: 2
Content-Type: application/json
{"books":[{"id":1,"title":"Effective Java","price":45.99},{"id":2,"title":"Java Concurrency in Practice","price":49.99}]}
> curl -i http://localhost:8080/api/books -H "Origin: https://evil.example.net"
HTTP/1.1 403
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Invalid CORS request
> curl -i -X OPTIONS http://localhost:8080/api/books -H "Origin: https://shop.example.com" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: Content-Type"
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Expose-Headers: X-Total-Count
Access-Control-Max-Age: 3600
Content-Length: 0
Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH
Explanation of the output
- From the allowed origin, the response carried
Access-Control-Allow-Origin: https://shop.example.com, so the browser hands the response to the page's JavaScript.Access-Control-Expose-Headerslets the script readX-Total-Count, which browsers otherwise hide.Varytells caches that the response depends on theOriginand preflight headers. - From another origin, Spring answered
403with Invalid CORS request and no CORS headers - the browser would block it. - The preflight
OPTIONSrequest asked whether aPUTwith aContent-Typeheader is allowed. The answer allowed it, listed the allowed methods and headers, andAccess-Control-Max-Age: 3600lets the browser remember the answer for an hour instead of asking before every request. @CrossOrigin(origins = "...")on a controller or method configures CORS for just that part of the API. When Spring Security is used (module 5), its CORS support must be switched on too, so that preflight requests are not rejected before they reach Spring MVC.- Never answer every origin with
allowedOrigins("*")on an API that uses cookies or other credentials; Spring refuses that combination, andallowedOriginPatternsexists for cases such ashttps://*.example.com.
Content negotiation: JSON or CSV
Content negotiation is how client and server agree on a format. The client lists the formats it accepts in theAccept header; Spring MVC compares them with the formats its message converters can write for the return type, and picks the best match. The BookListCsvConverter above can write a BookList as text/csv, and Jackson can write it as JSON, so the same endpoint can answer in both. Spring Boot can also take the format from a query parameter, switched on with two properties -
application.properties - JSON by default, and ?format=csv as well as the Accept header
spring.mvc.contentnegotiation.default-content-types=application/json
spring.mvc.contentnegotiation.favor-parameter=true
spring.mvc.contentnegotiation.media-types.csv=text/csv
> curl -i http://localhost:8080/api/books
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
X-Total-Count: 2
Content-Type: application/json
{"books":[{"id":1,"title":"Effective Java","price":45.99},{"id":2,"title":"Java Concurrency in Practice","price":49.99}]}
> curl -i http://localhost:8080/api/books -H "Accept: application/json"
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
X-Total-Count: 2
Content-Type: application/json
{"books":[{"id":1,"title":"Effective Java","price":45.99},{"id":2,"title":"Java Concurrency in Practice","price":49.99}]}
> curl -i http://localhost:8080/api/books -H "Accept: text/csv"
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
X-Total-Count: 2
Content-Type: text/csv; charset=UTF-8
id,title,price
1,"Effective Java",45.99
2,"Java Concurrency in Practice",49.99
> curl -i http://localhost:8080/api/books?format=csv
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
X-Total-Count: 2
Content-Type: text/csv; charset=UTF-8
id,title,price
1,"Effective Java",45.99
2,"Java Concurrency in Practice",49.99
> curl -i http://localhost:8080/api/books -H "Accept: application/xml"
HTTP/1.1 406
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
X-Total-Count: 2
Accept: text/csv, application/json, application/*+json
Content-Type: application/problem+json
{"detail":"Acceptable representations: [text/csv, application/json, application/*+json].","instance":"/api/books","status":406,"title":"Not Acceptable"}
Explanation of the output
Accept: application/jsonchose Jackson;Accept: text/csvchose our converter - the controller method is the same, only the converter differs.- With
favor-parameteron,?format=csvchose CSV through thecsvmedia type registered in the properties. That is handy for links a person clicks in a browser, where theAcceptheader cannot be set. - No converter can write XML here, so
Accept: application/xmlgot406 Not Acceptable. Adding thejackson-dataformat-xmllibrary would make Spring Boot register an XML converter, and the same request would succeed. - Spring Boot adds every
HttpMessageConverterbean to Spring MVC's converters, which is howBookListCsvConverterwas registered - one@Beanmethod, no other configuration. - A pitfall: Spring Boot puts such converter beans ahead of its built-in ones. A client that sends no
Acceptheader (orAccept: */*) accepts anything, so the first converter that can write the type wins - and without further configuration that was the CSV converter, so plain requests got CSV instead of JSON.spring.mvc.contentnegotiation.default-content-types=application/jsonfixes it: when the client expresses no preference, JSON is chosen, as the first request above shows.
File uploads with MultipartFile
Browsers and clients upload files asmultipart/form-data: one request body with several parts, each with its own headers - a file part and ordinary form fields. Spring MVC hands a file part to the method as a MultipartFile, with its original name, content type, size and bytes. The size of uploads is limited by spring.servlet.multipart.max-file-size (1MB by default) and max-request-size (10MB). To show the limit at work, this run lowers it to 100 bytes -
OUTPUT of a small upload and an upload over the limit> curl -i -X POST http://localhost:8080/api/files -H "Content-Type: multipart/form-data; boundary=jb-boundary" -d '--jb-boundary
Content-Disposition: form-data; name="description"
November orders
--jb-boundary
Content-Disposition: form-data; name="file"; filename="orders.csv"
Content-Type: text/csv
id,customer,total
1,Tom Jones,45.99
--jb-boundary--'
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Content-Length: 104
Content-Type: text/plain; charset=UTF-8
received 'orders.csv' (text/csv, 36 bytes), description 'November orders', first line: id,customer,total
> curl -i -X POST http://localhost:8080/api/files -H "Content-Type: multipart/form-data; boundary=jb-boundary" -d '--jb-boundary
Content-Disposition: form-data; name="description"
The whole year
--jb-boundary
Content-Disposition: form-data; name="file"; filename="orders-2026.csv"
Content-Type: text/csv
id,customer,total
1,Tom Jones,45.99
2,Nancy Smith,120.00
3,Ethan Hardy,64.50
4,Harry Major,18.75
5,Deborah Sprightly,99.90
--jb-boundary--'
HTTP/1.1 413
Content-Type: application/problem+json
{"detail":"Maximum upload size exceeded","instance":"/api/files","status":413,"title":"Content Too Large"}
Explanation of the output
- The request body is shown as sent: parts separated by the
--jb-boundarylines named in theContent-Type, each with aContent-Dispositionheader naming the form field. (curl builds such a body itself withcurl -F "file=@orders.csv" -F "description=November orders".) - The first upload was under the limit.
@RequestParam("file") MultipartFilereceived the file part, with its name, content type and size, and@RequestParam String descriptionthe text field. - The second file was larger than 100 bytes. Tomcat stopped reading it, and Spring answered
413 Content Too Largeas a problem response - before the controller ran. - Treat uploaded files as untrusted input: check their type and size, never use the original file name as a path on the server, and store files outside the application's own folders or in object storage.
File downloads
To send a file, return aResource - from bytes, the file system or the classpath - in a ResponseEntity, with a content type and a Content-Disposition header -
OUTPUT of downloading a report> curl -i http://localhost:8080/api/files/november-report HTTP/1.1 200 Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers Accept-Ranges: bytes Content-Length: 61 Content-Disposition: attachment; filename="november-report.txt" Content-Type: text/plain Order report for november-report Orders: 42 Revenue: 1890.50
Content-Disposition: attachment tells a browser to save the response as a file instead of showing it, under the given filename; inline would display it. ContentDisposition.attachment().filename(...) builds the header with correct quoting, including the encoded filename* form for non-ASCII names. For large files, return a FileSystemResource or an InputStreamResource, which Spring streams to the client without loading the whole file into memory.
Summary
In this tutorial we first saw why browsers block cross-origin requests and how CORS lets a server allow them. We configured CORS for/api/**, and saw an allowed request get its Access-Control-Allow-Origin header, another origin get 403, and a preflight request get the allowed methods, headers and a cache time. We then wrote a message converter for CSV, and served the same endpoint as JSON or CSV by the Accept header or a format parameter, with 406 for a format nobody can write. Lastly, we uploaded files with MultipartFile, hit the upload size limit with a 413, and served a download with Content-Disposition. This completes the REST module of the Spring series; the next module covers data access with Spring Data JPA.Spring REST API Tutorials
Documenting Spring Boot REST APIs with OpenAPI and springdocClick to read tutorial: Documenting Spring Boot REST APIs with OpenAPI and springdoc Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4
Documenting Spring Boot REST APIs with OpenAPI and springdocClick to read tutorial: Documenting Spring Boot REST APIs with OpenAPI and springdoc Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4