Skip to content
Unverified — AI-generated content. Help verify this page

File Uploads ​

File uploads are deceptively complex. The simple case — accept a file, save it to disk — works in a tutorial but breaks in production. Large files exhaust memory. Concurrent uploads saturate disk I/O. Malicious files bypass extension checks. Thumbnail generation blocks request threads. Storage fills up on a single server. And none of this is apparent until you have real users uploading real files.

Spring Boot provides solid multipart upload support through MultipartFile, but building a production-ready upload pipeline requires understanding streaming, async processing, external storage, and security scanning.

Basic File Upload ​

Configuration ​

yaml
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 10MB          # Maximum size per file
      max-request-size: 50MB       # Maximum size of entire request
      file-size-threshold: 2KB     # Below this, files stay in memory
      location: /tmp/uploads       # Temp directory for buffered files

Simple Upload Endpoint ​

java
@RestController
@RequestMapping("/api/files")
public class FileUploadController {

    private final FileStorageService storageService;

    public FileUploadController(FileStorageService storageService) {
        this.storageService = storageService;
    }

    @PostMapping("/upload")
    public ResponseEntity<FileUploadResponse> uploadFile(
            @RequestParam("file") MultipartFile file,
            @AuthenticationPrincipal UserDetails user) {

        // Validate
        if (file.isEmpty()) {
            throw new BadRequestException("File is empty");
        }

        // Store
        FileMetadata metadata = storageService.store(file, user.getUsername());

        return ResponseEntity.ok(new FileUploadResponse(
                metadata.getId(),
                metadata.getOriginalFilename(),
                metadata.getContentType(),
                metadata.getSize(),
                metadata.getDownloadUrl()
        ));
    }

    @PostMapping("/upload/multiple")
    public ResponseEntity<List<FileUploadResponse>> uploadMultiple(
            @RequestParam("files") List<MultipartFile> files,
            @AuthenticationPrincipal UserDetails user) {

        if (files.size() > 10) {
            throw new BadRequestException("Maximum 10 files per upload");
        }

        List<FileUploadResponse> responses = files.stream()
                .filter(f -> !f.isEmpty())
                .map(f -> storageService.store(f, user.getUsername()))
                .map(FileUploadResponse::from)
                .toList();

        return ResponseEntity.ok(responses);
    }
}

File Validation ​

Never trust client-provided filenames, content types, or extensions:

java
@Component
public class FileValidator {

    private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
            "image/jpeg", "image/png", "image/gif", "image/webp",
            "application/pdf",
            "application/msword",
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
    );

    private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

    // Magic bytes for common file types
    private static final Map<String, byte[]> MAGIC_BYTES = Map.of(
            "image/jpeg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
            "image/png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47},
            "image/gif", new byte[]{0x47, 0x49, 0x46, 0x38},
            "application/pdf", new byte[]{0x25, 0x50, 0x44, 0x46}
    );

    public void validate(MultipartFile file) {
        // 1. Check size
        if (file.getSize() > MAX_FILE_SIZE) {
            throw new FileValidationException("File exceeds maximum size of 10MB");
        }

        // 2. Check content type
        String contentType = file.getContentType();
        if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
            throw new FileValidationException("File type not allowed: " + contentType);
        }

        // 3. Verify magic bytes (don't trust Content-Type header)
        try {
            byte[] header = new byte[8];
            file.getInputStream().read(header);
            if (!verifyMagicBytes(contentType, header)) {
                throw new FileValidationException(
                        "File content does not match declared type: " + contentType);
            }
        } catch (IOException e) {
            throw new FileValidationException("Cannot read file header", e);
        }

        // 4. Sanitize filename
        String filename = file.getOriginalFilename();
        if (filename != null && (filename.contains("..") || filename.contains("/"))) {
            throw new FileValidationException("Invalid filename: " + filename);
        }
    }

    private boolean verifyMagicBytes(String contentType, byte[] header) {
        byte[] expected = MAGIC_BYTES.get(contentType);
        if (expected == null) return true; // No magic bytes to check
        for (int i = 0; i < expected.length; i++) {
            if (header[i] != expected[i]) return false;
        }
        return true;
    }
}

Streaming Uploads for Large Files ​

MultipartFile buffers the entire file in memory (or temp directory) before your controller method runs. For large files, this is wasteful. Streaming lets you process bytes as they arrive:

