When building modern web applications or APIs, you often need to grant users temporary access to a specific resource. Common scenarios include:

  • Generating a single-use or time-bound download link for a private file.
  • Sending an e-mail link for account verification or password reset.
  • Creating temporary webhooks or one-off API action links.

A common reaction is to reach for traditional authorization methods like JWT (JSON Web Tokens) or Database-Backed Session Tokens. However, when applied to temporary resource access, these traditional approaches come with trade-offs.

In this post, we’ll explore why Signed URLs (using HMAC-SHA256) are often the superior pattern for temporary access, compare them against alternative approaches, and walk through a complete Spring Boot demo project called temporary-url-demo.

The Comparison: 3 Approaches to Temporary Access

Feature Database Tokens JWT in Headers Signed URLs (HMAC-SHA256)
Stateful / Database Load High (Requires DB write & read) Stateless Stateless (Zero DB I/O)
URL-Friendly Yes (e.g. ?token=xyz) No (Headers required; awkward in URL query) Yes (Parameters in URL)
Tamper-Proof Yes Yes Yes (Cryptographic Signature)
Method & Path Binding Manual tracking needed Requires custom claims Built-in (Signed Payload)
Cleanup Maintenance Requires cron jobs to purge expired tokens None None (Automatic via TTL)

Why Traditional Approaches Fall Short

  1. Database-Backed Tokens: Every time you generate or validate a temporary link, you hit the database. Over time, you need background cleanup jobs to purge expired tokens.
  2. JWT via Headers: JWTs are excellent for stateless authentication, but they rely on request headers (Authorization: Bearer <token>). Standard HTML links (<a href="...">), image tags, or e-mail links cannot send custom HTTP headers natively when clicked by a user in a browser.

How Signed URLs Work (The HMAC-SHA256 Pattern)

A Signed URL locks down a specific HTTP method, request URI, and expiration timestamp into a single cryptographic hash using a secret key stored on the server.

Data Canonicalization & Signing

To issue a signed URL, the server constructs a deterministic payload string:

HTTP_METHOD + "\n" + REQUEST_URI + "\n" + EXPIRES_AT_TIMESTAMP

This string is signed using HmacSHA256 with a server-side secretKey and encoded using URL-Safe Base64 (without padding):

// Example Canonical Payload:
POST
/protected/123123123
1787582041957

Validation & Verification

When a request arrives at the server with query parameters exp and sig:

  1. Check if currentTime > exp. If true, reject immediately (403).
  2. Re-compute the expected signature using the incoming HTTP_METHOD, REQUEST_URI, and exp.
  3. Perform a constant-time equality check between the expected signature and sig.

If anyone alters the URL (e.g., changing /protected/123123123 to /protected/999999999 or modifying exp), the signature will no longer match, making the request instantly invalid and tamper-proof.

⚠️ Since Signed URLs are purely stateless, a valid link can be reused multiple times until it reaches its expiration timestamp (exp). If your use case strictly requires a one-time access link (e.g., a single-use password reset or single-download link), you will need to complement this pattern with a stateful check—such as tracking a unique jti/nonce in a fast cache like Redis to invalidate the link upon its first consumption.

Implementation: Spring Boot Interceptor

Here is how you can enforce this validation logic centrally in Spring Boot using a custom HandlerInterceptor:

@RequiredArgsConstructor
public class TemporaryUrlInterceptor implements HandlerInterceptor {

  private final TemporaryUrlService temporaryUrlService;

  @Override
  public boolean preHandle(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler)
      throws Exception {

    String signatureParam = request.getParameter("sig");

    if (!StringUtils.hasText(signatureParam)) {
      throw new InvalidTemporaryUrlException("sig not present");
    }

    String expiresParam = request.getParameter("exp");

    if (!StringUtils.hasText(expiresParam)) {
      throw new InvalidTemporaryUrlException("exp not present");
    }

    long expires = Long.parseLong(expiresParam);

    if (Instant.now().toEpochMilli() > expires) {
      throw new InvalidTemporaryUrlException("Link expired");
    }

    String path = request.getRequestURI();

    String calculatedSignature = temporaryUrlService.calculateSignature(request.getMethod(), path, expires);

    if (!MessageDigest.isEqual(signatureParam.getBytes(), calculatedSignature.getBytes())) {
      throw new InvalidTemporaryUrlException(temporaryUrlService.getSignatureParam() + " not match");
    }

    return true;
  }
}

Hands-On Demo: temporary-url-demo in Spring Boot

To demonstrate this pattern in practice, I built a Spring Boot application called temporary-url-demo.

The application consists of two main parts:

  1. Protected Resources: 5 endpoints representing protected actions across different HTTP methods.
  2. Temporary URL Generator: An endpoint that accepts target resource details and returns a signed, time-bound URL.

Simulated Protected Endpoints

To test method binding, we cover 5 distinct HTTP methods under /protected/{id}:

  • @GetMapping("/protected/{id}")
  • @PostMapping("/protected/{id}")
  • @PutMapping("/protected/{id}")
  • @PatchMapping("/protected/{id}")
  • @DeleteMapping("/protected/{id}")

Every incoming request under /protected/** is intercepted before reaching the controllers.

Generating a Temporary URL

To create a signed link, we issue a POST request to /temporary-url with the desired target HTTP method, resource ID, and time-to-live (expiresIn in seconds).

Request:

curl --request POST \
  --url http://localhost:8080/temporary-url \
  --header 'Content-Type: application/json' \
  --data '{
  "method": "POST",
  "id": "123123123",
  "expiresIn": 60
}'

Response:

{
  "url": "http://localhost:8080/protected/123123123?sig=_Vix2IsIUA-F0erIr056IdwPKSKe04Yo_DwCxC4irts&exp=1787582041957"
}

Testing Security & Edge Cases

To verify that our stateless signature verification works as expected, let's test what happens when an attacker attempts to tamper with the URL or use an expired link.

Valid Request (Happy Path)

Accessing the generated URL before expiration returns success:

curl --request POST \
  --url "http://localhost:8080/protected/123123123?sig=...&exp=..."

# Response: HTTP 200 OK
POST Request Allowed for 123123123

Tampered Resource ID (Signature Mismatch)

If an unauthorized user attempts IDOR (Insecure Direct Object Reference) by changing the path ID from 123123123 to 999999999 without re-signing:

curl --request POST "http://localhost:8080/protected/999999999?sig=...&exp=..."

# Response: HTTP 403 Forbidden

Expired Link

If the link is accessed after the exp timestamp has passed:

curl --request POST "http://localhost:8080/protected/123123123?sig=...&exp=..."

# Response: HTTP 403 Forbidden

Conclusion

Signed URLs offer an elegant, stateless, and secure solution for temporary access control without adding database overhead or requiring authorization headers. By binding HTTP methods, URIs, and expiration times directly into an HMAC-SHA256 signature, you achieve tamper-proof authorization out of the box.

The full demo code is available in the temporary-url-demo repository. Feel free to explore the code, adapt it to your Spring Boot applications!