Skip to main content

Overview

Secure Link API implements a multi-layered access control system that validates every link access attempt against configured security constraints. Access is granted only when all validation checks pass.

Security Constraints

Each secure link can be protected by three independent security mechanisms:

Time Expiration

Links automatically expire at a specified timestamp

View Limits

Links expire after being accessed a maximum number of times

Password Protection

Links require password authentication before access

Time-Based Expiration

Configuration

Links can be configured with an expiresAt timestamp:
Request Example
{
  "targetUrl": "https://example.com/resource",
  "expiresAt": "2024-12-31T23:59:59Z",
  "maxViews": null,
  "password": null
}

Validation Logic

The system checks expiration using timezone-aware comparison:
SecureLink.java:94-104
public boolean isExpired() {
  if (expiresAt == null) {
    return false;  // No expiration set
  }
  boolean expired = OffsetDateTime.now(expiresAt.getOffset()).isAfter(expiresAt);
  if (expired) {
    expire();  // Transition to EXPIRED status
  }
  return expired;
}
Expiration checks use the same timezone offset as the stored expiresAt value to ensure accurate comparison across timezones.

Default TTL

If no expiresAt is provided, the system applies a default TTL configured in LinkTtlProperties:
CreateLinkServiceImpl.java:71-75
private OffsetDateTime resolveExpiresAt(OffsetDateTime requestedExpiresAt) {
  return requestedExpiresAt != null
    ? requestedExpiresAt
    : OffsetDateTime.now().plus(linkTtlProperties.getDefaultTtl());
}

View Limit Controls

Configuration

The maxViews parameter limits how many times a link can be successfully accessed:
Request Example
{
  "targetUrl": "https://example.com/resource",
  "expiresAt": null,
  "maxViews": 5,
  "password": null
}

Tracking and Enforcement

Each successful access increments the viewCount, and the link expires when the limit is reached:
SecureLink.java:79-88
public boolean hasReachedViewLimit() {
  return maxViews != null && viewCount >= maxViews;
}

public void incrementViewCount() {
  this.viewCount++;
  if (hasReachedViewLimit()) {
    expire();  // Auto-expire on limit
  }
}
Only successful access attempts count toward maxViews. Failed attempts (wrong password, etc.) do not increment the counter.

Validation During Resolution

The resolver checks view limits before granting access:
ResolveLinkServiceImpl.java:78-82
if (link.hasReachedViewLimit()) {
  link.expire();
  repository.save(link);
  handleDenied(link.getShortCode(), AccessResult.VIEW_LIMIT_REACHED, "view_limit_reached", context);
}

Password Protection

Setting a Password

Passwords are hashed using BCrypt before storage:
CreateLinkServiceImpl.java:46-49
if (request.password() != null && !request.password().isBlank()) {
  String hash = passwordEncoder.encode(request.password());
  link.protectWithPassword(hash);
}
SecureLink.java:118-121
public void protectWithPassword(String passwordHash) {
  this.passwordHash = passwordHash;
  this.passwordProtected = true;
}
Never store passwords in plaintext. The system uses PasswordEncoder (BCrypt) for secure hashing.

Password Validation

When accessing a password-protected link:
  1. Client must provide password via query parameter or header
  2. System checks if password is required
  3. System validates password against stored hash
ResolveLinkServiceImpl.java:87-94
if (link.isPasswordProtected()) {
  if (password == null || password.isBlank()) {
    handleDenied(link.getShortCode(), AccessResult.PASSWORD_REQUIRED, 
      "password_required", HttpStatus.UNAUTHORIZED, "Password required", context);
  }
  if (!passwordEncoder.matches(password, link.getPasswordHash())) {
    handleDenied(link.getShortCode(), AccessResult.INVALID_PASSWORD, 
      "invalid_password", HttpStatus.UNAUTHORIZED, "Invalid password", context);
  }
}

Access Patterns

GET /l/{shortCode}
Returns HTTP 200 with content or redirect.