java
@PostMapping("/upload/stream")
public ResponseEntity<FileUploadResponse> streamUpload(HttpServletRequest request)
        throws IOException, ServletException {

    // Parse multipart stream without buffering entire file
    Collection<Part> parts = request.getParts();
    Part filePart = parts.stream()
            .filter(p -> "file".equals(p.getName()))
            .findFirst()
            .orElseThrow(() -> new BadRequestException("No file part found"));

    String originalFilename = filePart.getSubmittedFileName();
    String contentType = filePart.getContentType();
    String storageKey = generateStorageKey(originalFilename);

    // Stream directly to S3 without buffering
    try (InputStream inputStream = filePart.getInputStream()) {
        storageService.storeStream(storageKey, inputStream,
                filePart.getSize(), contentType);
    }

    return ResponseEntity.ok(new FileUploadResponse(storageKey, originalFilename));
}

Chunked Upload for Very Large Files ​

For files over 100MB, implement chunked uploads so clients can resume interrupted transfers:

java
@RestController
@RequestMapping("/api/files/chunked")
public class ChunkedUploadController {

    private final ChunkedUploadService uploadService;

    @PostMapping("/init")
    public ResponseEntity<ChunkedUploadSession> initUpload(
            @RequestBody InitUploadRequest request) {
        ChunkedUploadSession session = uploadService.initSession(
                request.getFilename(),
                request.getTotalSize(),
                request.getChunkSize(),
                request.getContentType()
        );
        return ResponseEntity.ok(session);
    }

    @PostMapping("/{sessionId}/chunk/{chunkIndex}")
    public ResponseEntity<ChunkUploadResult> uploadChunk(
            @PathVariable String sessionId,
            @PathVariable int chunkIndex,
            @RequestParam("chunk") MultipartFile chunk) {

        ChunkUploadResult result = uploadService.uploadChunk(
                sessionId, chunkIndex, chunk);

        if (result.isComplete()) {
            // All chunks received — assemble the file
            uploadService.assembleFile(sessionId);
        }

        return ResponseEntity.ok(result);
    }

    @GetMapping("/{sessionId}/status")
    public ResponseEntity<UploadStatus> getStatus(@PathVariable String sessionId) {
        return ResponseEntity.ok(uploadService.getStatus(sessionId));
    }
}
java
@Service
public class ChunkedUploadService {

    private final Map<String, ChunkedUploadSession> sessions = new ConcurrentHashMap<>();

    public ChunkedUploadSession initSession(String filename, long totalSize,
                                             int chunkSize, String contentType) {
        String sessionId = UUID.randomUUID().toString();
        int totalChunks = (int) Math.ceil((double) totalSize / chunkSize);

        ChunkedUploadSession session = new ChunkedUploadSession(
                sessionId, filename, totalSize, chunkSize, totalChunks, contentType);
        sessions.put(sessionId, session);

        return session;
    }

    public ChunkUploadResult uploadChunk(String sessionId, int chunkIndex,
                                          MultipartFile chunk) {
        ChunkedUploadSession session = sessions.get(sessionId);
        if (session == null) {
            throw new NotFoundException("Upload session not found: " + sessionId);
        }

        // Save chunk to temp storage
        Path chunkPath = Path.of("/tmp/chunks", sessionId,
                String.format("chunk_%05d", chunkIndex));
        try {
            Files.createDirectories(chunkPath.getParent());
            chunk.transferTo(chunkPath);
        } catch (IOException e) {
            throw new StorageException("Failed to save chunk", e);
        }

        session.markChunkComplete(chunkIndex);

        return new ChunkUploadResult(
                chunkIndex,
                session.getCompletedChunks(),
                session.getTotalChunks(),
                session.isComplete()
        );
    }

    public void assembleFile(String sessionId) {
        ChunkedUploadSession session = sessions.get(sessionId);
        Path assembledPath = Path.of("/tmp/assembled", session.getFilename());

        try (OutputStream out = Files.newOutputStream(assembledPath)) {
            for (int i = 0; i < session.getTotalChunks(); i++) {
                Path chunkPath = Path.of("/tmp/chunks", sessionId,
                        String.format("chunk_%05d", i));
                Files.copy(chunkPath, out);
            }
        } catch (IOException e) {
            throw new StorageException("Failed to assemble file", e);
        }

        // Clean up chunks
        cleanupChunks(sessionId);
        sessions.remove(sessionId);
    }
}

Amazon S3 Integration ​

Local file storage does not scale. With multiple application instances behind a load balancer, files uploaded to one server are invisible to others. Amazon S3 (or any S3-compatible storage like MinIO) is the standard solution.

Dependencies and Configuration ​

