Protecting file downloads is a common requirement in backend systems. Whether you’re offering digital products, private documents, or internal assets, you usually want more control than just linking directly to files on a server. Static URLs can be bookmarked, scraped, or shared long after they should be available. To avoid that, we can use Spring Boot to generate download links that expire after a short time or that rely on tokens to grant access. This keeps sensitive files protected and allows dynamic rules around who can download what and for how long.
I publish free articles like this daily, if you want to support my work and get access to exclusive content and weekly recaps, consider subscribing to my Substack.
How Secure Download Links Work
You don’t want to store everything behind static paths or risk files sitting out in the open for anyone who knows the URL. Instead, you can create time-sensitive or token-based links that route through your own logic. That way, every file request goes through a permission check and you decide what should be allowed.
Short-lived links protect your files and give you more flexibility. You can make them user-specific, tie them to download limits, or expire them after a few minutes. Spring Boot handles HTTP requests through your controller logic, so you control how the links behave.
Why You Should Avoid Static URLs
Direct file paths like /files/myfile.pdf
are tempting because they work out of the box. You put a file on disk, map a static resource path, and the browser can fetch it with no extra effort. The problem is that once a static URL is live, it stays accessible. Users can share it, bots can index it, and there is no easy way to say, “this file should only be downloadable for ten minutes” or “only this user should see it.”
Even with authentication, the risk remains if the URLs never change. Anyone with the right headers can automate downloads or reuse the same link over and over. It’s better to avoid public file-based paths entirely. You send users to something like /download/abc123
, where abc123
is a unique token that only works for a short time. That keeps file locations hidden, and all download rules stay in your hands.
Generating Tokens
Each download link should be tied to a token that includes enough information to verify access. That usually means storing the filename, a timestamp for expiration, and optionally, the user or session it belongs to.
You can use a UUID or a signed value if you want stateless access. JWTs work too, but here we’ll stick with a plain UUID and a small token object for simplicity.
public class DownloadToken {
private final String token;
private final String fileName;
private final Instant expiresAt;
private final String username;
public DownloadToken(String fileName,
Duration validFor,
String username) {
this.token = UUID.randomUUID().toString();
this.fileName = fileName;
this.expiresAt = Instant.now().plus(validFor);
this.username = username;
}
public String getToken() {
return token;
}
public String getFileName() {
return fileName;
}
public String getUsername() {
return username;
}
public boolean isExpired() {
return Instant.now().isAfter(expiresAt);
}
}
Tokens like this can live in memory using a simple map. For small projects or early setups, that’s often enough.
private final Map<String, DownloadToken> tokenStore = new ConcurrentHashMap<>();
When a user requests a download, you create a token and return a temporary link:
@PostMapping("/request-download")
public ResponseEntity<String> createDownloadLink(@RequestParam String fileName) {
DownloadToken token =
new DownloadToken(fileName, Duration.ofMinutes(5), currentUser.getUsername());
tokenStore.put(token.getToken(), token);
String link = "/download/" + token.getToken();
return ResponseEntity.ok(link);
}
This builds a five-minute link tied to the requested file. The user only sees the token, not the actual file path or name.
Handling The Download Request
When the user follows the link, your application looks up the token, checks expiration, and serves the file if everything checks out. This keeps your access dynamic and protected. Nothing moves forward unless the token is valid.
@GetMapping("/download/{token}")
public ResponseEntity<Resource> download(@PathVariable String token) {
DownloadToken stored = tokenStore.get(token);
if (stored == null || stored.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
Path filePath = Paths.get("files", stored.getFileName()).normalize();
Resource file = new FileSystemResource(filePath);
if (!file.exists()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFilename() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(file);
}
The check guards against expired or missing tokens. The file only gets served if the token is still active.
To make links single-use, you can remove the token once the download is successful:
tokenStore.remove(token);
This keeps users from reusing links after the file is downloaded. You can also track downloads or add audit logs from here if needed.
The user input shouldn’t be trusted when resolving filenames, you’ll want to validate what gets passed in and make sure it matches files you expect. That part comes later, but for now, your logic handles access in a way that static files never could.
Serving Files Securely
After you’ve moved file downloads into your application layer, the next step is making sure the way those files are served holds up under real use. It’s one thing to block a static URL, but it’s another to prevent people from requesting things they shouldn’t have access to, trying to break into other directories, or bypassing your checks. Even with token-based downloads in place, the backend still needs to treat file access carefully.
Spring Boot gives you enough control to serve files from disk, memory, or remote sources, but that also means it’s your job to keep the logic tight. Each request should go through enough validation to make sure it’s both secure and well-scoped.
Preventing Path Traversal
One of the most common mistakes with file downloads is allowing user input to drive file paths without restrictions. That can lead to directory traversal, where a user tries to access files outside the intended directory using sequences like ../
or by injecting absolute paths.
Letting a filename come straight from the request and appending it to a base path is dangerous if you don’t clean it up. Instead, resolve the path carefully and always check that the result stays inside a trusted location.
This block shows one way to validate that the final resolved path stays within the files
folder:
Path baseDir = Paths.get("files").toAbsolutePath().normalize();
Path requestedPath = baseDir.resolve(userInputFileName).normalize();
if (!requestedPath.startsWith(baseDir)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
That extra normalize()
step is what prevents users from sneaking in directory jumps. If they try to send ../../etc/passwd
, the resulting path will fall outside the allowed directory, and your check will catch it.
It’s also a good idea to validate the filename itself before it even reaches the file system. Keeping a safe list of known files, or only accepting IDs instead of filenames, removes a lot of guesswork and limits the surface area for abuse.
Here’s a variation using a predefined map of allowed files:
Map<String, String> fileMap = Map.of(
"invoice", "invoice_2024.pdf",
"summary", "quarterly_summary.xlsx"
);
String mappedFile = fileMap.get(requestParam);
if (mappedFile == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
Path filePath = Paths.get("files", mappedFile).normalize();
This way, users don’t control filenames at all. They only request known labels like invoice
or summary
, and the backend decides what to serve.
Serving From Memory Or External Sources
Not every file lives on disk. Sometimes you’re generating files in real time, pulling them from cloud storage, or streaming binary data that doesn’t have a file on the local file system. In those cases, your logic doesn’t change much, but how you serve the content does.
Spring Boot gives you several ways to send file content as a response. If you have the file in memory as a byte array, you can return it directly with headers set for download:
@GetMapping("/download-mem/{token}")
public ResponseEntity<byte[]> downloadFromMemory(@PathVariable String token) {
DownloadToken downloadToken = tokenStore.get(token);
if (downloadToken == null || downloadToken.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
byte[] content = createDocumentFor(downloadToken.getFileName());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadToken.getFileName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(content.length)
.body(content);
}
This works well when you’re generating documents like PDFs or Excel files or when pulling from services like S3 or Blob Storage and reading content directly into memory.
When working with larger files or streaming sources, you can send an InputStreamResource
instead to avoid loading the full file into memory at once:
@GetMapping("/download-stream/{token}")
public ResponseEntity<Resource> streamDownload(@PathVariable String token) throws IOException {
DownloadToken downloadToken = tokenStore.get(token);
if (downloadToken == null || downloadToken.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
InputStream stream = fetchFromCloudStorage(downloadToken.getFileName());
Resource resource = new InputStreamResource(stream);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadToken.getFileName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
This works well when your files are large or remote, and it keeps memory usage stable. It’s also useful when your file format isn’t known ahead of time or needs to be generated on demand.
Adding Authentication Or User Restrictions
Not every download should be available to anyone with a valid token. Sometimes tokens are tied to a specific user, and that extra layer of restriction needs to be enforced. Otherwise, someone could share their token with others, and the download would still work.
Spring Security lets you access the current user with the @AuthenticationPrincipal
annotation. You can compare it to whatever metadata is stored with the token and block access if they don’t match.
Here’s a basic example using username matching:
@GetMapping("/download-secure/{token}")
public ResponseEntity<Resource> downloadWithUserCheck(@PathVariable String token,
@AuthenticationPrincipal UserDetails currentUser) {
DownloadToken downloadToken = tokenStore.get(token);
if (downloadToken == null || downloadToken.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
if (!downloadToken.getUsername().equals(currentUser.getUsername())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Path filePath = Paths.get("files", downloadToken.getFileName()).normalize();
Resource file = new FileSystemResource(filePath);
if (!file.exists()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFilename() + "\"")
.body(file);
}
This adds a second check that ties the download token to the authenticated user. If the user doesn’t match, the request is rejected before the file is touched.
You could take this further and link tokens to roles, expiration times, or client IPs. What matters is that each token has the chance to be validated in context, and you’re not relying on its existence alone as proof of access.
Conclusion
The mechanics behind secure downloads in Spring Boot rely on bringing file access into your code instead of leaving it to static paths. Tokens act as a control point, giving you a chance to check the request every time. Once the file is requested, you decide what gets served, how long the link stays active, and who’s allowed to use it. Paired with careful path handling, stream-based responses, and user checks, this kind of setup keeps access tied directly to your logic, not just the file system.
- Spring Security Reference Guide
- Spring Web MVC Documentation
- Spring’s Resource Interface
- InputStreamResource Class
Thanks for reading! If you found this helpful, highlighting, clapping, or leaving a comment really helps me out.
Learn more Making a Secure Download Link System in Spring Boot