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 the Accept 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 from https://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 simple GET 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-Headers lets the script read X-Total-Count, which browsers otherwise hide. Vary tells caches that the response depends on the Origin and preflight headers.
  • From another origin, Spring answered 403 with Invalid CORS request and no CORS headers - the browser would block it.
  • The preflight OPTIONS request asked whether a PUT with a Content-Type header is allowed. The answer allowed it, listed the allowed methods and headers, and Access-Control-Max-Age: 3600 lets 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, and allowedOriginPatterns exists for cases such as https://*.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 the Accept 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
 OUTPUT of the same endpoint in different formats
> 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/json chose Jackson; Accept: text/csv chose our converter - the controller method is the same, only the converter differs.
  • With favor-parameter on, ?format=csv chose CSV through the csv media type registered in the properties. That is handy for links a person clicks in a browser, where the Accept header cannot be set.
  • No converter can write XML here, so Accept: application/xml got 406 Not Acceptable. Adding the jackson-dataformat-xml library would make Spring Boot register an XML converter, and the same request would succeed.
  • Spring Boot adds every HttpMessageConverter bean to Spring MVC's converters, which is how BookListCsvConverter was registered - one @Bean method, no other configuration.
  • A pitfall: Spring Boot puts such converter beans ahead of its built-in ones. A client that sends no Accept header (or Accept: */*) 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/json fixes 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 as multipart/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-boundary lines named in the Content-Type, each with a Content-Disposition header naming the form field. (curl builds such a body itself with curl -F "file=@orders.csv" -F "description=November orders".)
  • The first upload was under the limit. @RequestParam("file") MultipartFile received the file part, with its name, content type and size, and @RequestParam String description the text field.
  • The second file was larger than 100 bytes. Tomcat stopped reading it, and Spring answered 413 Content Too Large as 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 a Resource - 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.