xml
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>s3</artifactId>
</dependency>
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>s3-transfer-manager</artifactId>
</dependency>
yaml
aws:
  s3:
    bucket: my-app-uploads
    region: us-east-1
    prefix: uploads/
  credentials:
    access-key: ${AWS_ACCESS_KEY_ID}
    secret-key: ${AWS_SECRET_ACCESS_KEY}

S3 Storage Service ​

java
@Service
public class S3StorageService implements FileStorageService {

    private final S3Client s3Client;
    private final S3Presigner presigner;
    private final String bucket;
    private final String prefix;

    public S3StorageService(S3Client s3Client, S3Presigner presigner,
                            @Value("${aws.s3.bucket}") String bucket,
                            @Value("${aws.s3.prefix}") String prefix) {
        this.s3Client = s3Client;
        this.presigner = presigner;
        this.bucket = bucket;
        this.prefix = prefix;
    }

    @Override
    public FileMetadata store(MultipartFile file, String uploadedBy) {
        String key = generateKey(file.getOriginalFilename());

        Map<String, String> metadata = Map.of(
                "uploaded-by", uploadedBy,
                "original-filename", file.getOriginalFilename(),
                "upload-timestamp", Instant.now().toString()
        );

        try {
            PutObjectRequest request = PutObjectRequest.builder()
                    .bucket(bucket)
                    .key(key)
                    .contentType(file.getContentType())
                    .contentLength(file.getSize())
                    .metadata(metadata)
                    .serverSideEncryption(ServerSideEncryption.AES256)
                    .build();

            s3Client.putObject(request,
                    RequestBody.fromInputStream(file.getInputStream(), file.getSize()));

            return new FileMetadata(
                    key,
                    file.getOriginalFilename(),
                    file.getContentType(),
                    file.getSize(),
                    generateDownloadUrl(key)
            );
        } catch (IOException e) {
            throw new StorageException("Failed to upload to S3", e);
        }
    }

    @Override
    public InputStream retrieve(String key) {
        GetObjectRequest request = GetObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build();
        return s3Client.getObject(request);
    }

    @Override
    public void delete(String key) {
        s3Client.deleteObject(DeleteObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build());
    }

    private String generateKey(String originalFilename) {
        String extension = getExtension(originalFilename);
        String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
        return prefix + date + "/" + UUID.randomUUID() + "." + extension;
    }

    private String generateDownloadUrl(String key) {
        return "/api/files/download/" + URLEncoder.encode(key, StandardCharsets.UTF_8);
    }
}

Presigned URLs ​

Presigned URLs let clients upload directly to S3, bypassing your application server entirely. This eliminates the upload bottleneck and bandwidth cost on your server:

┌──────────┐                    ┌──────────────┐                ┌─────┐
│  Client   │ ──1. Request ───→ │  App Server   │                │ S3  │
│           │ ←─2. Presigned ── │               │                │     │
│           │        URL        │               │                │     │
│           │ ──3. PUT file ────────────────────────────────────→│     │
│           │ ←─4. 200 OK ──────────────────────────────────────│     │
│           │ ──5. Confirm ───→ │               │                │     │
└──────────┘                    └──────────────┘                └─────┘
java
@PostMapping("/upload/presigned")
public ResponseEntity<PresignedUploadResponse> getPresignedUrl(
        @RequestBody PresignedUploadRequest request,
        @AuthenticationPrincipal UserDetails user) {

    String key = generateKey(request.getFilename());

    PutObjectRequest objectRequest = PutObjectRequest.builder()
            .bucket(bucket)
            .key(key)
            .contentType(request.getContentType())
            .metadata(Map.of("uploaded-by", user.getUsername()))
            .build();

    PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
            .signatureDuration(Duration.ofMinutes(15))
            .putObjectRequest(objectRequest)
            .build();

    PresignedPutObjectRequest presigned = presigner.presignPutObject(presignRequest);

    return ResponseEntity.ok(new PresignedUploadResponse(
            presigned.url().toString(),
            key,
            presigned.expiration()
    ));
}

// Download with presigned URL
@GetMapping("/download/presigned/{key}")
public ResponseEntity<PresignedDownloadResponse> getPresignedDownloadUrl(
        @PathVariable String key) {

    GetObjectRequest getRequest = GetObjectRequest.builder()
            .bucket(bucket)
            .key(key)
            .build();

    GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
            .signatureDuration(Duration.ofHours(1))
            .getObjectRequest(getRequest)
            .build();

    PresignedGetObjectRequest presigned = presigner.presignGetObject(presignRequest);

    return ResponseEntity.ok(new PresignedDownloadResponse(
            presigned.url().toString(),
            presigned.expiration()
    ));
}

