File Upload in Spring Boot: MultipartFile, S3, Streaming & Validation
Master Spring Boot file upload with MultipartFile, S3 integration, streaming large files, size limits, and production-ready validation.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Use
@RequestParam MultipartFile fileor@RequestBodywithMultipartFilein a@RestControllerto accept uploads - Configure
spring.servlet.multipart.max-file-sizeandspring.servlet.multipart.max-request-sizeinapplication.properties - Stream large files with
InputStreamto avoid heap exhaustion — never load the whole file into a byte array - Validate file type by checking magic bytes, not just the extension or Content-Type header
- Use AWS SDK v2
S3AsyncClientwith multipart upload for files over 100 MB in production
Think of file upload like handing a package to a delivery service. Spring Boot is the counter — it needs to know the maximum package size it will accept, where to temporarily store it, and where to forward it. If the package is huge, you don't carry the whole thing at once; you pass it through a conveyor belt piece by piece (streaming), so you don't drop it.
At 2 AM your on-call phone rings. The service is throwing java.lang.OutOfMemoryError on every request. The root cause: a junior dev used file.getBytes() on a 500 MB video upload, loading the entire file into the JVM heap on every concurrent request. The pod crashed, and the autoscaler couldn't keep up. This is the most common file-upload mistake in Spring Boot, and it's entirely preventable.
File upload sounds simple — it's just bytes going from a browser to a server — but the production surface area is enormous. You need to think about request size limits at four layers: the client, the reverse proxy (Nginx/ALB), the Spring DispatchedServlet, and your application code. Missing any one layer causes mysterious 413 errors or silent truncation.
Multipart handling in Spring Boot changed significantly between Spring Boot 2 and 3. The underlying Tomcat, Jetty, or Undertow behaviour differs, and when you put a Kubernetes ingress in front, default body size limits of 1 MB will silently break uploads before your code even runs.
Then there's security. Accepting arbitrary files from the internet is an attack surface. Path traversal, polyglot files (a valid image that is also a ZIP bomb or a script), MIME type spoofing — each has a real CVE attached. Production file upload code must validate at the byte level, not at the filename level.
This guide covers everything from the basic MultipartFile handler through streaming with InputStream, virus scanning hooks, S3 multipart upload using AWS SDK v2, and the exact Actuator metrics you should alert on in production.
Basic MultipartFile Upload Endpoint
The simplest Spring Boot file upload endpoint uses @RequestParam MultipartFile file on a @PostMapping. This works for files that fit comfortably in memory — anything under ~50 MB on a well-tuned JVM. The critical first step is configuration: without explicit size limits, Spring Boot defaults to 1 MB for both max-file-size and max-request-size, which is useless for any real application.
Annotate your controller with @RestController and @RequestMapping. The MultipartFile parameter gives you access to getOriginalFilename(), getContentType(), getSize(), and getInputStream(). Never trust getOriginalFilename() or getContentType() — both come directly from the client and can be spoofed. A file named photo.jpg with Content-Type: image/jpeg can contain arbitrary bytes.
For small files, after validation, you can write to local disk with Files.copy(file.getInputStream(), destination, StandardCopyOption.REPLACE_EXISTING). For production systems, local disk is an antipattern — your pods are ephemeral. Write to S3, GCS, or Azure Blob Storage instead.
Always return a structured response — include the generated file ID (UUID), the storage path, the file size, and the detected MIME type. Never return the original filename in the response without sanitizing it, as it could contain path traversal sequences.
file.getSize() logging at INFO level — unexpected size spikes in logs often precede OOM incidents by minutes.Streaming Large Files to S3 with AWS SDK v2
For files over 50 MB, or when running at scale, streaming directly to S3 without intermediate buffering is the only production-viable approach. AWS SDK v2 introduced S3AsyncClient and AsyncRequestBody.fromInputStream(), which pipes the MultipartFile's InputStream directly to S3 over HTTP/2 without ever materializing the bytes on the JVM heap.
For files over 100 MB, use the S3TransferManager (built on top of S3AsyncClient) which automatically splits the upload into 8 MB chunks and uploads them in parallel via the S3 multipart upload API. This is dramatically faster on high-bandwidth connections and is resumable — if the connection drops mid-upload, only the failed parts need to be retried.
The tricky part is that MultipartFile.getInputStream() can only be read once. If you need to compute a checksum or validate the file type in a streaming fashion, you must compose the stream: wrap it in a DigestInputStream for MD5/SHA-256, then pipe it through your validator, then to S3, all in a single read pass. Apache Tika supports streaming detection with AutoDetectParser.
For very large files (video, datasets), consider presigned S3 URLs: the client uploads directly to S3, bypassing your server entirely. Your server generates the presigned URL, the client uploads, and S3 triggers an event notification to your backend via SQS. This eliminates your server from the upload path entirely, which is the correct architecture for files over 500 MB.
Always set a content MD5 or SHA-256 header on S3 puts. S3 verifies the checksum and rejects corrupted uploads. This catches network corruption that would otherwise silently store a truncated file.
Executors.newVirtualThreadPerTaskExecutor() to AsyncRequestBody.fromInputStream() to avoid blocking platform threads.File Validation: Magic Bytes, Size Limits, and Virus Scanning
Production file upload validation has three layers: size limits (enforced at the framework level before your code runs), type validation (using magic bytes, not extensions), and content scanning (ClamAV or a commercial API for virus/malware detection).
Magic byte detection with Apache Tika is the standard approach. Tika reads the first few bytes of the stream and matches them against a database of known file type signatures. A JPEG always starts with FF D8 FF, a PNG with 89 50 4E 47, a PDF with 25 50 44 46. No amount of filename manipulation can fake these bytes.
For images, additionally validate that the file can actually be decoded as an image. Use ImageI wrapped in a try-catch — if it returns null or throws, the file is corrupt or a disguised binary. For a more thorough check, use the O.read()scrimage library which also detects image bombs (tiny files that expand to gigabytes when decoded).
Virus scanning in production typically uses ClamAV via its Unix socket. Run ClamAV as a sidecar in your Kubernetes pod and connect to its socket using the clamav4j library. Scan the input stream before storing it anywhere. For high-throughput systems, send files to a scanning queue asynchronously and move them from a quarantine bucket to the public bucket only after a clean scan result.
Path traversal prevention: never use file.getOriginalFilename() as a filesystem path. Always generate a UUID-based filename server-side. If you must preserve the original name for display, store it in a database field only — never use it for filesystem operations.
ImmutableImage.loader() has a built-in dimension limit option.