Spring Boot – File Upload

July 5, 20266 min readUpdated 8/18/2026

Accepting a file is four lines of Spring. Accepting a file safely is the rest of this lesson, because an upload endpoint takes a file chosen by a stranger and writes it to your disk.

The endpoint

@Operation(summary = "Upload an image for a product")
@PostMapping(value = "/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ProductDTO> uploadImage(
        @PathVariable UUID id, @RequestPart("file") MultipartFile file) {
    log.info("POST /api/admin/products/{}/image ({} bytes)", id, file.getSize());
    return new ResponseEntity<>(productService.setProductImage(id, file), OK);
}

Three things make this a multipart endpoint rather than a normal one:

  • consumes = MULTIPART_FORM_DATA_VALUE — without it the request is matched against the JSON handlers and rejected as an unsupported media type.
  • @RequestPart, not @RequestBody — it binds one named part of the body.
  • The part name matters. "file" must match what the client puts in its FormData; a mismatch is a 400 naming the missing part.
const body = new FormData();
body.append('file', selectedFile);          // <- must match @RequestPart("file")

await fetch(`/api/admin/products/${id}/image`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}` },
  body,                                      // do NOT set Content-Type yourself
});

Never set Content-Type by hand for a multipart request. The browser has to generate it, because it includes a boundary token it just invented. Setting it manually produces a boundary-less header and the server cannot parse the body.

Two size limits, and they are not redundant

# The application's rule — produces a friendly 400.
pizza.storage.upload-dir=./uploads/products
pizza.storage.max-image-bytes=2097152

# The servlet-level backstop. It must be LARGER than pizza.storage.max-image-bytes,
# because a request killed here never reaches the controller and cannot produce a
# friendly error. max-request-size covers the whole multipart body, not one part -
# raising only max-file-size still rejects a large upload, which is a confusing hour
# to spend the first time it happens.
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=10MB

The servlet limit is enforced while the request is still being parsed and produces a raw MaxUploadSizeExceededException. The application limit produces a normal 400 with a message a user can act on. Keep the application limit lower so it is the one that normally fires.

⚠️ Three things you must not trust

The upload arrives with a filename, a declared content type and some bytes. Only one of those is evidence.

1. The filename is not a name, it is a path

../../../etc/passwd resolved against your upload directory writes outside your upload directory. The pizza API's defence is to discard the client's name entirely:

// The browser's filename is used for NOTHING except this log line. The stored name is a
// UUID we generate, which makes path traversal and filename collisions both impossible.
String original = StringUtils.cleanPath(
        file.getOriginalFilename() == null ? "unnamed" : file.getOriginalFilename());
String storedName = UUID.randomUUID() + "." + format;

Path target = uploadRoot.resolve(storedName).normalize();
if (!target.getParent().equals(uploadRoot)) {
    throw ApiException.badRequest("Invalid file name");
}

Generating the name solves two problems at once: traversal becomes impossible, and so do collisions between two customers who both uploaded photo.jpg.

The getParent().equals(uploadRoot) check is belt and braces here — the name is a UUID we just generated — but it is one line and one refactor away from being necessary. Where it genuinely matters is the read path, since that name comes from a URL:

// fileName reaches us from a path variable, so here the traversal risk is real rather than
// theoretical. Strip it to a bare name first, then verify where it resolved to.
String safeName = Path.of(StringUtils.cleanPath(fileName)).getFileName().toString();
Path target = uploadRoot.resolve(safeName).normalize();

if (!target.getParent().equals(uploadRoot)) {
    throw ApiException.notFound("Image", fileName);
}

2. The declared content type is a claim

Content-Type is set by the client. Checking it stops honest mistakes and nothing else — an attacker simply sends image/png with whatever bytes they like. The extension is no better.

3. The bytes are the only evidence

Read the leading bytes — the "magic number" — and require them to match a real image format:

private static final Map<String, byte[]> MAGIC_NUMBERS = Map.of(
        "jpg",  new byte[] {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
        "png",  new byte[] {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A},
        "gif",  new byte[] {'G', 'I', 'F', '8'},
        "webp", new byte[] {'R', 'I', 'F', 'F'});

private String detectImageFormat(MultipartFile file) {
    byte[] head = new byte[12];
    try (InputStream in = file.getInputStream()) {
        int read = in.read(head);
        if (read < 4) {
            throw ApiException.badRequest("File is too small to be an image");
        }
    } catch (IOException ex) {
        throw ApiException.badRequest("Could not read the uploaded file");
    }

    for (Map.Entry<String, byte[]> entry : MAGIC_NUMBERS.entrySet()) {
        if (startsWith(head, entry.getValue())) {
            return entry.getKey();
        }
    }

    throw ApiException.badRequest("Only JPEG, PNG, GIF and WebP images are accepted");
}

Note that it returns the detected format and that value becomes the stored extension. The file is named after what it actually is, never after what it claimed to be.

Two more rules worth stating plainly: never store uploads inside your web root, and never serve them from a path that could execute them. An "image" upload that lands somewhere the server will happily interpret is how a file upload becomes remote code execution.

Storing it

@PostConstruct
void init() throws IOException {
    // normalize() collapses any ".." in the CONFIGURED path; toAbsolutePath() gives us a fixed
    // root to compare against later.
    this.uploadRoot = Path.of(properties.storage().uploadDir())
            .toAbsolutePath()
            .normalize();
    Files.createDirectories(uploadRoot);
    log.info("Product images are stored in {}", uploadRoot);
}

try (InputStream in = file.getInputStream()) {
    Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
    log.error("Could not store upload {}", original, ex);
    throw ApiException.badRequest("Could not store the uploaded file");
}

Then update the row — and note the ordering:

/**
 * <p>Note the ordering: the file is written BEFORE the row is updated. Do it the other way
 * round and a storage failure leaves the database pointing at an image that does not exist.
 * This way the worst case is an orphaned file, which is a cleanup job rather than a broken
 * menu.
 */
@Transactional
public ProductDTO setProductImage(UUID id, MultipartFile file) {
    Product product = productDAO.findByPublicIdWithSizes(id)
            .orElseThrow(() -> ApiException.notFound("Product", id));

    String storedName = imageStorage.store(file);
    product.setImageUrl(imageStorage.urlFor(storedName));

    return mapper.mapProductToProductDTO(productDAO.save(product));
}

Local disk is the demo choice, not the production one. It does not survive a container restart and is not shared between instances, so a second replica serves 404s for anything the first one stored. Production writes to S3 or another object store.

Streaming it back

@GetMapping("/images/{fileName}")
public ResponseEntity<Resource> getImage(@PathVariable String fileName) {
    Resource image = imageStorage.load(fileName);

    return ResponseEntity.ok()
            .contentType(contentType)
            // The name contains a UUID, so the bytes behind a given URL never change and it is
            // safe to cache hard.
            .cacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic())
            .header("Content-Disposition", "inline; filename=\"" + fileName + "\"")
            .body(image);
}

Return Resource, not byte[]. Spring streams a Resource, so a 2 MB image never sits in heap in its entirety; with byte[] the whole file is buffered per concurrent request, which is fine until it is not.

inline renders in the page; attachment triggers a save dialog. Getting that backwards makes every product photo prompt a download.

What to take from this

  • @RequestPart + consumes = MULTIPART_FORM_DATA_VALUE, and let the browser set its own Content-Type.
  • Two size limits — application below servlet, so the friendly error is the one that fires.
  • Generate the stored filename. Never build a path from client input.
  • Validate the bytes, not the extension or the declared type.
  • Write the file before the row, and stream with Resource.

Next: API docs with springdoc-openapi.