Image Processing ​

Thumbnail Generation ​

java
@Service
public class ImageProcessingService {

    private static final Map<String, int[]> THUMBNAIL_SIZES = Map.of(
            "small", new int[]{150, 150},
            "medium", new int[]{400, 400},
            "large", new int[]{800, 800}
    );

    @Async("imageProcessingExecutor")
    public CompletableFuture<Map<String, String>> generateThumbnails(
            String originalKey, InputStream imageStream) {

        try {
            BufferedImage original = ImageIO.read(imageStream);
            if (original == null) {
                throw new ImageProcessingException("Cannot read image");
            }

            Map<String, String> thumbnailKeys = new HashMap<>();

            for (Map.Entry<String, int[]> entry : THUMBNAIL_SIZES.entrySet()) {
                String size = entry.getKey();
                int maxWidth = entry.getValue()[0];
                int maxHeight = entry.getValue()[1];

                BufferedImage thumbnail = resize(original, maxWidth, maxHeight);
                String thumbnailKey = originalKey.replace(".", "_" + size + ".");

                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ImageIO.write(thumbnail, "jpeg", baos);

                s3StorageService.storeBytes(thumbnailKey, baos.toByteArray(),
                        "image/jpeg");
                thumbnailKeys.put(size, thumbnailKey);
            }

            return CompletableFuture.completedFuture(thumbnailKeys);
        } catch (IOException e) {
            return CompletableFuture.failedFuture(
                    new ImageProcessingException("Thumbnail generation failed", e));
        }
    }

    private BufferedImage resize(BufferedImage original, int maxWidth, int maxHeight) {
        int width = original.getWidth();
        int height = original.getHeight();
        double ratio = Math.min(
                (double) maxWidth / width,
                (double) maxHeight / height
        );

        if (ratio >= 1.0) return original; // Don't upscale

        int newWidth = (int) (width * ratio);
        int newHeight = (int) (height * ratio);

        BufferedImage resized = new BufferedImage(newWidth, newHeight,
                BufferedImage.TYPE_INT_RGB);
        Graphics2D g = resized.createGraphics();
        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
                RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        g.drawImage(original, 0, 0, newWidth, newHeight, null);
        g.dispose();

        return resized;
    }
}

EXIF Stripping and Auto-Rotation ​

java
public BufferedImage stripExifAndAutoRotate(InputStream imageStream) throws IOException {
    Metadata metadata = ImageMetadataReader.readMetadata(imageStream);
    ExifIFD0Directory exifDir = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);

    BufferedImage image = ImageIO.read(imageStream);

    if (exifDir != null && exifDir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
        int orientation = exifDir.getInt(ExifIFD0Directory.TAG_ORIENTATION);
        image = applyOrientation(image, orientation);
    }

    return image;
}

private BufferedImage applyOrientation(BufferedImage image, int orientation) {
    return switch (orientation) {
        case 3 -> rotate(image, 180);
        case 6 -> rotate(image, 90);
        case 8 -> rotate(image, 270);
        default -> image;
    };
}

Virus Scanning with ClamAV ​

Every file upload from untrusted users should be scanned for malware. ClamAV is the standard open-source solution:

java
@Service
public class VirusScanService {

    private final String clamavHost;
    private final int clamavPort;

    public VirusScanService(
            @Value("${clamav.host:localhost}") String clamavHost,
            @Value("${clamav.port:3310}") int clamavPort) {
        this.clamavHost = clamavHost;
        this.clamavPort = clamavPort;
    }

    /**
     * Scans a file using ClamAV's INSTREAM protocol.
     * Returns true if the file is clean.
     */
    public ScanResult scan(InputStream fileStream) {
        try (Socket socket = new Socket(clamavHost, clamavPort);
             OutputStream out = socket.getOutputStream();
             InputStream in = socket.getInputStream()) {

            // Send INSTREAM command
            out.write("zINSTREAM\0".getBytes());

            // Send file in chunks
            byte[] buffer = new byte[8192];
            int bytesRead;
            while ((bytesRead = fileStream.read(buffer)) != -1) {
                // Each chunk: 4-byte length prefix (big-endian) + data
                out.write(intToBytes(bytesRead));
                out.write(buffer, 0, bytesRead);
            }

            // End of stream marker: zero-length chunk
            out.write(intToBytes(0));
            out.flush();

            // Read response
            String response = new String(in.readAllBytes()).trim();
            boolean clean = response.endsWith("OK");

            return new ScanResult(clean, response);
        } catch (IOException e) {
            log.error("ClamAV scan failed", e);
            throw new VirusScanException("Virus scan unavailable", e);
        }
    }

