File Upload and Download
Uploading files looks easy but has serious security and memory issues. This page shows a safe complete implementation: size limits, name sanitizing, path traversal protection, streaming downloads and where to store files in production.
Workflow
Browser ── multipart/form-data ──► Tomcat parses the parts (temp files above the memory threshold)
▼
DispatcherServlet ─► MultipartResolver ─► MultipartFile parameter
▼
Controller ─► FileStorageService ─► disk / S3 / object storage
Download: Controller ─► Resource / StreamingResponseBody ─► bytes streamed to the clientLimits (very important)
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=15MB
spring.servlet.multipart.file-size-threshold=1MB
app.upload-dir=/data/uploadsDefaults are small: 1MB per file and 10MB per request. A request above the limit fails with MaxUploadSizeExceededException, which you should convert to HTTP 413 in your exception handler.
Storage service
@Service
public class FileStorageService {
private final Path root;
public FileStorageService(@Value("${app.upload-dir}") String dir) throws IOException {
this.root = Path.of(dir).toAbsolutePath().normalize();
Files.createDirectories(root);
}
public String store(MultipartFile file) throws IOException {
if (file.isEmpty()) throw new IllegalArgumentException("Empty file");
String original = StringUtils.cleanPath(Objects.requireNonNullElse(file.getOriginalFilename(), "file"));
if (original.contains("..")) throw new IllegalArgumentException("Invalid file name");
String ext = original.contains(".") ? original.substring(original.lastIndexOf('.')) : "";
String storedName = UUID.randomUUID() + ext; // never trust the client's name
Path target = root.resolve(storedName).normalize();
if (!target.startsWith(root)) throw new IllegalArgumentException("Invalid path"); // path traversal guard
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
return storedName;
}
public Resource load(String name) throws MalformedURLException {
Path file = root.resolve(name).normalize();
if (!file.startsWith(root) || !Files.isReadable(file)) throw new NotFoundException("File not found");
return new UrlResource(file.toUri());
}
}Controller
@RestController
@RequestMapping("/api/files")
public class FileController {
private static final Set<String> ALLOWED = Set.of("image/png", "image/jpeg", "application/pdf");
private final FileStorageService storage;
public FileController(FileStorageService storage) { this.storage = storage; }
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Map<String, String>> upload(@RequestPart("file") MultipartFile file) throws IOException {
if (!ALLOWED.contains(file.getContentType())) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build();
}
String name = storage.store(file);
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("file", name, "download", "/api/files/" + name));
}
@GetMapping("/{name}")
public ResponseEntity<Resource> download(@PathVariable String name) throws IOException {
Resource resource = storage.load(name);
String type = Files.probeContentType(resource.getFile().toPath());
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(type != null ? type : "application/octet-stream"))
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(name).build().toString())
.body(resource);
}
@GetMapping("/export/report.csv") // generate and stream a big file
public ResponseEntity<StreamingResponseBody> exportCsv() {
StreamingResponseBody body = out -> {
try (Writer w = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8))) {
w.write("id,name\n");
for (int i = 1; i <= 100_000; i++) w.write(i + ",item" + i + "\n"); // never build it all in memory
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=report.csv")
.contentType(MediaType.parseMediaType("text/csv"))
.body(body);
}
}curl -F "file=@photo.png;type=image/png" http://localhost:8080/api/files
curl -O -J http://localhost:8080/api/files/<stored-name>Security checklist
- Limit size, and check the content type AND the real content (magic bytes), because the client controls the Content-Type header.
- Generate the stored file name yourself. Never use the original file name as a path.
- Guard against path traversal (../../etc/passwd) with normalize() and startsWith(root).
- Store uploads outside the web root, and never execute or serve them as HTML from your own domain (XSS risk). Serve as attachment.
- Scan for viruses if users share files with each other.
- Protect download URLs with authorization checks, or use short-lived signed URLs.
Production storage
Local disk does not work with several instances or in containers (the disk disappears). Use object storage (Amazon S3, Google Cloud Storage, Azure Blob, MinIO) and save only the object key and metadata in your database. For big files, let the client upload directly to storage using a pre-signed URL, so the file never passes through your application.
Common mistake: Using file.getOriginalFilename() directly to create a path. An attacker sends ../../application.yml as the name and overwrites files on your server. Always generate your own name and verify the final path.
Interview questions
How do I return HTTP 413 when the file is too big?
Handle MaxUploadSizeExceededException in a @RestControllerAdvice and return ProblemDetail with status 413.
How do I upload multiple files?
Use @RequestPart("files") List<MultipartFile> files and loop over them, with the same validation for each file.
Why should large downloads use StreamingResponseBody or Resource?
They write bytes to the response as they are produced. Loading a whole file into a byte array uses memory for each request and can crash the application under load.