HMAC-Signed Media URLs: Securing File Access Without Auth Headers
Here is a problem that sounds simple until you actually try to solve it: how do you serve protected files (PDFs, images, documents) to a browser when the browser cannot send an Authorization header? Think about it — an <img> tag, an <iframe> loading a PDF, or a direct file download link. None of these can include a Bearer token in the request. But you still need to verify that the user is authorized to access the file.
In the internal platform, I solved this with HMAC-signed URLs, and it turned out to be one of the cleanest security patterns in the entire application.
The Problem in Detail
the internal platform handles a large volume of sensitive internal documents. When a user views a document detail page, they see embedded images, PDF previews, and download links. The naive approach is to make all media publicly accessible, but that is a non-starter for sensitive internal documents.
The second approach is to proxy everything through an API endpoint that checks auth headers:
html<!-- This won't work - img tags can't send Authorization headers -->
<img src="/api/media/123" />
You could work around this with blob URLs created via fetch, but that means every image requires a JavaScript fetch call, blob creation, and URL revocation. For a page with many images, that is a terrible user experience.
The HMAC Solution
The idea is simple: generate a URL that contains a cryptographic signature proving the server authorized this specific user to access this specific file for a limited time. No auth headers needed — the authorization is embedded in the URL itself.
Server Side: Generating Signed URLs
csharppublic class MediaUrlService : IMediaUrlService
{
private readonly byte[] _signingKey;
private readonly ICurrentUserService _currentUser;
private readonly IOptions<MediaOptions> _options;
public string GenerateSignedUrl(int mediaId, MediaAccessLevel accessLevel)
{
var expiry = DateTimeOffset.UtcNow
.Add(_options.Value.UrlExpiry) // Default: 15 minutes
.ToUnixTimeSeconds();
var userId = _currentUser.UserId;
// Payload: mediaId + userId + expiry + accessLevel
var payload = $"{mediaId}:{userId}:{expiry}:{accessLevel}";
// HMAC-SHA256 signature
using var hmac = new HMACSHA256(_signingKey);
var signatureBytes = hmac.ComputeHash(
Encoding.UTF8.GetBytes(payload));
var signature = Convert.ToBase64String(signatureBytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('='); // URL-safe Base64
return $"/api/media/{mediaId}" +
$"?uid={userId}" +
$"&exp={expiry}" +
$"&lvl={(int)accessLevel}" +
$"&sig={signature}";
}
}
The signed URL contains all the context needed for verification: who is accessing it, when it expires, and what access level is granted. The HMAC signature ensures none of these values can be tampered with.
Server Side: Validating Signed URLs
csharp[ApiController]
[Route("api/[controller]")]
[AllowAnonymous] // No auth header required - signature is the auth
public class MediaController : ControllerBase
{
private readonly byte[] _signingKey;
private readonly IMediaRepository _mediaRepo;
[HttpGet("{id}")]
public async Task<IActionResult> Get(
int id,
[FromQuery] int uid,
[FromQuery] long exp,
[FromQuery] int lvl,
[FromQuery] string sig)
{
// 1. Check expiry
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (exp < now)
return StatusCode(410, new { error = "URL expired" });
// 2. Reconstruct and verify signature
var payload = $"{id}:{uid}:{exp}:{lvl}";
using var hmac = new HMACSHA256(_signingKey);
var expectedSig = Convert.ToBase64String(
hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)))
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(sig),
Encoding.UTF8.GetBytes(expectedSig)))
{
return Forbid();
}
// 3. Serve the file
var media = await _mediaRepo.GetByIdAsync(id);
if (media is null) return NotFound();
// 4. Check access level
var accessLevel = (MediaAccessLevel)lvl;
if (accessLevel == MediaAccessLevel.Preview && media.Size > 10_000_000)
return BadRequest("File too large for preview");
return File(media.Content, media.ContentType, media.FileName);
}
}
There are two security details worth highlighting:
-
CryptographicOperations.FixedTimeEqualsprevents timing attacks. A naive string comparison leaks information about how many characters match, allowing an attacker to guess the signature byte by byte. Fixed-time comparison always takes the same amount of time regardless of where the mismatch occurs. -
The endpoint is
[AllowAnonymous]because the signature IS the authorization. The standard auth middleware is bypassed, and the HMAC validation takes its place.
Frontend: Using Signed URLs
On the React side, the API returns signed URLs as part of the document response:
typescriptinterface DocumentDetail {
id: number;
title: string;
attachments: Array<{
id: number;
fileName: string;
contentType: string;
previewUrl: string; // HMAC-signed, expires in 15 min
downloadUrl: string; // HMAC-signed, expires in 15 min
}>;
}
function DocumentAttachments({ attachments }: Props) {
return (
<div>
{attachments.map((att) => (
<div key={att.id}>
{att.contentType.startsWith('image/') ? (
// Signed URL works directly in img tags
<img src={att.previewUrl} alt={att.fileName} />
) : att.contentType === 'application/pdf' ? (
// Signed URL works in iframes
<iframe src={att.previewUrl} title={att.fileName} />
) : (
// Signed URL works as download links
<a href={att.downloadUrl} download>{att.fileName}</a>
)}
</div>
))}
</div>
);
}
No fetch calls, no blob URLs, no complexity. The signed URLs work everywhere a regular URL works.
URL Refresh
URLs expire after 15 minutes. For long-lived pages, a background refresh ensures URLs do not go stale:
typescriptfunction useRefreshableMediaUrls(documentId: number) {
return useQuery({
queryKey: ['document-media', documentId],
queryFn: () => documentService.getMediaUrls(documentId),
refetchInterval: 12 * 60 * 1000, // Refresh every 12 minutes
// URLs expire at 15 min, refresh at 12 min gives 3 min buffer
});
}
Security Considerations
Key Rotation
The HMAC signing key should be rotated periodically. During rotation, both the old and new keys must be accepted for validation to avoid breaking active URLs:
csharppublic bool ValidateSignature(string payload, string signature)
{
// Try current key first
if (Validate(payload, signature, _currentKey)) return true;
// Fall back to previous key (for URLs generated before rotation)
if (_previousKey is not null && Validate(payload, signature, _previousKey))
return true;
return false;
}
What Cannot Be Tampered
Because the HMAC signature covers the user ID, expiry, and access level:
- A user cannot extend the URL expiry
- A user cannot change the user ID to impersonate someone
- A user cannot escalate from preview to download access
- A user cannot change the media ID to access a different file
Any modification to any parameter invalidates the signature.
What HMAC-Signed URLs Do Not Protect Against
Be honest about limitations:
- URL sharing: If a user shares the signed URL, anyone can access it until it expires. The short expiry (15 minutes) mitigates this.
- Referer leakage: The signed URL is visible in the browser's address bar and network tab. Use
Referrer-Policy: no-referrerheaders to prevent leakage to third-party sites. - Server-side key compromise: If the signing key is leaked, all URLs can be forged. Store the key in Azure Key Vault or similar.
Key Takeaways
- HMAC-signed URLs solve the "no auth headers in HTML elements" problem cleanly and securely.
- Use fixed-time comparison for signature validation to prevent timing attacks.
- Include user identity in the payload so URLs are non-transferable.
- Keep expiry short (15 minutes) and refresh proactively on long-lived pages.
- Plan for key rotation from the start — accept two keys simultaneously during rotation.