DEV Community

Cover image for Building a Cost-Efficient Game Launcher with AWS Pre-Signed URLs
Lucas
Lucas

Posted on

Building a Cost-Efficient Game Launcher with AWS Pre-Signed URLs

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.

introduction


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:

  1. The launcher authenticates the player.
  2. It calls our /download-url endpoint with the requested asset ID.
  3. The backend Lambda validates entitlement and returns a signed URL with a short TTL (60–300s).
  4. The launcher immediately begins the download via CloudFront.
  5. 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"
}
Enter fullscreen mode Exit fullscreen mode

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})
    }
Enter fullscreen mode Exit fullscreen mode

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

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)