Streaming Files Securely with Quarkus
A practical guide to scalable, secure file delivery using databases, S3, and reactive Java.
In enterprise applications, file downloads are a core feature: users may request invoices, export reports, access images, download media, or retrieve archived data. While the use case seems straightforward, the implementation demands attention to performance, scalability, and security.
This article walks through how to implement secure, scalable file streaming in Quarkus, covering both database and object store backends. You'll learn to stream large files without crashing your app, protect file access, set correct HTTP headers, and deal with practical edge cases like backpressure and content sniffing.
Why Stream Files?
Imagine you let users download large files, say 500 MB reports. If your service buffers the entire file in memory before sending it, you're asking for trouble. One request might succeed. Ten might not. A hundred will likely bring down your server.
Streaming solves this by sending the file to the client in chunks as it's being read from storage. You don’t hold the entire file in memory, and clients start receiving data immediately, improving perceived performance.
Benefits:
Efficient memory usage: Only a small buffer is needed at a time.
Responsiveness: Users see the download begin immediately.
Scalability: Your server can serve many large files simultaneously without blowing up.
Where Are Your Files?
Before you can stream files securely, you need to decide where and how those files are stored. Broadly speaking, files in enterprise applications can be stored in one of several places:
Database (e.g., PostgreSQL, MySQL)
This is a straightforward approach: you store the file's binary content in a BLOB (BYTEA
, LONGBLOB
, etc.), along with metadata like filename, content type, and owner.
Pros:
Transactional consistency with related business data
Simple to manage via database backups and migrations
Easy to enforce access control with standard queries
Cons:
Not designed for large binary payloads
Can significantly increase backup and restore times
May cause memory pressure if entire blobs are loaded into the heap
This works best for files that are relatively small (under 10–20MB), infrequently accessed, and closely tied to transactional workflows (e.g., signed PDFs, generated invoices).
Object Storage (e.g., Amazon S3, MinIO, Azure Blob)
Object stores are purpose-built for serving large files and scale efficiently across cloud-native workloads. Files are stored and retrieved via APIs, and you typically persist only metadata and the file’s object key in your application database.
Pros:
Optimized for large files and streaming
Decouples file storage from your app infrastructure
Scalable, cost-effective, and easy to integrate with CDNs
Pre-signed URLs allow for secure, delegated downloads
Cons:
Requires proper configuration of buckets, policies, and SDKs
Adds some operational complexity (latency, availability, consistency)
Access control must be explicitly enforced or delegated
This is the most common pattern for large-scale or public-facing applications, such as customer portals, video delivery platforms, or reporting systems.
Filesystem (local or mounted volumes)
Some applications store uploaded files directly on the local disk or a mounted volume inside the container or VM. This could be a shared directory, an NFS volume, or a bind mount.
Pros:
Simple implementation with standard Java I/O
Fast local read/write access
Good fit for single-node, legacy, or internal systems
Cons:
Not suitable for stateless or autoscaled environments
Doesn’t scale across pods/containers in Kubernetes unless using shared storage
Backup, migration, and disaster recovery are harder to manage
In containerized environments, this approach can be risky. Files stored in a pod’s container filesystem are ephemeral—they’re lost if the pod restarts. If you go this route, use persistent volumes (PVs) or external mounts like EFS, Ceph, or cloud-native file services.
Temporary Storage (e.g., /tmp, memory-mapped files)
Sometimes files don’t need to be persisted long-term. You might generate files on the fly (e.g., CSV exports or ZIP archives), stream them once, and discard them.
Pros:
Fast and isolated
No external storage integration required
Ideal for one-off downloads or transient assets
Cons:
Ephemeral—must be cleaned up proactively
Limited by available disk or memory
Still requires safe streaming and proper access control
Quarkus can stream directly from a temp file using InputStream
—just remember to delete it afterward.
Quarkus Setup
One of the reasons Quarkus shines for building file-heavy backend services is its strong focus on developer productivity and sensible defaults. Whether you're exposing REST endpoints, integrating with AWS services, or dealing with databases and security, Quarkus provides first-class support with minimal boilerplate.
Need to stream a file from a database? Panache makes it easy. Want to upload or download files from S3 or MinIO? Quarkus has built-in extensions that configure the SDK and authentication for you. Need to enforce security or protect endpoints? Just annotate and go.
The Quarkus ecosystem includes:
Reactive and imperative REST with RESTEasy Reactive
Secure endpoints with Quarkus Security and Identity management
Amazon S3 integration via the
quarkus-amazon-s3
extensionORM made simple with Panache and Hibernate
Blazing fast startup and live reload for rapid development
In this section, you’ll add the required dependencies for file streaming from both databases and object stores, and configure your development or staging environment with just a few lines in application.properties
.
Quarkus helps you focus on solving the business problem—not on wiring libraries together.
Here’s what to add to your pom.xml
:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-amazon-s3</artifactId>
</dependency>
Configuring S3 or MinIO
quarkus.s3.endpoint-override=http://localhost:9000
quarkus.s3.aws.region=us-east-1
quarkus.s3.aws.credentials.type=static
quarkus.s3.aws.credentials.static-provider.access-key-id=minio
quarkus.s3.aws.credentials.static-provider.secret-access-key=minio123
This setup assumes a local MinIO instance on port 9000. You can also use the MinIO DevService and only specify the bucket name instead of all the S3 details:
quarkus.s3.devservices.buckets=eisele.largefiles
File Streaming from a Database
Storing files directly in a relational database might seem old-fashioned, but it remains a common choice in many enterprise systems. Especially those where files are tightly coupled to structured business data. Think of use cases like medical records, signed legal contracts, employee attachments, or invoice PDFs. These files often come with rich metadata, strict ownership rules, audit requirements, and transactional consistency needs.
For example, when a user submits a form and uploads a document, storing both the metadata and the binary content in a single transaction guarantees integrity. If the form submission fails, the file isn’t saved either. This is hard to replicate when file storage is external.
From a business perspective, centralizing everything in the database simplifies compliance, reporting, and backup strategies. You can replicate or snapshot the database and know you’re getting a complete picture. It's also easier to enforce fine-grained access control through SQL-level joins and filters.
In this section, I’ll implement a streaming endpoint that delivers a file stored as a BLOB in the database to the end user, along with key security and performance considerations.
Entity Model
@Entity
public class FileEntity extends PanacheEntity {
public String filename;
public String contentType;
public byte[] content;
public String ownerId;
}
Streaming Endpoint
@GET
@Path("/{id}")
@Produces(MediaType.APPLICATION_OCTET_STREAM)
public Response downloadFile(@PathParam("id") Long id) {
FileEntity file = FileEntity.findById(id);
if (file == null || !file.ownerId.equals(identity.getPrincipal().getName())) {
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
String safeFilename = file.filename.replaceAll("[^a-zA-Z0-9_.-]", "_");
StreamingOutput stream = output -> {
try (InputStream in = new ByteArrayInputStream(file.content)) {
in.transferTo(output);
}
};
return Response.ok(stream)
.header("Content-Disposition", "attachment; filename=\"" + safeFilename + "\"")
.header("Content-Type", file.contentType)
.header("X-Content-Type-Options", "nosniff")
.build();
}
Caveat
While StreamingOutput
writes to the response in chunks, the file is still fully loaded into memory as a byte[]
. This does not scale for large files.
Real streaming from DB requires using java.sql.Blob.getBinaryStream()
or Hibernate's LobHelper
. This is more verbose and bypasses Panache’s convenience methods but is essential for streaming truly large files.
In case you are working with Multipart file uploads you may also want to adjust the following settings in your applications.properties
quarkus.http.limits.max-body-size=150M
quarkus.http.limits.max-form-attribute-size=150M
File Streaming from Object Storage
As applications grow and file sizes increase, object storage becomes the go-to solution for scalable, durable, and cost-efficient file management. Whether you're serving high-resolution media, customer-generated uploads, export archives, or large reports, systems like Amazon S3, MinIO, or Azure Blob Storage are purpose-built for this workload.
From an architecture standpoint, object storage decouples file management from your core application and database. This makes it easier to scale, offload storage I/O, and support high-throughput scenarios like batch exports or global content delivery. Files are stored as objects with unique keys, and metadata is usually persisted in your application database, enabling fast lookups and flexible filtering.
From a business perspective, this model allows teams to manage files at scale without bloating database storage, slowing down backups, or increasing operational risk. Object stores also provide features like versioning, lifecycle policies, access logs, and integrations with CDNs—all of which are valuable for security and cost optimization.
In this section, I’ll show you how to upload files to an S3-compatible store, store metadata in the database, and stream files back to the user using Quarkus, all while enforcing access control and keeping memory usage low.
Simple Entity
@Entity
public class FileMetadata extends PanacheEntity {
public String filename;
public String contentType;
public String s3Key;
public String ownerId;
}
Uploading to S3
@Inject S3Client s3Client;
@POST
@Path("/s3/upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Transactional
public Response uploadToS3(@MultipartForm FileUploadForm form) {
String s3Key = UUID.randomUUID().toString();
try (InputStream input = Files.newInputStream(form.file.filePath())) {
s3Client.putObject(
PutObjectRequest.builder()
.bucket("files")
.key(s3Key)
.contentType(form.file.mimeType())
.contentDisposition("attachment; filename=\"" + form.file.fileName() + "\"")
.build(),
RequestBody.fromInputStream(input, form.file.size())
);
} catch (IOException e) {
throw new WebApplicationException("Upload failed", Response.Status.INTERNAL_SERVER_ERROR, e);
}
FileMetadata meta = new FileMetadata();
meta.filename = form.file.fileName();
meta.contentType = form.file.mimeType();
meta.s3Key = s3Key;
meta.ownerId = identity.getPrincipal().getName();
meta.persist();
return Response.status(Response.Status.CREATED).build();
}
Downloading from S3
@GET
@Path("/s3/{id}")
@Produces(MediaType.APPLICATION_OCTET_STREAM)
public Response downloadFromS3(@PathParam("id") Long id) {
FileMetadata meta = FileMetadata.findById(id);
if (meta == null || !meta.ownerId.equals(identity.getPrincipal().getName())) {
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
String safeFilename = meta.filename.replaceAll("[^a-zA-Z0-9_.-]", "_");
StreamingOutput stream = output -> {
try (ResponseInputStream<GetObjectResponse> s3Stream =
s3Client.getObject(GetObjectRequest.builder()
.bucket("files")
.key(meta.s3Key)
.build())) {
s3Stream.transferTo(output);
} catch (IOException e) {
throw new WebApplicationException("Download failed", e);
}
};
return Response.ok(stream)
.header("Content-Disposition", "attachment; filename=\"" + safeFilename + "\"")
.header("Content-Type", meta.contentType)
.header("X-Content-Type-Options", "nosniff")
.build();
}
This is true streaming: data flows from S3 to the client without being buffered in your application.
Handling Large Files and Backpressure
When multiple users stream files, your server must handle varying download speeds. What if a user on a slow connection requests a 500MB file? Without backpressure, your server might try to push data faster than the client can receive it, causing memory bloat or crashes.
What is Backpressure?
Backpressure is a flow-control mechanism. It allows slower consumers (like browsers or mobile clients) to signal the server to slow down. This prevents buffer overflows and improves stability under load.
How Quarkus Handles It
Quarkus RESTEasy Reactive and the underlying Vert.x engine use non-blocking I/O with reactive backpressure built-in. When you use StreamingOutput
or Mutiny
's Multi
, you're not pushing bytes blindly. You’re cooperating with the underlying transport to send chunks only when the client is ready.
Advanced Techniques
ChunkedOutput: Useful if you manually manage response chunks.
Vert.x Core API: For zero-copy file serving from disk (not applicable to object stores).
Pre-signed URLs: Offload large file delivery to S3 entirely.
For most use cases, StreamingOutput
is sufficient, but knowing backpressure is working behind the scenes gives you peace of mind.
Optional: Pre-Signed URLs
Instead of routing the file through your server, let clients download directly from object storage with time-limited, secure URLs.
@Inject S3Presigner s3Presigner;
@GET
@Path("/s3/presigned/{id}")
public Response generatePresignedUrl(@PathParam("id") Long id) {
FileMetadata meta = FileMetadata.findById(id);
if (meta == null || !meta.ownerId.equals(identity.getPrincipal().getName())) {
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
GetObjectRequest req = GetObjectRequest.builder()
.bucket("files").key(meta.s3Key).build();
String url = s3Presigner.presignGetObject(r -> r
.signatureDuration(Duration.ofMinutes(15))
.getObjectRequest(req))
.url().toString();
return Response.ok(Map.of("url", url)).build();
}
Reminder: If the client is a browser (e.g., for images), configure CORS on the bucket.
Security Considerations
Serving files over the web isn’t just a technical problem—it’s a security boundary. Whether you’re exposing documents, reports, or media assets, every download endpoint is a potential attack surface. A misconfigured file service can leak private data, expose sensitive system paths, or allow unauthorized users to access content they shouldn't see.
That’s why securing your file streaming logic is just as important as making it efficient. You need to control who can access each file, sanitize user input, set proper HTTP headers, and ensure that the storage backend is locked down. This is especially critical in multi-tenant systems or compliance-heavy industries like healthcare, finance, or government. The following elements are in no way exhaustive but should give you an idea about what you have to look out for. If you do not know the OWASP Foundation and their recommendations, make sure to check them out.
Access Control
Never allow file access based only on the file ID. Always validate the file’s ownerId
or use fine-grained role-based access.
Filename Sanitization
Avoid header injection or weird behavior by replacing problematic characters in filenames:
String safeFilename = filename.replaceAll("[^a-zA-Z0-9_.-]", "_");
Do not allow user-supplied filenames to contain quotes, semicolons, or slashes. And of course check for directory traversal attacks carefully if you need to be more generous with special characters!
HTTP Headers
Content-Disposition
: Indicates download and sets filenameContent-Type
: Tells the browser how to handle the fileX-Content-Type-Options: nosniff
: Prevents MIME sniffing (protects from disguised files)Cache-Control: no-store
: Prevents caching sensitive data
Bucket Policies
There is a complete best-practices section on the Amazon S3 website and you should absolutely make sure to check it out if you are not “just using” a pre-configured one.
Always use private buckets
Deny public access by default
Use pre-signed URLs or backend-verified access
Virus Scanning
If users can upload files, integrate an antivirus tool like ClamAV to scan uploads before storing them. I might write another article, how to do that exactly.
Testing File Streaming
Test the file streaming logic across different conditions:
Tools
curl
to inspect headers and file sizePostman
to simulate browser downloadsJUnit + RestAssured
for automated validation
Strategies
Check
Content-Disposition
,Content-Type
, andContent-Length
Compare checksums (e.g., SHA-256) against expected values
Download large files to ensure memory is stable and streaming works under load
byte[] content = response.asByteArray();
assertEquals("expected-sha", DigestUtils.sha256Hex(content));
Secure, performant file streaming is more than just sending bytes.
Whether files live in your database or in object storage, you must stream them responsibly, enforce access, and harden endpoints.
Takeaways:
Stream files to reduce memory pressure
Prefer object storage for large, scalable downloads
Use headers to protect clients and browsers
Enforce strict access control and sanitize input
Let S3 handle file delivery when scale demands it
Quarkus gives you the tools to build efficient and secure download flows without added complexity. Give it a try and start coding!