Implement Signed URLs for Downloads
Learn to implement signed URLs for secure file downloads in this practical secure development lesson. Understand the core concept, explore step-by-step implementation, and troubleshoot common issues. Designed for hands-on learners, this tutorial covers URL signatures, expiration, and access control.
Focus: implement signed urls for file downloads
You’ve built a file-sharing feature and now anyone with the link can download your private documents forever. Direct URLs are dangerously permanent and unauthenticated, but rolling your own login-and-serve file system is slow, expensive, and fragile. The answer used by S3, Google Cloud, and Netflix is the signed URL — a time-limited, permission-scoped link that lets you ship secure downloads without sucking every request through your app server. In this lesson you’ll learn how to implement signed URLs for file downloads in Python, from cryptography basics to production-grade verification and common pitfalls.
The problem this lesson solves
Directly exposing files on a web server (/static/reports/q3.pdf) or in object storage (https://bucket.s3.amazonaws.com/private/q3.pdf) has two fatal flaws:
- Permanent access — once a link leaks, anyone can download the file forever. There’s no revocation, no expiry.
- No access control — the server can’t enforce who is allowed to download, how many times, or for how long.
Traditional fixes (require a login, then stream the file through your backend) work but create a performance bottleneck and add enormous complexity. Every download ties up an app server, and scaling becomes a nightmare.
The core insight: shift the trust decision from a live authentication check to a cryptographic proof embedded in the URL. If you can verify the signature, you can trust the request, and the file can be served directly from a CDN or object store — no app server in the hot path.
Core concept / mental model
Think of a signed URL as a handwritten, tamper-proof ticket to a concert. The ticket contains the show date, seat number, and a hologram (the signature) that only the venue can produce. The usher doesn’t call the box office to verify every ticket — they check the hologram, confirm the date hasn’t passed, and let you in.
In technical terms, a signed URL looks like this:
https://your-cdn.com/files/q3.pdf?Expires=1710000000&Signature=abc123...&Key-Pair-Id=XYZ
The components are:
- Resource identifier — the path to the file (
/files/q3.pdf). - Expiration timestamp — a Unix epoch time (seconds since 1970) after which the URL is no longer valid.
- Signature — an HMAC (Hash‑Based Message Authentication Code) or asymmetric signature computed over the path and expiration.
- Optional query parameters — such as a user ID or allowed IP range, embedded so they can’t be tampered with.
The security model relies on a secret only the server knows. The server generates the URL, hands it to a client, and the client downloads directly from the storage layer. The storage layer (or your edge server) verifies the signature and expiration without contacting your app. This is why signed URLs are so efficient — verification is pure cryptography, done in microseconds.
Why HMAC is the right tool
For most signed URL implementations, HMAC-SHA256 is the sweet spot. It’s a symmetric algorithm — the same secret both signs and verifies — and it’s blazing fast. A simplistic approach like base64-encoding the resource is not security; the signature must be computed over the data so any change invalidates it.
How it works step by step
Implementing signed URLs is a simple, repeatable process. Here is the logical flow:
- Choose your resources — the file paths you’ll protect.
- Create a secret — a long, random, server-only string (at least 32 bytes). Rotate it periodically.
- Generate the URL — take the base URL, add the resource path and expiry (current time + TTL), then compute an HMAC over that string.
- Append the signature — as a query parameter (commonly
signatureorsig). - Serve the URL — the client gets it from your API, email, or UI.
- Verify incoming requests — the server receiving the file request recalculates the HMAC with the same secret. If it matches and the expiry hasn’t passed, allow the download.
Cause → effect: any tampering with the path or expiry changes the signed data, so the verification fails. Expiration ensures the URL becomes worthless after the TTL, even if leaked.
Encoding considerations
- Always use URL-safe base64 for the signature (Pythons
base64.urlsafe_b64encode) to avoid+,/, and=characters that break in URLs. - Use the Unix epoch for expiration to avoid timezone bugs.
- Compare signatures in constant time to prevent timing attacks — use
secrets.compare_digest().
Hands-on walkthrough
Let’s build a small Python library that generates and verifies signed URLs. We’ll use only the standard library, but you can easily swap in itsdangerous (Flask’s signing library) or your cloud provider’s SDK.
Generating a signed URL
import base64
import hashlib
import hmac
import time
from urllib.parse import quote
SECRET = b"change-me-to-a-long-random-string-32-bytes-min"
BASE_URL = "https://cdn.example.com"
TTL_SECONDS = 3600 # 1 hour
def generate_signed_url(resource_path: str, ttl: int = TTL_SECONDS) -> str:
expiry = int(time.time()) + ttl
# Build the string to sign: path + expiry
to_sign = f"{resource_path}:{expiry}".encode()
signature = hmac.new(SECRET, to_sign, hashlib.sha256).digest()
signature_b64 = base64.urlsafe_b64encode(signature).decode().rstrip("=")
# URL-encode the path to be safe
encoded_path = quote(resource_path, safe="/")
return f"{BASE_URL}{encoded_path}?Expires={expiry}&Signature={signature_b64}"
# Example usage
url = generate_signed_url("/private/reports/q3.pdf")
print(url)
# Output: https://cdn.example.com/private/reports/q3.pdf?Expires=1710000000&Signature=abc...
Verifying a signed URL
import urllib.parse
def verify_signed_url(url: str, max_skew: int = 60) -> bool:
parsed = urllib.parse.urlparse(url)
query = urllib.parse.parse_qs(parsed.query)
expiry = query.get("Expires", [None])[0]
signature = query.get("Signature", [None])[0]
if not expiry or not signature:
return False
try:
expiry = int(expiry)
except ValueError:
return False
# Check expiration (allow a little clock skew)
if expiry + max_skew < int(time.time()):
return False
# Recompute signature
to_sign = f"{parsed.path}:{expiry}".encode()
expected = hmac.new(SECRET, to_sign, hashlib.sha256).digest()
expected_b64 = base64.urlsafe_b64encode(expected).decode().rstrip("=")
return hmac.compare_digest(expected_b64, signature)
# Test the URL we generated
print(verify_signed_url(url)) # True
# Tamper with the path
tampered = url.replace("q3.pdf", "q4.pdf")
print(verify_signed_url(tampered)) # False
# Expired URL
old_url = generate_signed_url("/private/reports/q3.pdf", ttl=-10)
print(verify_signed_url(old_url)) # False
Expected output:
True
False
False
Using a library: itsdangerous
If you’re in a Flask or Django ecosystem, itsdangerous provides a battle-tested URLSafeTimedSerializer:
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
SECRET_KEY = "your-secret"
serializer = URLSafeTimedSerializer(SECRET_KEY)
# Generate
token = serializer.dumps({"file": "/private/reports/q3.pdf", "user_id": 42})
url = f"https://cdn.example.com/private/reports/q3.pdf?token={token}"
# Verify
try:
data = serializer.loads(token, max_age=3600) # 1 hour
print("Valid:", data)
except SignatureExpired:
print("Token expired")
except BadSignature:
print("Invalid signature")
This keeps you from rolling your own crypto and handles edge cases like signed tokens with embedded IDs.
Compare options / when to choose what
There are three main ways to protect file downloads. Here’s how they compare:
| Approach | Pros | Cons | Best for |
|---|---|---|---|
| Signed URLs (HMAC) | Simple, fast, stateless, works with CDN/object storage | Must manage secret, URL can be shared before expiry | Most use cases: downloads, temporary access |
| Cloud-native signed URLs (S3 presigned, Google signed, CloudFront) | Battle-tested, integrated with IAM, supports additional policies | Vendor lock-in, limited to that cloud | When you’re already using AWS/GCP/Azure object storage |
| Token-based streaming (app serves file) | Full control, can enforce per-download limits | Expensive, scaling bottleneck, no CDN offload | Small files, non-public, high-security, low-traffic |
When to choose what:
- Use HMAC-based signed URLs when you need a portable, simple solution or are serving from your own CDN.
- Use cloud-native presigned URLs if you’re on a major cloud — they handle secret rotation and timestamps for you.
- Use token-based streaming only when you need the app server to log every download or enforce complex business rules beyond expiration.
Pro tip: Always set the shortest possible expiration. If a URL is leaked, you want the damage window to be minutes, not months.
Troubleshooting & edge cases
Signature mismatch when URL contains spaces or special characters
- Always URL-encode the resource path and query parameters consistently. If you generate the signature on
my file.pdfbut the client requestsmy%20file.pdf, the server must normalize to the same path before verifying. urllib.parse.quote()withsafe="/"is your friend.
Expired URL but server clock is slightly off
- Add a small clock skew allowance (e.g., 30–60 seconds) in your verification logic, but never accept URLs that expired more than a minute ago.
- Use time-synchronized servers (NTP) for both signer and verifier.
Tamper attempts appear as "invalid signature"
- If you see “invalid signature” while debugging, log the expected and received signatures (during development) to spot missing URL encoding or double-encoding.
- Never log the secret.
Using == in base64 signature breaks the URL
- Strip the padding with
.rstrip("=")when adding to the query string, or use URL-safe base64. - Our example already does this, but many custom implementations forget it.
Secret leakage / rotation
- If your secret is exposed in Git or logs, rotate it immediately and use key versioning (e.g.,
kidparameter) so old URLs can still validate for a short transition period. - Store secrets in environment variables or a secrets manager, never in code.
What you learned & what's next
You now understand the core idea behind signed URLs: a cryptographic signature that grants time-limited access without a live authentication round-trip. You can generate and verify signed URLs in Python with standard library cryptography, and you know when to reach for a cloud-native presigned URL instead. You also learned key edge cases like URL encoding, clock skew, and secret rotation.
You’re ready to move beyond file downloads. The next lesson in the Secure development track will likely cover token-based authentication or security headers — where you’ll apply the same mental model to protecting API endpoints and browser-based sessions. Keep practicing: implement signed URLs in a real project and see how they behave under load and tampering.
Practice recap
Now try it yourself: build a Flask endpoint that returns a signed URL for a file like /files/report.pdf. Verify that an expired URL fails with a 403, and that tampering with the path breaks the signature. Extend your implementation to include a user_id in the signed data and log which user accessed which file — this will prepare you for the next lesson on token-based authentication.
Common mistakes
- Using plain base64 encoding instead of HMAC to 'sign' URLs — attackers can forge any value.
- Forgetting to URL-encode the path and signature, causing verification failures due to spaces or slashes.
- Hardcoding the secret in source code or committing it to version control.
- Setting overly long expirations (years) that turn a temporary link into a permanent backdoor.
Variations
- Cloud-native presigned URLs: AWS S3 presigned URLs, Google Cloud signed URLs, or Azure SAS tokens.
- Asymmetric signatures (e.g., RSA) for third-party verification without sharing a secret.
- Using a serialization library like itsdangerous to embed user IDs and additional claims in the token.
Real-world use cases
- Private file sharing in a SaaS app: generate a short-lived signed link for a user to download a report, then revoke automatically.
- Content delivery from a CDN: protect premium videos for 24 hours and let the CDN serve the file directly, reducing server load.
- Multipart upload with progress: sign a URL that allows a client to upload a large file directly to object storage, then expire it after the upload completes.
Key takeaways
- A signed URL embeds a cryptographic signature and expiration, shifting trust from live auth to stateless verification.
- Use HMAC-SHA256 with a server-only secret for simplicity; cloud-native presigned URLs are a robust alternative.
- Always include an expiration timestamp and verify it; never sign URLs with infinite lifetime.
- URL-encode all components and compare signatures in constant time to prevent tampering and timing attacks.
- Rotate secrets and use key versioning to recover from leaks without invalidating all existing links.
- Choose signed URLs for performance and scalability; fall back to token-based streaming only for complex custom logic.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.