Spring Boot
File Download API
How to serve file downloads in Spring Boot with ResponseEntity<Resource>: attachment vs inline, streaming large files without running out of memory, S3 downloads, range requests, on-the-fly CSV/ZIP exports, and the path-traversal mistakes that cause real security bugs. Includes a decision guide for choosing the right return type.
Which Return Type Should You Use?
Most download bugs come from picking the wrong type.
- Small file on disk or classpath: ResponseEntity<Resource> (FileSystemResource / UrlResource). Spring handles Range headers for you.
- Generated in memory and under a few MB: ByteArrayResource.
- Large file, size known: InputStreamResource with an explicit Content-Length.
- Large or generated output (CSV, ZIP), size unknown: StreamingResponseBody, so nothing is fully buffered.
- File lives in S3 and the client may reach it directly: pre-signed URL. Your server never touches the bytes.
Rule of thumb: if you call Files.readAllBytes() on anything user-driven, you have a memory problem waiting for a big file.
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, and resolve every filename through one helper that blocks path traversal. Both endpoints below share that helper.
Java
@RestController
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor
public class FileDownloadController {
@Value("${app.storage.location:./uploads}")
private String storageLocation;
// ── Single place that resolves and validates paths ────────────────
private Path resolveSafe(String filename) {
Path root = Paths.get(storageLocation).toAbsolutePath().normalize();
Path filePath = root.resolve(filename).normalize();
if (!filePath.startsWith(root)) {
throw new AccessDeniedException("Access denied");
}
return filePath;
}
private Resource loadResource(Path filePath) throws MalformedURLException {
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists() || !resource.isReadable()) {
throw new ResourceNotFoundException("File not found: " + filePath.getFileName());
}
return resource;
}
private String detectContentType(Path filePath) throws IOException {
String contentType = Files.probeContentType(filePath);
return contentType != null ? contentType : MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
// ── Download as attachment (triggers Save As dialog) ──────────────
@GetMapping("/{filename}")
public ResponseEntity<Resource> download(
@PathVariable String filename) throws IOException {
Path filePath = resolveSafe(filename);
Resource resource = loadResource(filePath);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(detectContentType(filePath)))
.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 = resolveSafe(filename);
Resource resource = loadResource(filePath);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(detectContentType(filePath)))
.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: Spring runs it on an async MVC thread, so the Tomcat request thread is released while bytes are written. Both examples reuse the resolveSafe helper from the previous section.
Java
// ── InputStreamResource — streams without buffering ──────────────────
@GetMapping("/stream/{filename}")
public ResponseEntity<InputStreamResource> stream(
@PathVariable String filename) throws IOException {
Path filePath = resolveSafe(filename);
long fileSize = Files.size(filePath);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(detectContentType(filePath)))
.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 = resolveSafe(filename);
StreamingResponseBody body = outputStream -> {
try (InputStream is = Files.newInputStream(filePath)) {
is.transferTo(outputStream);
}
};
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(detectContentType(filePath)))
.contentLength(Files.size(filePath))
.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 so browsers and download managers can show progress. When the client can reach S3 directly, a pre-signed URL is cheaper, because the bytes never pass through your server. Inject a single S3Presigner bean instead of creating one per request.
Java
@Service
@RequiredArgsConstructor
public class S3DownloadService {
private final S3Client s3Client;
private final S3Presigner presigner; // define once as a @Bean
@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) {
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(expiry)
.getObjectRequest(b -> b.bucket(bucket).key(key))
.build();
return presigner.presignGetObject(presignRequest)
.url().toString();
}
}
@Configuration
class S3PresignerConfig {
@Bean
S3Presigner s3Presigner() {
return S3Presigner.create();
}
}Range Requests (Resumable Downloads)
Range requests let clients download a specific byte range, which is essential for resumable downloads and video streaming. When you return a Resource with status 200, Spring automatically converts it to 206 Partial Content if the request has a Range header, so the download endpoints above already support resuming. For manual control, use ResourceRegion instead of reading bytes into an array, so large ranges are never held in memory. Always advertise Accept-Ranges so clients know resuming is supported.
Java
// ── Automatic range support for static-style file serving ────────────
@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 with ResourceRegion ──────────────────────────
@GetMapping("/range/{filename}")
public ResponseEntity<?> downloadRange(
@PathVariable String filename,
@RequestHeader HttpHeaders headers) throws IOException {
Path filePath = resolveSafe(filename);
Resource resource = loadResource(filePath);
long length = resource.contentLength();
MediaType type = MediaType.parseMediaType(detectContentType(filePath));
List<HttpRange> ranges = headers.getRange();
if (ranges.isEmpty()) {
// No Range header: return the whole file
return ResponseEntity.ok()
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.contentType(type)
.contentLength(length)
.body(resource);
}
HttpRange range = ranges.get(0);
long start = range.getRangeStart(length);
long end = range.getRangeEnd(length);
long chunk = Math.min(1024 * 1024, end - start + 1); // cap at 1 MB per response
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.contentType(type)
.body(new ResourceRegion(resource, start, chunk));
}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 small in-memory content. Two things matter in production: escape CSV values, because a name containing a comma or quote will corrupt the file, and put a limit on how many items one request can ask for.
Java
@RestController
@RequestMapping("/api/v1/exports")
@RequiredArgsConstructor
public class ExportController {
private static final int MAX_ARCHIVE_ITEMS = 50;
private final UserService userService;
private final ReportService reportService;
private String csv(String value) {
if (value == null) return "";
return "\"" + value.replace("\"", "\"\"") + "\"";
}
// ── 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(), csv(user.name()),
csv(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) {
if (ids.isEmpty() || ids.size() > MAX_ARCHIVE_ITEMS) {
throw new IllegalArgumentException(
"Request between 1 and " + MAX_ARCHIVE_ITEMS + " items");
}
// Also verify the caller is allowed to access every id before streaming
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. The safest design is to expose opaque IDs and keep real filenames in a metadata table, as below.
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);
}
}Common Mistakes
1. Trusting the filename. "../../etc/passwd" is the classic. Resolve against an absolute, normalized root and check startsWith. Relative roots like ./uploads break the check unless you call toAbsolutePath() first.
2. Skipping the check on secondary endpoints. A preview or stream endpoint added later often forgets the guard the main endpoint has. Route every endpoint through one helper.
3. Relying on the browser to guess Content-Type. Set it explicitly, with an octet-stream fallback.
4. Reading whole files into byte[]. A few concurrent 500 MB downloads will exhaust the heap.
5. Forgetting Content-Length on InputStreamResource, so browsers show no progress bar.
6. Not closing S3 streams. Let Spring close the InputStreamResource, and never keep the stream in a field.
7. Non-ASCII filenames breaking headers. Use ContentDisposition with filename(name, UTF_8) instead of concatenating the header by hand.
8. StreamingResponseBody timing out. Increase spring.mvc.async.request-timeout and configure a TaskExecutor for MVC async.
9. Unescaped CSV values. Names with commas or quotes silently shift columns.
FAQ
Why does my browser display the file instead of downloading it?
Content-Disposition is inline, or missing with a renderable Content-Type such as PDF. Use attachment.
How do I download a file from a REST API in Spring Boot?
Return ResponseEntity<Resource> with Content-Disposition: attachment and an explicit Content-Type.
How do I stream a large file without OutOfMemoryError?
Return InputStreamResource or StreamingResponseBody. Never use Files.readAllBytes() for user-driven downloads.
Does Spring Boot support resumable downloads?
Yes. Returning a Resource with a 200 status makes Spring answer Range requests with 206 Partial Content automatically.
Should I serve files through my app or straight from S3?
Use pre-signed URLs for large or public files. Proxy through your app only when you need per-request authorization or auditing.