Have you ever found yourself stuck when trying to implement file upload and download in a Spring Boot REST API, wondering “How do I use MultipartFile?” or “How do I specify where to save files?”

This article walks through the full implementation — receiving, saving, and downloading files — with code examples.

What Is MultipartFile?

MultipartFile is an interface that Spring MVC creates when it receives a request in multipart/form-data format.

Here are the commonly used methods:

MethodDescription
getOriginalFilename()The filename sent by the client
getContentType()MIME type (e.g., image/jpeg)
getSize()File size in bytes
transferTo(Path)Writes the file to the specified path
getInputStream()Retrieves the file content as a stream

transferTo() is the simplest way to save a file, but combining it with Files.copy() gives you more flexible option handling.

Dependencies and Basic Configuration

No additional dependencies are required since this is included in spring-boot-starter-web.

Configure file size limits in application.properties:

# Per-file limit (default: 1MB)
spring.servlet.multipart.max-file-size=10MB
# Total request limit (default: 10MB)
spring.servlet.multipart.max-request-size=10MB

The defaults of 1MB / 10MB are quite small, so you will need to tune these for production based on your requirements.

Implementing the Upload Endpoint

The implementation of validateFileType() is covered in the next section, and sanitizeFilename() is explained right after that.

@RestController
@RequestMapping("/api")
public class FileController {

    @Value("${app.upload-dir:/tmp/uploads}")
    private String uploadDir;

    @PostMapping("/upload")
    public ResponseEntity<Map<String, String>> upload(
            @RequestParam("file") MultipartFile file) {

        if (file.isEmpty()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "ファイルが空です"));
        }

        validateFileType(file);

        try {
            String filename = sanitizeFilename(file.getOriginalFilename());
            Path dest = Paths.get(uploadDir).resolve(filename);
            Files.createDirectories(dest.getParent());
            Files.copy(file.getInputStream(), dest, StandardCopyOption.REPLACE_EXISTING);
            return ResponseEntity.ok(Map.of("filename", filename));
        } catch (IOException e) {
            return ResponseEntity.internalServerError()
                    .body(Map.of("error", "ファイルの保存に失敗しました"));
        }
    }
}

Retrieve the upload directory via @Value from properties rather than hardcoding it in your source code.

MIME Type and Extension Validation

The Content-Type sent by a client can be spoofed. It is safer to use an allowlist and validate both the MIME type and the file extension together.

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

private void validateFileType(MultipartFile file) {
    String contentType = file.getContentType();
    if (contentType == null || !ALLOWED_TYPES.contains(contentType)) {
        throw new InvalidFileTypeException("許可されていないファイル形式です");
    }
    String name = file.getOriginalFilename();
    // Explicitly reject null or names with no extension
    if (name == null || !name.contains(".")) {
        throw new InvalidFileTypeException("拡張子が不正です");
    }
    String ext = name.substring(name.lastIndexOf('.')).toLowerCase();
    if (!ALLOWED_EXTENSIONS.contains(ext)) {
        throw new InvalidFileTypeException("許可されていない拡張子です");
    }
}

For more details on validation, see Validation in Spring Boot.

Sanitizing Filenames

private String sanitizeFilename(String original) {
    if (original == null || original.isBlank()) return "unnamed";
    // Strip path separators and extract just the filename
    Path fn = Paths.get(original).getFileName();
    // Reject names that are only dots, like "." or ".."
    if (fn == null || fn.toString().matches("^\\.+$")) return "unnamed";
    String sanitized = fn.toString().replaceAll("[^a-zA-Z0-9._-]", "_");
    // Verify the normalized path stays within the base directory (path traversal protection)
    Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
    Path resolved = baseDir.resolve(sanitized).toAbsolutePath().normalize();
    if (!resolved.startsWith(baseDir)) return "unnamed";
    return sanitized;
}

Note that filenames containing Japanese characters will be replaced with underscores by the regex. Since different Japanese filenames can collide to the same sanitized name and be silently overwritten due to REPLACE_EXISTING, renaming files using UUIDs or sequential numbers is strongly recommended in production.

Exception Handling Setup

public class InvalidFileTypeException extends RuntimeException {
    public InvalidFileTypeException(String message) { super(message); }
}
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity<Map<String, String>> handleMaxUploadSize(
            MaxUploadSizeExceededException e) {
        return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
                .body(Map.of("error", "ファイルサイズが上限を超えています"));
    }

    @ExceptionHandler(InvalidFileTypeException.class)
    public ResponseEntity<Map<String, String>> handleInvalidFileType(
            InvalidFileTypeException e) {
        return ResponseEntity.badRequest()
                .body(Map.of("error", e.getMessage()));
    }
}

MaxUploadSizeExceededException is thrown before the request reaches your controller, so it must be caught in a @ControllerAdvice. For a full overview of exception handling design, see Exception Handling for Spring Boot REST APIs.

Implementing the Download Endpoint

@GetMapping("/download/{filename}")
public ResponseEntity<?> download(@PathVariable String filename) {
    try {
        String safeFilename = sanitizeFilename(filename);
        Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
        Path filePath = baseDir.resolve(safeFilename).toAbsolutePath().normalize();

        if (!filePath.startsWith(baseDir)) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "無効なファイル名です"));
        }
        if (!Files.exists(filePath)) {
            return ResponseEntity.notFound().build();
        }

        Resource resource = new FileSystemResource(filePath);
        String contentType = Files.probeContentType(filePath);
        if (contentType == null) contentType = "application/octet-stream";

        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(contentType))
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.attachment()
                                .filename(safeFilename, StandardCharsets.UTF_8)
                                .build().toString())
                .body(resource);
    } catch (IOException e) {
        return ResponseEntity.internalServerError()
                .body(Map.of("error", "ファイルの読み取りに失敗しました"));
    }
}

Rather than declaring throws IOException, exceptions are caught with try-catch and returned as error responses. The structure mirrors the upload endpoint, keeping error handling consistent throughout.

Testing with curl

# Upload (the -F option automatically sets Content-Type: multipart/form-data)
curl -F "[email protected]" http://localhost:8080/api/upload

# Download
curl -O http://localhost:8080/api/download/sample.pdf

When calling from other clients such as axios, using a FormData object ensures headers are set correctly. For Swagger UI configuration, refer to Spring Boot OpenAPI & Swagger UI Integration.

Production Considerations

In a multi-instance setup, saving to local disk will not work as-is, since files cannot be shared across instances. Consider migrating to object storage such as S3.

If you need access control on the download endpoint, integrate it with Spring Security. For cases where CORS configuration is required, also refer to CORS Configuration in Spring Boot.

Summary

  • Files received via MultipartFile can be saved using Files.copy()
  • Sanitize filenames thoroughly, including a startsWith check after normalization, to guard against path traversal attacks
  • Configure size limits in application.properties and handle exceeded limits with @ControllerAdvice
  • Implement downloads with ResponseEntity<?> and Content-Disposition: attachment, using try-catch for consistent exception handling

The code in this article is structured so you can run it immediately in local development. Before applying it to production, review your filename strategy and storage design carefully.