Spring Boot File Upload and Download REST API Example
Spring Boot 14 min read
Multipart endpoints that survive contact with real users: size limits, a storage service that rejects path traversal, correct content-disposition on download, and the 413 you get before your controller ever runs.
A Spring Boot file upload example takes about fifteen lines to demonstrate and rather more to get
right. This one builds a complete Spring Boot file upload REST API and the matching download
endpoint, handling the parts usually left out, where the size limit actually lives, why
getOriginalFilename() is not safe to use as a path, and what a browser needs in order to save a
download rather than render it.
Written against Spring Boot 3.2 and Java 17. Only spring-boot-starter-web is required —
multipart support is built into the embedded container.
Configuring the Spring Boot file upload limits
# src/main/resources/application.properties
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=25MB
# Not a Spring property — our own, bound below.
storage.upload-dir=./uploads
Every Spring Boot file upload REST API needs these two before anything else, and the distinction
between them matters. max-file-size caps any single part;
max-request-size caps the whole multipart body. Upload five 8 MB files at once and each passes the
first limit while the request breaches the second.
These are enforced before your controller is invoked. The container rejects an oversized body
during parsing, so a @RestController method never runs and a plain @ExceptionHandler on the
controller will not catch it. Handle it globally instead:
@RestControllerAdvice
public class UploadExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
ProblemDetail tooLarge(MaxUploadSizeExceededException ex) {
return ProblemDetail.forStatusAndDetail(
HttpStatus.PAYLOAD_TOO_LARGE, "File exceeds the 10MB limit");
}
}
Bind the storage directory to a typed record rather than scattering @Value annotations:
@ConfigurationProperties(prefix = "storage")
public record StorageProperties(String uploadDir) { }
@SpringBootApplication
@EnableConfigurationProperties(StorageProperties.class)
public class FilesApplication {
public static void main(String[] args) { SpringApplication.run(FilesApplication.class, args); }
}
A storage service that rejects path traversal
This is the part most examples get wrong, so it is worth reading closely.
MultipartFile.getOriginalFilename() is attacker-controlled. A client can send
../../../../etc/cron.d/payload as the filename, and if you resolve that against your upload
directory you have written outside it.
@Service
public class FileStorageService {
private final Path root;
public FileStorageService(StorageProperties props) {
this.root = Paths.get(props.uploadDir()).toAbsolutePath().normalize();
try {
Files.createDirectories(root);
} catch (IOException e) {
throw new IllegalStateException("Could not create upload directory", e);
}
}
public String store(MultipartFile file) {
if (file.isEmpty()) throw new StorageException("Cannot store an empty file");
// StringUtils.cleanPath resolves ".." segments; the containment check below is
// what actually enforces the boundary. Do both.
String cleaned = StringUtils.cleanPath(
Objects.requireNonNull(file.getOriginalFilename(), "filename is required"));
if (cleaned.contains("..")) {
throw new StorageException("Filename contains a relative path: " + cleaned);
}
// Never trust the client's name for the stored name. Keep the extension only.
String ext = StringUtils.getFilenameExtension(cleaned);
String stored = UUID.randomUUID() + (ext != null ? "." + ext : "");
Path target = root.resolve(stored).normalize();
if (!target.getParent().equals(root)) { // the real guard
throw new StorageException("Resolved outside the storage directory");
}
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new StorageException("Failed to store " + cleaned, e);
}
return stored;
}
public Resource loadAsResource(String filename) {
Path target = root.resolve(filename).normalize();
if (!target.getParent().equals(root)) {
throw new StorageException("Resolved outside the storage directory");
}
Resource resource = new FileSystemResource(target);
if (!resource.exists() || !resource.isReadable()) {
throw new FileNotFoundException("File not found: " + filename);
}
return resource;
}
}
Three defences, and they are not redundant:
cleanPathnormalises the string.- Generating the stored name yourself removes the client’s influence entirely. This is the one that matters. It also fixes collisions and filesystem-illegal characters for free.
- The containment check on the resolved path is the backstop that catches anything the first two missed.
The upload and download REST API endpoints
public record UploadResponse(String fileName, String downloadUri, String contentType, long size) { }
@RestController
@RequestMapping("/api/files")
public class FileController {
private final FileStorageService storage;
public FileController(FileStorageService storage) { this.storage = storage; }
@PostMapping
public UploadResponse upload(@RequestParam("file") MultipartFile file) {
String stored = storage.store(file);
String uri = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/api/files/").path(stored).toUriString();
return new UploadResponse(stored, uri, file.getContentType(), file.getSize());
}
@PostMapping("/batch")
public List<UploadResponse> uploadMany(@RequestParam("files") MultipartFile[] files) {
return Arrays.stream(files).map(this::upload).toList();
}
@GetMapping("/{filename:.+}")
public ResponseEntity<Resource> download(@PathVariable String filename,
HttpServletRequest request) throws IOException {
Resource resource = storage.loadAsResource(filename);
String contentType = request.getServletContext()
.getMimeType(resource.getFile().getAbsolutePath());
if (contentType == null) contentType = "application/octet-stream";
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(resource.getFilename(), StandardCharsets.UTF_8)
.build().toString())
.body(resource);
}
}
Details that are easy to miss:
{filename:.+}: without the :.+ regex, Spring truncates at the last dot and
report.pdf arrives as report. This trips up nearly everyone once.
ContentDisposition.attachment() rather than a hand-built string. It handles RFC 5987
encoding, so a filename with non-ASCII characters survives instead of being mangled.
attachment prompts a save dialog; inline lets the browser render it.
Serving user uploads from your own origin is a stored-XSS risk. An uploaded .html or .svg
rendered inline executes in your domain’s context. attachment disposition plus
X-Content-Type-Options: nosniff mitigates it; a separate cookieless domain is the real fix.
Running the example
./mvnw spring-boot:run
curl -F '[email protected]' http://localhost:8080/api/files
{
"fileName": "9f0c2b31-4e77-4a2c-bb0a-7c1a6f2d5e88.pdf",
"downloadUri": "http://localhost:8080/api/files/9f0c2b31-4e77-4a2c-bb0a-7c1a6f2d5e88.pdf",
"contentType": "application/pdf",
"size": 284913
}
# several at once
curl -F '[email protected]' -F '[email protected]' http://localhost:8080/api/files/batch
# download, keeping the server-provided filename
curl -OJ http://localhost:8080/api/files/9f0c2b31-4e77-4a2c-bb0a-7c1a6f2d5e88.pdf
# over the limit → 413, handled globally
curl -F '[email protected]' -i http://localhost:8080/api/files
That is the whole Spring Boot file upload example end to end, store, list a download URI, retrieve. A slice test covers the upload path without touching the disk:
@WebMvcTest(FileController.class)
class FileControllerTest {
@Autowired MockMvc mvc;
@MockitoBean FileStorageService storage;
@Test
void uploadReturnsDownloadUri() throws Exception {
given(storage.store(any())).willReturn("stored.txt");
MockMultipartFile file =
new MockMultipartFile("file", "orig.txt", "text/plain", "hello".getBytes());
mvc.perform(multipart("/api/files").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.fileName").value("stored.txt"));
}
}
When the filesystem is the wrong place
This example writes to local disk, which is fine for a single instance and wrong almost everywhere else. Two instances behind a load balancer cannot see each other’s uploads, and a container filesystem disappears on restart, the same trap as any ephemeral volume.
For anything beyond one box, store the bytes in S3 or equivalent and keep only the key in your
database. The controller barely changes; FileStorageService gains a different implementation,
which is exactly the seam an interface earns its keep on.
The middle option, bytes in a LONGBLOB alongside the metadata, is covered in
storing uploads in the database,
together with the max_allowed_packet ceiling that decides where it stops working. The
REST API walkthrough covers the
controller conventions this one assumes.
Frequently asked questions
How do I set the Spring Boot file upload size limit?
spring.servlet.multipart.max-file-size for one part and
spring.servlet.multipart.max-request-size for the whole body. Set both: five files under the
per-file limit can still exceed the request limit.
Why is my exception handler not catching the oversized upload?
The container rejects the request while parsing the multipart body, before your controller runs.
Handle MaxUploadSizeExceededException in a @RestControllerAdvice.
Why is my file extension being stripped from the path variable?
Spring truncates at the last dot by default. Use @GetMapping("/{filename:.+}").
Is getOriginalFilename() safe to use as a path?
No. It is supplied by the client and can contain .. segments. Generate the stored name yourself
and verify the resolved path stays inside your upload directory.
How do I upload several files in one request?
Take a MultipartFile[] bound to a repeated request parameter, and make sure max-request-size
covers the combined total.
What is the difference between inline and attachment?
inline lets the browser render the file; attachment prompts a save dialog. Prefer attachment
for user-supplied content, since inline HTML or SVG executes in your origin.
How do I return the correct content type on download?
Ask the servlet context for the MIME type of the resolved file and fall back to
application/octet-stream. Do not echo the content type the client sent at upload time.
Can I stream a large file instead of loading it into memory?
Yes, returning a Resource from a FileSystemResource streams it. Avoid
MultipartFile.getBytes(), which materialises the whole file.
Where should uploads actually be stored?
Object storage such as S3 for anything running on more than one instance. Local disk is not shared between replicas and does not survive a container restart.
How do I restrict the file types accepted?
Check the magic bytes of the content, not the extension or the client-supplied content type. Both of those are trivially forged.