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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Secure File Downloads in Spring Boot 3: Token‑Based Links & Path‑Traversal Protection

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

Diagram 1
Diagram 1
Diagram 2
Diagram 2
Diagram 3
Diagram 3
Diagram 4
Diagram 4
Diagram 5
Diagram 5
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaSpring Boottokenfile protectionSecure Download
Spring Full-Stack Practical Cases
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.