Spring BootFile Download API
Spring Boot

File Download API

Spring Boot serves file downloads through ResponseEntity<Resource>. The Resource abstraction covers local disk files, classpath resources, byte arrays, and S3 objects. This entry covers inline vs attachment delivery, range requests, streaming large files, security, and content-type detection.

Serving a File from Disk

Return ResponseEntity<Resource> with a UrlResource or FileSystemResource pointing to the file path. Set Content-Disposition to attachment to trigger a browser download, or inline to render the file in the browser. Always set Content-Type explicitly — do not rely on the browser to guess it.
Java
@RestController
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor
public class FileDownloadController {

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

    // ── Download as attachment (triggers Save As dialog) ──────────────
    @GetMapping("/{filename}")
    public ResponseEntity<Resource> download(
            @PathVariable String filename) throws IOException {

        Path filePath = Paths.get(storageLocation).resolve(filename).normalize();

        // Security: ensure path stays within storage root
        if (!filePath.startsWith(Paths.get(storageLocation).toAbsolutePath())) {
            throw new AccessDeniedException("Access denied");
        }

        Resource resource = new UrlResource(filePath.toUri());
        if (!resource.exists() || !resource.isReadable()) {
            throw new ResourceNotFoundException("File not found: " + filename);
        }

        String contentType = Files.probeContentType(filePath);
        if (contentType == null) contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;

        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(contentType))
            .contentLength(resource.contentLength())
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    ContentDisposition.attachment()
                        .filename(filename, StandardCharsets.UTF_8)
                        .build().toString())
            .body(resource);
    }

    // ── Inline display (PDF, image rendered in browser) ───────────────
    @GetMapping("/{filename}/preview")
    public ResponseEntity<Resource> preview(
            @PathVariable String filename) throws IOException {

        Path filePath = Paths.get(storageLocation).resolve(filename).normalize();
        Resource resource = new UrlResource(filePath.toUri());

        String contentType = Files.probeContentType(filePath);
        if (contentType == null) contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;

        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(contentType))
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    ContentDisposition.inline()
                        .filename(filename, StandardCharsets.UTF_8)
                        .build().toString())
            .body(resource);
    }
}

Streaming Large Files

For large files, avoid loading the entire content into memory. Return an InputStreamResource and let Spring stream the bytes. For very large or slow downloads, use a StreamingResponseBody to write directly to the servlet output stream on a separate thread without blocking a Tomcat worker.
Java
// ── InputStreamResource — streams without buffering ──────────────────
@GetMapping("/stream/{filename}")
public ResponseEntity<InputStreamResource> stream(
        @PathVariable String filename) throws IOException {

    Path filePath = Paths.get(storageLocation).resolve(filename).normalize();
    long fileSize = Files.size(filePath);
    String contentType = Files.probeContentType(filePath);

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(
            contentType != null ? contentType
                                : MediaType.APPLICATION_OCTET_STREAM_VALUE))
        .contentLength(fileSize)
        .header(HttpHeaders.CONTENT_DISPOSITION,
                ContentDisposition.attachment()
                    .filename(filename, StandardCharsets.UTF_8)
                    .build().toString())
        .body(new InputStreamResource(Files.newInputStream(filePath)));
}

// ── StreamingResponseBody — async, non-blocking large file ───────────
@GetMapping("/async/{filename}")
public ResponseEntity<StreamingResponseBody> streamAsync(
        @PathVariable String filename) throws IOException {

    Path filePath = Paths.get(storageLocation).resolve(filename).normalize();
    String contentType = Files.probeContentType(filePath);

    StreamingResponseBody body = outputStream -> {
        try (InputStream is = Files.newInputStream(filePath)) {
            byte[] buffer = new byte[8192];
            int bytesRead;
            while ((bytesRead = is.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
                outputStream.flush();
            }
        }
    };

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(
            contentType != null ? contentType
                                : MediaType.APPLICATION_OCTET_STREAM_VALUE))
        .header(HttpHeaders.CONTENT_DISPOSITION,
                ContentDisposition.attachment()
                    .filename(filename, StandardCharsets.UTF_8)
                    .build().toString())
        .body(body);
}

Downloading from S3

Streaming from S3 follows the same pattern — obtain an InputStream from the S3 GetObject response and wrap it in an InputStreamResource. Set Content-Length from the object's metadata to allow browsers and download managers to display progress.
Java
@Service
@RequiredArgsConstructor
public class S3DownloadService {

    private final S3Client s3Client;

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

    public ResponseEntity<InputStreamResource> download(String key) {
        try {
            GetObjectRequest request = GetObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build();

            ResponseInputStream<GetObjectResponse> s3Object =
                s3Client.getObject(request);

            GetObjectResponse metadata = s3Object.response();
            String filename = key.substring(key.lastIndexOf('/') + 1);

            return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(
                    metadata.contentType() != null
                        ? metadata.contentType()
                        : MediaType.APPLICATION_OCTET_STREAM_VALUE))
                .contentLength(metadata.contentLength())
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.attachment()
                            .filename(filename, StandardCharsets.UTF_8)
                            .build().toString())
                .body(new InputStreamResource(s3Object));

        } catch (NoSuchKeyException e) {
            throw new ResourceNotFoundException("File not found: " + key);
        }
    }

    // ── Pre-signed URL (redirect client directly to S3) ───────────────
    public String generatePresignedUrl(String key, Duration expiry) {
        try (S3Presigner presigner = S3Presigner.create()) {
            GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
                .signatureDuration(expiry)
                .getObjectRequest(b -> b.bucket(bucket).key(key))
                .build();
            return presigner.presignGetObject(presignRequest)
                            .url().toString();
        }
    }
}

