Spring BootFile Upload API
Spring Boot

File Upload API

Spring Boot handles file uploads through MultipartFile, auto-configured by MultipartAutoConfiguration. This entry covers single and multiple file uploads, size limits, storage strategies (local disk, cloud), validation, async processing, and progress tracking.

Multipart Configuration

MultipartAutoConfiguration is active by default. Configure size limits, temporary storage, and the file-size threshold (above which files are written to disk rather than held in memory) in application.yml. Always set explicit limits — Spring's defaults are generous and can be exploited.
yaml
# application.yml
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 10MB        # per-file limit
      max-request-size: 50MB     # entire multipart request limit
      file-size-threshold: 2KB   # write to disk above this size
      location: /tmp/uploads     # temp directory for disk-backed parts

# ── Programmatic configuration (alternative to yml) ──────────────────
@Configuration
public class MultipartConfig {

    @Bean
    public MultipartConfigElement multipartConfigElement() {
        MultipartConfigFactory factory = new MultipartConfigFactory();
        factory.setMaxFileSize(DataSize.ofMegabytes(10));
        factory.setMaxRequestSize(DataSize.ofMegabytes(50));
        factory.setFileSizeThreshold(DataSize.ofKilobytes(2));
        return factory.createMultipartConfig();
    }
}

Single and Multiple File Upload

Bind an uploaded file with @RequestParam MultipartFile. For multiple files use MultipartFile[] or List<MultipartFile>. Additional form fields arrive as ordinary @RequestParam or @RequestPart parameters alongside the file parts.
Java
@RestController
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor
public class FileUploadController {

    private final FileStorageService storageService;

