File Handling & Object Storage
Handling uploads and downloads safely: multipart limits, detecting real content types with Tika, streaming large files, S3 and MinIO integration, and presigned URLs.
On this page
File upload endpoints are a well-known source of both outages and breaches. The failure modes are predictable — memory exhaustion, path traversal, content-type spoofing, unbounded storage growth — and each has a straightforward defence.
Key Takeaways
- Set both multipart limits: per file and per request. The defaults are small and the errors are confusing.
- Never trust the declared content type. Detect it from the bytes with Tika.
- Generate your own storage filename; the client's is untrusted input and a path-traversal vector.
- Stream both directions —
getBytes()on a large upload is an OOM waiting for a busy day. - For anything user-facing at scale, prefer presigned URLs over proxying bytes.
Limits first
spring:
servlet:
multipart:
enabled: true
max-file-size: 20MB
max-request-size: 60MB # a multi-file request must fit here too
file-size-threshold: 1MB # below this stays in memory
location: /var/tmp/uploads # where larger parts spill to disk
server:
tomcat:
max-swallow-size: 25MB # bytes Tomcat reads before aborting an oversize requestmax-swallow-size is the setting nobody sets and everybody eventually needs. When a request exceeds
the limit, Tomcat must still read some of it to send a clean error; if the body is much larger it
resets the connection instead, and the client sees a network failure rather than your 413.
Give the limit breach a proper error:
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ProblemDetail tooLarge(MaxUploadSizeExceededException ex) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.PAYLOAD_TOO_LARGE, "The uploaded file exceeds the 20MB limit");
problem.setTitle("File too large");
problem.setType(URI.create("https://api.acme.com/errors/file-too-large"));
return problem;
}The upload pipeline
@Service
public class FileUploadService {
private static final Set<String> ALLOWED =
Set.of("image/jpeg", "image/png", "image/webp", "application/pdf");
private final Tika tika = new Tika();
private final ObjectStorage storage;
private final FileMetadataRepository metadata;
public StoredFile store(MultipartFile file, String uploadedBy) throws IOException {
if (file.isEmpty()) throw new BadRequestException("empty file");
// Detect from content, not from the declared header. Tika reads the
// leading bytes, so this must happen before anything else consumes them.
String detected;
try (InputStream in = file.getInputStream()) {
detected = tika.detect(in);
}
if (!ALLOWED.contains(detected)) {
throw new UnsupportedMediaTypeException(detected);
}
// The client filename is untrusted: it may contain ../, a null byte, or
// a second extension. Keep it as a display label only.
String originalName = StringUtils.cleanPath(
Objects.requireNonNullElse(file.getOriginalFilename(), "upload"));
String storageKey = "%s/%s%s".formatted(
LocalDate.now(), UUID.randomUUID(), extensionFor(detected));
try (InputStream in = file.getInputStream()) {
storage.put(storageKey, in, file.getSize(), detected);
}
return metadata.save(new StoredFile(
storageKey, originalName, detected, file.getSize(), uploadedBy, Instant.now()));
}
}The ordering is deliberate. Detection happens first because it is cheap and rejects most bad uploads; storage happens last so nothing durable is written for a request that will be rejected.
Downloads without buffering
@GetMapping("/api/v1/files/{id}")
public ResponseEntity<Resource> download(@PathVariable UUID id, Authentication auth) {
StoredFile file = metadata.findById(id).orElseThrow(() -> new ResourceNotFoundException("file", id));
accessControl.assertCanRead(auth, file); // authorise before touching storage
InputStream stream = storage.open(file.storageKey());
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.contentType()))
.contentLength(file.size())
// attachment prevents the browser rendering an HTML or SVG payload
// in your origin, which would be a stored XSS.
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(file.originalName(), StandardCharsets.UTF_8)
.build().toString())
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600")
.body(new InputStreamResource(stream));
}InputStreamResource streams straight from storage to the socket. The alternative — reading into a
byte[] — means a 200MB download occupies 200MB of heap for its duration, and ten concurrent ones
take the process down.
ContentDisposition.attachment() with an explicit charset handles non-ASCII filenames correctly by
emitting the filename* form. It also stops a .svg or .html upload from being rendered inline,
which is a genuine stored-XSS vector if your files are served from the application origin.
Presigned URLs
For any significant volume, taking the bytes out of your request path is the single biggest improvement available.
@Service
public class PresignedUploadService {
private final S3Presigner presigner;
public PresignedUpload createUploadUrl(String contentType, long contentLength, String userId) {
if (contentLength > 20 * 1024 * 1024) throw new BadRequestException("too large");
String key = "uploads/%s/%s".formatted(userId, UUID.randomUUID());
var request = PutObjectRequest.builder()
.bucket("acme-user-content")
.key(key)
// Binding the type and length into the signature means the
// client cannot upload something other than what we authorised.
.contentType(contentType)
.contentLength(contentLength)
.build();
var presigned = presigner.presignPutObject(b -> b
.signatureDuration(Duration.ofMinutes(10))
.putObjectRequest(request));
return new PresignedUpload(presigned.url().toString(), key, presigned.expiration());
}
}The flow becomes: client asks your API for a URL, your API authorises and records intent, client uploads directly to storage, client tells your API it finished, your API verifies the object exists and has the expected size before marking it usable. That last verification step matters — without it, a client can claim an upload that never happened.
Keep expiry short. Ten minutes is generous for an upload and short enough that a leaked URL is worthless by the time it reaches anyone.
Metadata and lifecycle
Store metadata in your database, bytes in object storage. The table wants: storage key, original filename, detected content type, size, checksum, uploader, timestamp, and a status.
The status column is what makes cleanup possible. Objects get orphaned — a presigned upload the
client abandoned, a row deleted while the object survived. A scheduled reconciliation that lists
storage, compares against the table, and deletes objects with no live row keeps the bill from
growing forever. Pair it with an object-storage lifecycle rule that expires anything in an
uploads/ prefix older than a day and never confirmed.
Virus scanning
Any file that another user will download should be scanned. ClamAV over its clamd socket is the
usual choice and integrates as a stream:
public ScanResult scan(InputStream stream) {
try (var client = new ClamavClient(host, port)) {
return client.scan(stream) instanceof ScanResult.OK ? ScanResult.clean() : ScanResult.infected();
}
}Scan asynchronously for large files: accept the upload, store it in a quarantine prefix, mark the
row PENDING_SCAN, and promote it once the scan passes. Blocking the request on a scan makes upload
latency depend on a service that occasionally takes seconds.
What to take away
Set the limits, detect the type from the bytes, generate your own filename, and stream in both directions. Once volume justifies it, move the transfer itself to presigned URLs and keep your service responsible only for authorisation and metadata.
Frequently Asked Questions
Why should I not trust the Content-Type header on an upload?
How do I handle files larger than available memory?
Should uploads go through my application at all?
Related tutorials
- Logging & Debugging in ProductionLogging that helps at 3am: choosing levels that mean something, MDC correlation IDs across threads, structured JSON output, async appenders, and what must never be logged.
- Validation & Data IntegrityJakarta Bean Validation in Spring Boot: the full constraint set, custom validators, validation groups, cross-field rules, method validation and where each layer belongs.
- Redis with Spring BootRedis beyond caching: choosing the right data structure, distributed locks that are actually safe, Redis Streams as a queue, and configuring Lettuce for Sentinel and Cluster.
- Spring Data JPA Deep DiveEntity mapping that scales: relationship pitfalls, diagnosing and fixing the N+1 problem, derived queries versus Specifications, pagination that stays fast, and JPA auditing.