Home / Spring Boot / Web and REST / File Upload and Download

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 client

Limits (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/uploads

Defaults 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

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.