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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Secure Temporary File Access in Spring Boot 3 with Signed URLs

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=xxxoooaaabbbcccddd

The 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: /files

2.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=3FwZxHFpxJrPvGyfcGuJiaCH2zkSDoCLzhZhZtkk5qo

2.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.

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 BootREST APIFile Securitysigned URL
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.