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:
{
"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:
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:
{
"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:
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);
}
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:
Client must provide password via query parameter or header
System checks if password is required
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
Without Password
With Password
Missing Password
Wrong Password
Returns HTTP 200 with content or redirect. GET /l/{shortCode}?password=secret123
Password is validated before granting access. GET /l/{shortCode}
# For password-protected link
Returns HTTP 401 Unauthorized with PASSWORD_REQUIRED result. GET /l/{shortCode}?password=wrongpass
Returns HTTP 401 Unauthorized with INVALID_PASSWORD result.
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 Failure HTTP Status AccessResult Link not found 404 Not Found NOT_FOUNDPassword required 401 Unauthorized PASSWORD_REQUIREDInvalid password 401 Unauthorized INVALID_PASSWORDLink revoked 410 Gone REVOKEDLink expired (time) 410 Gone EXPIREDView limit reached 410 Gone VIEW_LIMIT_REACHEDUnexpected state 410 Gone UNEXPECTED_STATESuccess 200 OK / 302 Found SUCCESS
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