Back to Blog
Security.NETHMACFile StorageAPI Design

HMAC-Signed Media URLs: Securing File Access Without Auth Headers

Umut Korkmaz2025-07-258 min

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

csharp
public 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:

  1. CryptographicOperations.FixedTimeEquals prevents 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.

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

typescript
interface 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:

typescript
function 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:

csharp
public 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-referrer headers 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

  1. HMAC-signed URLs solve the "no auth headers in HTML elements" problem cleanly and securely.
  2. Use fixed-time comparison for signature validation to prevent timing attacks.
  3. Include user identity in the payload so URLs are non-transferable.
  4. Keep expiry short (15 minutes) and refresh proactively on long-lived pages.
  5. Plan for key rotation from the start — accept two keys simultaneously during rotation.