Introduction
We’re a small game studio based in New Zealand. Like many modern studios, we built our own game launcher (think Steam or Epic Games Launcher style) that allows players to log in to our own authentication solution, using their favorite identity provider — Google, Epic, Steam, Outlook, and more. From there, they can download and update our games.
Sounds straightforward, right? Well… not quite.
The Problem
Our games and launcher binaries are stored in Amazon S3, fronted by CloudFront with a WAF for security.
The catch:
Even with CloudFront and WAF in place, the S3 buckets were still public. That meant anyone who discovered the URL could:
- Download the game repeatedly.
- Share direct links with others.
- Drive up our CDN data transfer bill.
When you’re distributing large binaries (think tens of gigabytes per game), the cost of unrestricted downloads quickly becomes painful.
The Solution
We needed a way to control who can download and ensure downloads were tied to real authenticated users. Enter CloudFront Pre-Signed URLs.
Our infrastructure looks like this:
- API Gateway – Public entry point for download requests.
- Lambda Authorizer – Validates user authentication/session.
- Lambda Backend – Issues short-lived CloudFront pre-signed URLs.
- SQS + DLQ – Provides a dead-letter queue for failed requests or processing errors, ensuring no data is lost and enabling easier debugging and recovery.
The flow is simple:
- The launcher authenticates the player.
- It calls our
/download-url
endpoint with the requested asset ID. - The backend Lambda validates entitlement and returns a signed URL with a short TTL (60–300s).
- The launcher immediately begins the download via CloudFront.
- If any step fails, the event is sent to SQS DLQ for observability.
Result: only authorized, entitled users get valid download links.
Caching & TTL (what actually changes)
-
Caching behavior: our API response that returns the signed URL is sent with
Cache-Control: private, max-age=0
.That prevents accidental caching of the API response. The asset itself remains cacheable at the edge; the signature gates access, not caching.
-
TTL strategy: we keep signed URL TTLs short (60–120s for most assets). That’s long enough for the launcher to start the transfer, but short enough to reduce link leakage.
Minimal Terraform (copy-paste)
# 1) CloudFront public key (the PEM public half of your keypair)
resource "aws_cloudfront_public_key" "downloads_pk" {
name = "downloads-public-key"
encoded_key = file("${path.module}/cloudfront-public-key.pem")
comment = "Public key for signing download URLs"
}
# 2) Key group that includes the public key
resource "aws_cloudfront_key_group" "downloads_kg" {
name = "downloads-key-group"
items = [aws_cloudfront_public_key.downloads_pk.id]
}
# 3) Origin (S3 or an Origin Access Control-backed S3)
resource "aws_cloudfront_origin_access_control" "oac" {
name = "downloads-oac"
description = "OAC for S3 downloads"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_s3_bucket" "assets" {
bucket = "game-assets-example"
}
# 4) Distribution with behavior that requires signed URLs
resource "aws_cloudfront_distribution" "downloads" {
enabled = true
comment = "Game downloads (signed URLs)"
default_root_object = ""
origins {
domain_name = aws_s3_bucket.assets.bucket_regional_domain_name
origin_id = "s3-assets"
origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
}
default_cache_behavior {
target_origin_id = "s3-assets"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
# Require signed URLs via this key group
trusted_key_groups = [aws_cloudfront_key_group.downloads_kg.id]
compress = true
# Cache policy & origin request policy can be customized as needed
cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
origin_request_policy_id = data.aws_cloudfront_origin_request_policy.cors_s3_origin.id
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
data "aws_cloudfront_cache_policy" "caching_optimized" {
name = "Managed-CachingOptimized"
}
data "aws_cloudfront_origin_request_policy" "cors_s3_origin" {
name = "Managed-CORS-S3Origin"
}
Minimal Lambda signer (Python)
Here’s a small extract based on our backend that shows the core signing path: read Key-Pair-Id from env, load private key from Secrets Manager, sign a URL, return { url, expiresAt }.
import os, json, base64
from datetime import datetime, timedelta
import boto3
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
secrets = boto3.client("secretsmanager")
def _b64url(data: bytes) -> str:
s = base64.b64encode(data).decode("utf-8")
return s.replace("+", "-").replace("/", "~").replace("=", "_")
def sign_url(url: str, expires_secs: int, key_pair_id: str, private_key_pem: str) -> str:
expire_ts = int((datetime.utcnow() + timedelta(seconds=expires_secs)).timestamp())
policy = {"Statement": [{"Resource": url, "Condition": {"DateLessThan": {"AWS:EpochTime": expire_ts}}}]}
policy_json = json.dumps(policy, separators=(",", ":"))
private_key = serialization.load_pem_private_key(private_key_pem.encode(), password=None)
signature = private_key.sign(policy_json.encode(), padding.PKCS1v15(), hashes.SHA1())
return f"{url}?Policy={_b64url(policy_json.encode())}&Signature={_b64url(signature)}&Key-Pair-Id={key_pair_id}"
def handler(event, _context):
cf_domain = os.environ["CLOUDFRONT_DOMAIN_NAME"] # e.g., dxxxxx.cloudfront.net
key_pair_id = os.environ["CLOUDFRONT_PUBLIC_KEY_ID"] # the public key ID
secret_arn = os.environ["CLOUDFRONT_PRIVATE_KEY_SECRET"] # PEM private key in Secrets Manager
path = json.loads(event.get("body","{}")).get("path", "/launcher/ReadyLauncher.exe")
pem = secrets.get_secret_value(SecretId=secret_arn)["SecretString"]
url = f"https://{cf_domain}{path}"
ttl = int(os.environ.get("PRESIGNED_URL_EXPIRY", "120"))
signed = sign_url(url, ttl, key_pair_id, pem)
# Important: prevent caching of *API* response
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json",
"Cache-Control": "private, max-age=0",
"Access-Control-Allow-Origin": "*"},
"body": json.dumps({"url": signed, "expiresAt": int(datetime.utcnow().timestamp()) + ttl})
}
Observability: how we know it worked
CloudFront (distribution)
- CacheHitRate: should trend up for game assets once hot.
-
BytesDownloaded: visualize by path prefix (e.g.,
/launcher/*
vs/games/*
). - Requests: also split by path to see hot objects and suspicious spikes.
API (download-url)
-
Count of requests to
/download-url
vs 2xx success rate. - Emit a correlation ID (e.g.,
x-request-id
) from API Gateway → Lambda logs → include it in any DLQ payloads for failed attempts.
Guardrails
-
403 spike alarm in CloudFront: when
4xxErrorRate
exceeds a baseline over N minutes. -
Unsigned access attempts: create a log metric filter from CloudFront logs counting requests for asset paths missing
Key-Pair-Id
/Signature
query params; alarm if > threshold.
AWS Cost
Did this make sense from a cost perspective? Absolutely.
Here’s what our August AWS bill looked like for this setup:
- 10,000 requests handled by the Pre-Signed API.
- 10 TB of game assets delivered through CloudFront.
- Total monthly cost for the API stack: USD $47.95.
That’s less than the cost of a single AAA game… while distributing terabytes worth of player downloads over time.
Conclusion
By moving to Pre-Signed URLs, we’ve delivered a solution that is:
- Secure – Only authorized users can access downloads.
- Scalable – API Gateway, Lambda, and SQS scale automatically.
- Cost-efficient – Infrastructure overhead is minimal compared to raw CDN transfer charges.
- Manageable – Everything is deployed and maintained via Terraform IaC.
For studios and SaaS products dealing with large assets, this pattern is a proven way to reduce risk and keep bills under control — without sacrificing user experience.
✅ Next step for us: keep refining observability (tying SQS data into dashboards) and expanding entitlement logic for future titles.
Top comments (0)