    // ── Single file ───────────────────────────────────────────────────
    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<FileUploadResponse> upload(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "description", required = false) String description) {

        FileUploadResponse response = storageService.store(file, description);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    // ── Multiple files ────────────────────────────────────────────────
    @PostMapping(value = "/batch",
                 consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<List<FileUploadResponse>> uploadBatch(
            @RequestParam("files") List<MultipartFile> files) {

        List<FileUploadResponse> responses = files.stream()
            .map(f -> storageService.store(f, null))
            .toList();
        return ResponseEntity.status(HttpStatus.CREATED).body(responses);
    }

    // ── File + JSON metadata via @RequestPart ─────────────────────────
    @PostMapping(value = "/with-metadata",
                 consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<FileUploadResponse> uploadWithMetadata(
            @RequestPart("file") MultipartFile file,
            @RequestPart("metadata") @Valid FileMetadataRequest metadata) {

        // metadata is deserialized from its part's JSON body
        FileUploadResponse response = storageService.storeWithMetadata(file, metadata);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
}

// ── Response DTO ──────────────────────────────────────────────────────
public record FileUploadResponse(
    String fileId,
    String filename,
    String contentType,
    long sizeBytes,
    String url,
    Instant uploadedAt
) {}

File Validation

Always validate uploads before storing them. Check file size, content type, and — for security — verify the actual file signature (magic bytes) rather than trusting the MIME type the client reports. Reject empty files and enforce an allowed-extensions allowlist.
Java
@Component
public class FileValidator {

    private static final Set<String> ALLOWED_TYPES = Set.of(
        "image/jpeg", "image/png", "image/gif",
        "application/pdf", "text/plain"
    );

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

    public void validate(MultipartFile file) {
        if (file.isEmpty()) {
            throw new InvalidFileException("File must not be empty");
        }
        if (file.getSize() > MAX_SIZE) {
            throw new InvalidFileException(
                "File exceeds maximum size of 10 MB");
        }

        String contentType = file.getContentType();
        if (contentType == null || !ALLOWED_TYPES.contains(contentType)) {
            throw new InvalidFileException(
                "File type not allowed: " + contentType);
        }

        // Verify magic bytes — don't trust the Content-Type header
        verifyMagicBytes(file, contentType);
    }

    private void verifyMagicBytes(MultipartFile file, String declaredType) {
        try {
            byte[] header = new byte[8];
            file.getInputStream().read(header);
            if (declaredType.equals("image/jpeg") &&
                    (header[0] != (byte) 0xFF || header[1] != (byte) 0xD8)) {
                throw new InvalidFileException("File content does not match declared type");
            }
            if (declaredType.equals("image/png") &&
                    header[0] != (byte) 0x89) {
                throw new InvalidFileException("File content does not match declared type");
            }
            if (declaredType.equals("application/pdf") &&
                    !new String(header, 0, 4).equals("%PDF")) {
                throw new InvalidFileException("File content does not match declared type");
            }
        } catch (IOException e) {
            throw new InvalidFileException("Could not read file content");
        }
    }
}

// ── Use in the service ────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class FileStorageService {

    private final FileValidator validator;

    public FileUploadResponse store(MultipartFile file, String description) {
        validator.validate(file);
        // proceed with storage ...
    }
}

Local Disk Storage

For development or single-instance deployments, store files on local disk under a configurable base path. Generate a UUID filename to prevent path traversal attacks and filename collisions. Never use the original filename directly as a storage path.
Java
@Service
@Slf4j
public class LocalFileStorageService {

    @Value("${app.storage.location:./uploads}")
    private String storageLocation;

    @PostConstruct
    public void init() throws IOException {
        Files.createDirectories(Paths.get(storageLocation));
    }

    public FileUploadResponse store(MultipartFile file) {
        String originalName = StringUtils.cleanPath(
            Objects.requireNonNull(file.getOriginalFilename()));

        // Sanitise: reject path traversal attempts
        if (originalName.contains("..")) {
            throw new InvalidFileException("Filename contains illegal path sequence");
        }

        String extension = getExtension(originalName);
        String storedName = UUID.randomUUID() + "." + extension;  // safe filename
        Path targetPath = Paths.get(storageLocation).resolve(storedName);

        try {
            Files.copy(file.getInputStream(), targetPath,
                       StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            throw new FileStorageException("Failed to store file " + originalName, e);
        }

        log.info("Stored file {} as {}", originalName, storedName);

        return new FileUploadResponse(
            storedName,
            originalName,
            file.getContentType(),
            file.getSize(),
            "/api/v1/files/" + storedName,
            Instant.now()
        );
    }

    private String getExtension(String filename) {
        int dot = filename.lastIndexOf('.');
        return dot > 0 ? filename.substring(dot + 1).toLowerCase() : "bin";
    }
}

Cloud Storage (AWS S3)

For production or multi-instance deployments, store files in object storage. Spring Cloud AWS provides an S3 client that integrates cleanly with Spring Boot's auto-configuration. Upload the stream directly to avoid buffering the entire file in memory.
XML
<!-- pom.xml -->
<dependency>
    <groupId>io.awspring.cloud</groupId>
    <artifactId>spring-cloud-aws-starter-s3</artifactId>
    <version>3.1.0</version>
</dependency>

# application.yml
spring:
  cloud:
    aws:
      s3:
        region: eu-west-1
      credentials:
        access-key: ${AWS_ACCESS_KEY_ID}
        secret-key: ${AWS_SECRET_ACCESS_KEY}
app:
  storage:
    bucket: my-app-uploads

// ── S3 storage service ────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class S3FileStorageService {

    private final S3Client s3Client;

    @Value("${app.storage.bucket}")
    private String bucket;

    public FileUploadResponse store(MultipartFile file) throws IOException {
        String key = "uploads/" + UUID.randomUUID() + "/" +
                     StringUtils.cleanPath(
                         Objects.requireNonNull(file.getOriginalFilename()));

        PutObjectRequest request = PutObjectRequest.builder()
            .bucket(bucket)
            .key(key)
            .contentType(file.getContentType())
            .contentLength(file.getSize())
            .build();

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

        String url = "https://" + bucket + ".s3.amazonaws.com/" + key;
        log.info("Uploaded {} to S3 key {}", file.getOriginalFilename(), key);

        return new FileUploadResponse(
            key,
            file.getOriginalFilename(),
            file.getContentType(),
            file.getSize(),
            url,
            Instant.now()
        );
    }

    public void delete(String key) {
        s3Client.deleteObject(b -> b.bucket(bucket).key(key));
    }
}

Exception Handling for Uploads

File upload errors — oversized files, wrong types, storage failures — need dedicated exception handlers. MaxUploadSizeExceededException is thrown by Spring before the controller is reached, so it must be caught in @RestControllerAdvice.
Java
@RestControllerAdvice
public class FileUploadExceptionHandler {

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity<ErrorResponse> handleMaxSize(
            MaxUploadSizeExceededException ex) {
        return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
            .body(ErrorResponse.of(
                413,
                "Payload Too Large",
                "File size exceeds the maximum allowed limit"));
    }

    @ExceptionHandler(InvalidFileException.class)
    public ResponseEntity<ErrorResponse> handleInvalidFile(
            InvalidFileException ex) {
        return ResponseEntity.badRequest()
            .body(ErrorResponse.of(400, "Invalid File", ex.getMessage()));
    }

    @ExceptionHandler(FileStorageException.class)
    public ResponseEntity<ErrorResponse> handleStorageFailure(
            FileStorageException ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(500, "Storage Error",
                "Failed to store the uploaded file"));
    }

    @ExceptionHandler(MultipartException.class)
    public ResponseEntity<ErrorResponse> handleMultipart(MultipartException ex) {
        return ResponseEntity.badRequest()
            .body(ErrorResponse.of(400, "Bad Request",
                "Invalid multipart request: " + ex.getMessage()));
    }
}