Range Requests (Resumable Downloads)

Range requests let clients download a specific byte range — essential for resumable downloads and video streaming. Spring's ResourceHttpRequestHandler handles Range headers automatically when you serve a FileSystemResource or UrlResource. For manual control, parse the Range header and return 206 Partial Content.
Java
// ── Automatic range support via ResourceHttpRequestHandler ───────────
@Configuration
public class StaticResourceConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/files/**")
            .addResourceLocations("file:./uploads/")
            .resourceChain(true);
        // Spring automatically handles Range requests for these resources
    }
}

// ── Manual range handling in a REST controller ─────────────────────────
@GetMapping("/range/{filename}")
public ResponseEntity<byte[]> downloadRange(
        @PathVariable String filename,
        @RequestHeader(value = HttpHeaders.RANGE, required = false) String rangeHeader)
        throws IOException {

    Path filePath = Paths.get(storageLocation).resolve(filename).normalize();
    long fileSize = Files.size(filePath);

    if (rangeHeader == null) {
        // No Range header — return the full file
        return ResponseEntity.ok()
            .contentLength(fileSize)
            .body(Files.readAllBytes(filePath));
    }

    // Parse "bytes=start-end"
    String[] range = rangeHeader.replace("bytes=", "").split("-");
    long start = Long.parseLong(range[0]);
    long end = range.length > 1 && !range[1].isEmpty()
        ? Long.parseLong(range[1])
        : fileSize - 1;

    long length = end - start + 1;
    byte[] data = new byte[(int) length];

    try (RandomAccessFile raf = new RandomAccessFile(filePath.toFile(), "r")) {
        raf.seek(start);
        raf.readFully(data);
    }

    return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
        .header(HttpHeaders.CONTENT_RANGE,
                "bytes " + start + "-" + end + "/" + fileSize)
        .contentLength(length)
        .body(data);
}

Generating Files On-the-Fly

Some downloads — CSV exports, PDF reports, ZIP archives — are generated at request time rather than retrieved from storage. Build the content in memory for small outputs or stream it for large ones. Use ByteArrayResource for in-memory content.
Java
@RestController
@RequestMapping("/api/v1/exports")
@RequiredArgsConstructor
public class ExportController {

    private final UserService userService;
    private final ReportService reportService;

    // ── CSV export from database query ────────────────────────────────
    @GetMapping(value = "/users.csv",
                produces = "text/csv")
    public ResponseEntity<StreamingResponseBody> exportUsersCsv() {

        StreamingResponseBody body = outputStream -> {
            try (PrintWriter writer = new PrintWriter(
                    new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {

                writer.println("id,name,email,createdAt");
                userService.streamAll().forEach(user ->
                    writer.printf("%d,%s,%s,%s%n",
                        user.id(), user.name(),
                        user.email(), user.createdAt()));
                writer.flush();
            }
        };

        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType("text/csv"))
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    ContentDisposition.attachment()
                        .filename("users-" + LocalDate.now() + ".csv",
                                  StandardCharsets.UTF_8)
                        .build().toString())
            .body(body);
    }

    // ── ZIP archive of multiple files ──────────────────────────────────
    @GetMapping("/archive")
    public ResponseEntity<StreamingResponseBody> downloadArchive(
            @RequestParam List<String> ids) {

        StreamingResponseBody body = outputStream -> {
            try (ZipOutputStream zip = new ZipOutputStream(outputStream)) {
                for (String id : ids) {
                    byte[] content = reportService.generatePdf(id);
                    zip.putNextEntry(new ZipEntry("report-" + id + ".pdf"));
                    zip.write(content);
                    zip.closeEntry();
                }
            }
        };

        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType("application/zip"))
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    ContentDisposition.attachment()
                        .filename("reports.zip", StandardCharsets.UTF_8)
                        .build().toString())
            .body(body);
    }
}

Security and Access Control

Never serve files based on a raw user-supplied path. Always resolve the path against a trusted root and verify the result stays within that root (path traversal check). Enforce authentication and per-file authorization before streaming bytes.
Java
@Service
@RequiredArgsConstructor
public class SecureFileDownloadService {

    private final FileMetadataRepository metadataRepo;

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

    // ── Resolve by opaque ID, not by filename ─────────────────────────
    public ResponseEntity<Resource> download(String fileId,
                                              Authentication auth) throws IOException {
        // 1. Look up metadata by opaque ID
        FileMetadata meta = metadataRepo.findById(fileId)
            .orElseThrow(() -> new ResourceNotFoundException("File not found"));

        // 2. Authorisation check
        if (!meta.ownerId().equals(auth.getName()) &&
                !auth.getAuthorities().contains(
                    new SimpleGrantedAuthority("ROLE_ADMIN"))) {
            throw new AccessDeniedException("You do not have access to this file");
        }

        // 3. Path traversal guard
        Path root = Paths.get(storageRoot).toAbsolutePath().normalize();
        Path filePath = root.resolve(meta.storedFilename()).normalize();
        if (!filePath.startsWith(root)) {
            throw new AccessDeniedException("Invalid file path");
        }

        // 4. Stream the file
        Resource resource = new UrlResource(filePath.toUri());
        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(meta.contentType()))
            .contentLength(resource.contentLength())
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    ContentDisposition.attachment()
                        .filename(meta.originalFilename(), StandardCharsets.UTF_8)
                        .build().toString())
            .body(resource);
    }
}