Secure File Downloads in Spring Boot 3: Token‑Based Links & Path‑Traversal Protection
This article explains how to protect file downloads in Spring Boot by generating short‑lived or token‑based URLs, preventing static URL exposure, handling path‑traversal attacks, supporting in‑memory or streamed files, and optionally tying downloads to authenticated users.
1. Introduction
Protecting file downloads is a common requirement in backend systems. Static URLs can be bookmarked, crawled, or shared indefinitely, which may expose sensitive files. Spring Boot can generate short‑lived or token‑based download links to control who can download a file and for how long.
2. How Secure Download Links Work
Instead of exposing files under a static path, you create time‑limited or token‑based links that are processed by your own logic. Each request passes a permission check before the file is served, allowing per‑user, per‑download‑count, or expiration‑time restrictions.
3. Why Avoid Static URLs
Static URLs such as /download/SpringBoot实战案例200讲.pdf remain accessible forever once enabled, allowing users to share them or crawlers to index them. They cannot enforce "download only for ten minutes" or "only this user can view" constraints.
4. Resource Handler Example (Static Mapping)
@Component
public class DownloadConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/download/**")
.addResourceLocations("file:///d:/images/");
}
}This configuration exposes all files under d:/images via the /download/** prefix, which is why we later replace it with token‑based routes.
5. Practical Cases
5.1 Generate Token
Each download link carries a token containing the file name, expiration timestamp, and optional user information.
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;
}
// getters omitted
}5.2 Token Store Interface
public interface TokenStore {
void store(String token, DownloadToken downloadToken);
DownloadToken getToken(String token);
void removeToken(String token);
}5.3 In‑Memory Implementation
public class MemoryTokenStore implements TokenStore {
private static final Map<String, DownloadToken> tokens = new ConcurrentHashMap<>();
@Override
public void store(String token, DownloadToken downloadToken) {
tokens.putIfAbsent(token, downloadToken);
}
@Override
public DownloadToken getToken(String token) {
return tokens.get(token);
}
@Override
public void removeToken(String token) {
tokens.remove(token);
}
}5.4 Create Download Link Endpoint
@RestController
@RequestMapping("/download")
public class DownloadController {
private final TokenStore tokenStore;
public DownloadController(TokenStore tokenStore) {
this.tokenStore = tokenStore;
}
@GetMapping("/request-download")
public ResponseEntity<String> createDownloadLink(@RequestParam String fileName) {
DownloadToken token = new DownloadToken(fileName, Duration.ofMinutes(5), "pack");
tokenStore.store(token.getToken(), token);
String link = "/download/" + token.getToken();
return ResponseEntity.ok(link);
}
}5.5 Handle Download (Token Validation)
@GetMapping("/{token}")
public ResponseEntity<Resource> download(@PathVariable String token) {
DownloadToken stored = tokenStore.getToken(token);
if (stored == null || stored.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
Path filePath = Paths.get("d:/images", stored.getFileName());
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);
}5.6 Prevent Path Traversal
Validate that the resolved path stays inside the allowed directory.
@GetMapping("/request-download")
public ResponseEntity<String> createDownloadLink(@RequestParam String fileName) {
DownloadToken token = new DownloadToken(fileName, Duration.ofMinutes(5), "pack");
tokenStore.store(token.getToken(), token);
Path baseDir = Paths.get("d:/images").toAbsolutePath().normalize();
Path requestedPath = baseDir.resolve(fileName).normalize();
if (!requestedPath.startsWith(baseDir)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
String link = "/download/" + token.getToken();
return ResponseEntity.ok(link);
}5.7 Download from Memory (Byte Array)
@GetMapping("/download-mem/{token}")
public ResponseEntity<byte[]> downloadFromMemory(@PathVariable String token) {
DownloadToken downloadToken = tokenStore.getToken(token);
if (downloadToken == null || downloadToken.isExpired()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
byte[] content = createFileStream(downloadToken.getFileName());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadToken.getFileName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(content.length)
.body(content);
}
private byte[] createFileStream(String fileName) {
try {
return StreamUtils.copyToByteArray(new FileInputStream(new File("d:/images/" + fileName)));
} catch (Exception e) {
throw new RuntimeException("文件错误");
}
}5.8 Stream Large Files
@GetMapping("/download-stream/{token}")
public ResponseEntity<Resource> streamDownload(@PathVariable String token) throws IOException {
DownloadToken downloadToken = tokenStore.getToken(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);
}5.9 Bind Token to Authenticated User
@GetMapping("/download-secure/{token}")
public ResponseEntity<Resource> downloadWithUserCheck(@PathVariable String token,
@AuthenticationPrincipal UserDetails currentUser) {
DownloadToken downloadToken = tokenStore.getToken(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);
}6. Illustrative Diagrams
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