    private byte[] intToBytes(int value) {
        return ByteBuffer.allocate(4).putInt(value).array();
    }
}

Integrating Scanning into the Upload Pipeline ​

java
@Service
public class SecureFileUploadService {

    private final FileValidator validator;
    private final VirusScanService virusScanner;
    private final S3StorageService storageService;
    private final ImageProcessingService imageProcessor;

    /**
     * Full upload pipeline: validate → scan → store → process
     */
    @Transactional
    public FileMetadata upload(MultipartFile file, String userId) {
        // Step 1: Validate file type, size, magic bytes
        validator.validate(file);

        // Step 2: Virus scan
        try {
            ScanResult scanResult = virusScanner.scan(file.getInputStream());
            if (!scanResult.isClean()) {
                log.warn("Malware detected in upload from user {}: {}",
                        userId, scanResult.getDetails());
                throw new MalwareDetectedException(
                        "File rejected: malware detected");
            }
        } catch (IOException e) {
            throw new StorageException("Cannot read file for scanning", e);
        }

        // Step 3: Store original
        FileMetadata metadata = storageService.store(file, userId);

        // Step 4: Async post-processing (thumbnails, etc.)
        if (metadata.getContentType().startsWith("image/")) {
            try {
                imageProcessor.generateThumbnails(
                        metadata.getStorageKey(),
                        file.getInputStream());
            } catch (IOException e) {
                log.warn("Thumbnail generation failed for {}", metadata.getId(), e);
            }
        }

        return metadata;
    }
}

Download Endpoint with Range Support ​

Support resumable downloads and video streaming with HTTP Range headers:

java
@GetMapping("/download/{*key}")
public ResponseEntity<StreamingResponseBody> download(
        @PathVariable String key,
        @RequestHeader(value = "Range", required = false) String rangeHeader) {

    FileMetadata metadata = storageService.getMetadata(key);

    if (rangeHeader != null) {
        return handleRangeRequest(key, metadata, rangeHeader);
    }

    StreamingResponseBody body = outputStream -> {
        try (InputStream input = storageService.retrieve(key)) {
            input.transferTo(outputStream);
        }
    };

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    "attachment; filename=\"" + metadata.getOriginalFilename() + "\"")
            .contentType(MediaType.parseMediaType(metadata.getContentType()))
            .contentLength(metadata.getSize())
            .body(body);
}

private ResponseEntity<StreamingResponseBody> handleRangeRequest(
        String key, FileMetadata metadata, String rangeHeader) {

    long fileSize = metadata.getSize();
    long[] range = parseRange(rangeHeader, fileSize);
    long start = range[0];
    long end = range[1];
    long contentLength = end - start + 1;

    StreamingResponseBody body = outputStream -> {
        try (InputStream input = storageService.retrieveRange(key, start, end)) {
            input.transferTo(outputStream);
        }
    };

    return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
            .header(HttpHeaders.CONTENT_RANGE,
                    "bytes " + start + "-" + end + "/" + fileSize)
            .header(HttpHeaders.ACCEPT_RANGES, "bytes")
            .contentType(MediaType.parseMediaType(metadata.getContentType()))
            .contentLength(contentLength)
            .body(body);
}

Production Checklist ​

ConcernRecommendation
StorageUse S3 or S3-compatible (MinIO, GCS) — never local filesystem in multi-instance deployments
Size limitsSet max-file-size and max-request-size in config; validate in code too
File type validationCheck magic bytes, not just Content-Type header or extension
Virus scanningClamAV for all user-uploaded files
Filename sanitizationStrip path components, generate UUID-based storage keys
Access controlPresigned URLs with short TTL; never expose S3 keys directly
EncryptionS3 server-side encryption (SSE-S3 or SSE-KMS); TLS in transit
CleanupLifecycle policies to delete orphaned files; scheduled cleanup jobs
MonitoringTrack upload sizes, failure rates, processing times, storage usage
CDNCloudFront in front of S3 for downloads; invalidation on delete

File uploads are a surface area for security vulnerabilities, performance problems, and operational headaches. Validate aggressively, scan for malware, store externally, process asynchronously, and serve through a CDN. The upload endpoint itself should do as little work as possible — accept the file, validate it, hand it off, and return immediately.

"What I cannot create, I do not understand." — Richard Feynman