Validation Flow

Every access attempt goes through a comprehensive validation pipeline in ResolveLinkServiceImpl:

Implementation

The complete validation sequence from ResolveLinkServiceImpl.java:46-126:
@Transactional
public ResolveResultDto resolve(String shortCode, String password, AccessContextDto context) {
  
  // Step 1: Link existence check
  SecureLink link = repository.findByShortCode(shortCode)
    .orElseThrow(() -> {
      auditService.audit(shortCode, AccessResult.NOT_FOUND, context.ipAddress(), context.userAgent());
      throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Link not found");
    });
  
  // Step 2: Revocation check
  if (link.isRevoked()) {
    handleDenied(link.getShortCode(), AccessResult.REVOKED, "revoked", context);
  }
  
  // Step 3: Time expiration check
  if (link.isExpired()) {
    repository.save(link);  // Persist status change
    handleDenied(link.getShortCode(), AccessResult.EXPIRED, "expired", context);
  }
  
  // Step 4: View limit check
  if (link.hasReachedViewLimit()) {
    link.expire();
    repository.save(link);
    handleDenied(link.getShortCode(), AccessResult.VIEW_LIMIT_REACHED, "view_limit_reached", context);
  }
  
  // Step 5: Status validation
  if (!link.isActive()) {
    handleDenied(link.getShortCode(), AccessResult.UNEXPECTED_STATE, "inactive", context);
  }
  
  // Step 6: Password validation
  if (link.isPasswordProtected()) {
    if (password == null || password.isBlank()) {
      handleDenied(link.getShortCode(), AccessResult.PASSWORD_REQUIRED, 
        "password_required", HttpStatus.UNAUTHORIZED, "Password required", context);
    }
    if (!passwordEncoder.matches(password, link.getPasswordHash())) {
      handleDenied(link.getShortCode(), AccessResult.INVALID_PASSWORD, 
        "invalid_password", HttpStatus.UNAUTHORIZED, "Invalid password", context);
    }
  }
  
  // Step 7: Grant access
  link.incrementViewCount();
  repository.save(link);
  auditService.audit(shortCode, AccessResult.SUCCESS, context.ipAddress(), context.userAgent());
  
  // Step 8: Return content based on link type
  if (link.getTargetUrl() != null && !link.getTargetUrl().isBlank()) {
    return new ResolveResultDto(LinkType.REDIRECT, link.getTargetUrl(), null, null);
  }
  
  Resource fileUri = fileUtils.getResource(link.getFilePath());
  return new ResolveResultDto(LinkType.DOWNLOAD, null, fileUri, link.getOriginalFileName());
}
Validation order matters. Checks are performed in a specific sequence to provide the most accurate denial reason.

HTTP Response Codes

Different validation failures return specific HTTP status codes:
Validation FailureHTTP StatusAccessResult
Link not found404 Not FoundNOT_FOUND
Password required401 UnauthorizedPASSWORD_REQUIRED
Invalid password401 UnauthorizedINVALID_PASSWORD
Link revoked410 GoneREVOKED
Link expired (time)410 GoneEXPIRED
View limit reached410 GoneVIEW_LIMIT_REACHED
Unexpected state410 GoneUNEXPECTED_STATE
Success200 OK / 302 FoundSUCCESS

Combining Constraints

Multiple security constraints can be combined for enhanced protection:
Multi-Layer Security Example
{
  "targetUrl": "https://example.com/sensitive-resource",
  "expiresAt": "2024-12-31T23:59:59Z",
  "maxViews": 10,
  "password": "secure-pass-123"
}
This link:
  • Expires on December 31, 2024
  • Can be accessed maximum 10 times
  • Requires password authentication
  • Expires when any constraint is violated
For highly sensitive content, use all three constraints to create defense-in-depth security.

Next Steps

Audit Tracking

Learn how access attempts and validation results are logged

API Reference

See the complete API documentation for link resolution

Build docs developers (and LLMs) love