Secure Temporary File Access in Spring Boot 3 with Signed URLs
This article explains how to implement secure, time‑limited file access in Spring Boot 3 by generating signed URLs, covering the underlying mechanism, configuration properties, utility classes, REST endpoints, and testing procedures with full code examples.
1. Introduction
File access is a common requirement in applications, and securely accessing private files often requires more than simple authentication. A signed URL provides a short‑lived access path without granting long‑term credentials, combining a digital signature and expiration time to ensure safe, temporary sharing.
1.1 Signed URL Mechanism
A signed URL balances security and temporary access by converting a file link into a mathematically verifiable form. It embeds the resource path, expiration time, and an encrypted signature; without a valid signature the link is invalid, making it suitable for private files without creating new accounts or long passwords.
1.2 Signed URL Structure
A signed URL is an HTTP link with extra query parameters for expiration and signature, otherwise appearing like a normal path. Example:
https://oss.pack.com/photos/architecture.png?expires=1755990064115&sign=xxxoooaaabbbcccdddThe server validates the expires timestamp and the sign field, which contains an encrypted checksum generated on the server side.
2. Practical Example
2.1 Basic Configuration
@Component
@ConfigurationProperties(prefix = "pack.app")
public class LinkProperties {
// secret key
private String secretKey;
// algorithm
private String algs;
// lifetime in seconds
private long lifetimeSeconds;
// HTTP method (GET, POST, ...)
private String method;
// access path URL
private String accessPath;
// getters and setters
} pack:
app:
algs: HmacSHA256
lifetime-seconds: 1800
method: get
secret-key: aaaabbbbccccdddd
accessPath: /files2.2 Signature Utility Class
@Component
public class SignatureUtil {
private final LinkProperties linkProperties;
private final byte[] secret;
public SignatureUtil(LinkProperties linkProperties) {
this.linkProperties = linkProperties;
this.secret = this.linkProperties.getSecretKey().getBytes(StandardCharsets.UTF_8);
}
public String signPath(String method, String path, long expires) throws Exception {
// Assemble data with a separator ("|")
String data = method + "|" + path + "|" + expires;
// Use HMAC algorithm defined in properties
final String HMAC = this.linkProperties.getAlgs();
Mac mac = Mac.getInstance(HMAC);
mac.init(new SecretKeySpec(secret, HMAC));
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
}
}2.3 Generating Signed URLs
@Service
public class LinkService {
private final SignatureUtil signatureUtil;
private final LinkProperties linkProperties;
public LinkService(SignatureUtil signatureUtil, LinkProperties linkProperties) {
this.signatureUtil = signatureUtil;
this.linkProperties = linkProperties;
}
public String generateLink(String filePath) throws Exception {
String canonicalPath = filePath.startsWith("/") ? filePath : "/" + filePath;
long expiresAt = ZonedDateTime.now()
.plusSeconds(this.linkProperties.getLifetimeSeconds())
.toEpochSecond();
String signature = signatureUtil.signPath(this.linkProperties.getMethod(), canonicalPath, expiresAt);
return String.format("/%s%s?expires=%d&sign=%s",
this.linkProperties.getAccessPath(), canonicalPath, expiresAt, signature);
}
}2.4 Access Controller
@RestController
@RequestMapping("${pack.app.accessPath:/files}")
public class FileAccessController {
private final SignatureUtil signatureUtil;
private final LinkService linkService;
private final LinkProperties linkProperties;
public FileAccessController(SignatureUtil signatureUtil, LinkService linkService, LinkProperties linkProperties) {
this.signatureUtil = signatureUtil;
this.linkService = linkService;
this.linkProperties = linkProperties;
}
@GetMapping("")
public ResponseEntity<List<String>> generateLinksForDirectory() throws Exception {
String directoryPath = "d:/images/photos";
List<String> links = new ArrayList<>();
Path dirPath = Paths.get(directoryPath);
if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) {
return ResponseEntity.badRequest().body(links);
}
Files.list(dirPath).filter(Files::isRegularFile).forEach(file -> {
try {
String relativePath = dirPath.relativize(file).toString().replace("\\", "/");
links.add(String.format("http://localhost:8080%s", this.linkService.generateLink(relativePath)));
} catch (Exception e) {
e.printStackTrace();
}
});
return ResponseEntity.ok(links);
}
@GetMapping("/{*path}")
public void fetchFile(@PathVariable("path") String path,
@RequestParam long expires,
@RequestParam String sign,
HttpServletResponse response) throws Exception {
long now = Instant.now().getEpochSecond();
if (now >= expires) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Link expired");
return;
}
String expected = signatureUtil.signPath(this.linkProperties.getMethod(), path, expires);
try {
byte[] expectedBytes = Base64.getUrlDecoder().decode(expected);
byte[] providedBytes = Base64.getUrlDecoder().decode(sign);
if (!MessageDigest.isEqual(expectedBytes, providedBytes)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid link");
return;
}
} catch (IllegalArgumentException e) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Illegal access: " + e.getMessage());
return;
}
Path filePath = Paths.get("d:/images/photos/", path).normalize();
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "File not found");
return;
}
String contentType = determineContentType(path);
response.setContentType(contentType);
Files.copy(resource.getFile().toPath(), response.getOutputStream());
response.getOutputStream().flush();
}
private String determineContentType(String path) {
if (path == null || !path.contains(".")) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
return switch (extension) {
case "png" -> MediaType.IMAGE_PNG_VALUE;
case "jpg", "jpeg" -> MediaType.IMAGE_JPEG_VALUE;
case "pdf" -> MediaType.APPLICATION_PDF_VALUE;
case "txt" -> MediaType.TEXT_PLAIN_VALUE;
case "html" -> MediaType.TEXT_HTML_VALUE;
default -> MediaType.APPLICATION_OCTET_STREAM_VALUE;
};
}
}Example generated link:
/files/ar.png?expires=1756081608&sign=3FwZxHFpxJrPvGyfcGuJiaCH2zkSDoCLzhZhZtkk5qo2.5 Testing
Access the directory endpoint http://localhost:8080/files to obtain signed URLs for all files in the configured folder. The following screenshots illustrate successful URL generation, valid access, and the error response when the link has expired.
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.
