Player Avatars in Private Object Storage: Signed URLs, Direct Uploads, and Cache Keys
A private bucket hands you one guarantee and takes another one away: every read is authorized, and no link you give out stays valid forever. In short, if a player avatar image renders from a public URL today and stops wo
A private bucket hands you one guarantee and takes another one away: every read is authorized, and no link you give out stays valid forever. In short, if a player avatar image renders from a public URL today and stops working the moment the bucket becomes private, nothing is broken β the object storage is doing precisely what a private ACL means, and the signed download URL expires because the signature carries its own deadline inside the query string. The explanation is mechanical and boring. What deserves your attention is the second-order damage: what that deadline does to your CDN, to the browser cache, and to the JSON payload your game client cached three hours ago.
That deadline is a feature. It is also the thing that quietly ruins your delivery path.
The constraint: player uploads must not pass through your app
Start with the upload, because the upload is what forces the access-control decision, and everything about delivery follows from it. A studio shipping player-generated content moves two very different payloads: avatars around a couple hundred kilobytes, and replay clips or screenshot bundles that run from 40 MB into the hundreds. S3-compatible APIs cap a single PUT at 5 GB and expect multipart for anything larger, so the ceiling isn't the problem. The problem is the path.
Proxying those bytes through a request handler turns a file upload into a memory, timeout, and egress problem on machines whose actual job is running game logic.
So the client writes to storage directly, and you hand it a scoped, expiring signature instead of an account credential β which is correct, and which is also the exact moment the delivery model gets decided for you. The same signing machinery that authorizes the write is what authorizes the read. A store designed around per-request signatures usually doesn't offer a public-read ACL at all; the public_url field in its API response comes back empty because there is genuinely nothing to put there. Some S3-compatible services do expose an opt-in public delivery mode over a custom domain, and others keep every object private by design. That difference is worth checking before you model your schema around a permanent image URL.
How do you serve a private avatar image when the public URL is empty?
You mint a signed GET at the moment something needs to render, and you keep the bucket and key β never the signed link β in your database. Storing the signed URL in a players row is the most durable-looking version of this mistake: the row survives, the signature doesn't, and a week later the profile screen is full of 403s from links that were perfectly valid at the moment they were written.
import boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url="https://storage.example-studio.net", # any S3-compatible endpoint
config=Config(signature_version="s3v4"),
)
def presign_avatar_upload(player_id: str, content_type: str) -> dict:
"""The game client PUTs the bytes itself; the API server only signs."""
key = f"avatars/{player_id}/original"
url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "player-media", "Key": key, "ContentType": content_type},
ExpiresIn=300, # five minutes is plenty for a 200 KB avatar
)
# The client must send exactly this Content-Type, or the signature won't match.
return {"url": url, "headers": {"Content-Type": content_type}}
def presign_avatar_read(player_id: str, seconds: int = 600) -> str:
"""No unauthenticated URL exists for a private object, so mint one per view."""
return s3.generate_presigned_url(
"get_object",
Params={"Bucket": "player-media", "Key": f"avatars/{player_id}/original"},
ExpiresIn=seconds, # SigV4 query signing caps this at 604800 (7 days)
)
Three limits are baked into those two functions and none of them are negotiable. SigV4 query-string signing tops out at seven days, so "permanent link" is not on the menu. The signing timestamp has to sit within fifteen minutes of the storage service's clock, which means a container with drifting time produces 403s that look exactly like an expired link and are diagnosed as one for hours. And a browser upload needs the bucket's CORS rules to allow the PUT, the Content-Type header and any custom headers you signed, or the preflight fails before a single byte moves.
An expired signature returns 403, not 404. Read your access logs accordingly.
What an expiring link does to your cache
Here is the part that rarely shows up in the storage documentation, and it's where the access-control decision turns into a delivery-performance decision. Caches β the browser's, the CDN's, the one your reverse proxy runs β key on the full URL, query string included. A signature is query string. So every time your profile endpoint mints a fresh link for the same unchanged 200 KB avatar, you have created a brand-new cache key, guaranteed a miss at the edge, and sent another request to the origin for bytes that thousands of players already have. On a leaderboard rendering sixty avatars, that arithmetic gets ugly fast, and it degrades worst for exactly the players sitting farthest from your origin region. Worse, the failure is invisible in a staging environment with four test accounts and a warm local cache; it only appears as an edge hit ratio that never climbs above single digits.
The image didn't change. Its name did.
There are three honest ways out, and each one moves the enforcement point somewhere else. Content-addressed keys plus Cache-Control: public, max-age=31536000, immutable fix cacheability for anything you are willing to make publicly readable, since the URL only changes when the bytes change. A stable application path that redirects to a freshly signed link keeps the cache key steady at the layer your players actually hit, at the cost of one extra round trip. Or you move the credential out of the query string entirely β signed cookies or an edge-issued token β so the path stays constant and the CDN, rather than the origin, decides who gets served. That last option is the one people underestimate: it works, and it also means your access-control logic now lives in edge configuration, which is a different team, a different deploy pipeline, and a different set of things that silently drift.
Three delivery paths: a comparison by enforcement point
| Delivery path | Enforcement point | Cache key | Revocation | Failure it hides |
|---|---|---|---|---|
| Public objects on a custom domain | None after upload | Stable, edge-friendly | Delete or re-key the object | Anything private that leaks in via a shared prefix |
| Signed GET minted per render | Object store | New key per render | Wait for expiry | Edge hit ratio collapse and 403s from cached HTML |
| App path redirecting to a signed GET | Your application | Stable at the app layer | Immediate at the app layer | Redirect cached longer than the signature it points at |
| Signed cookie or edge token | CDN | Stable | Cookie lifetime | Access rules drifting out of sync with the app |
The decision rule I'd apply to a gaming workload is narrower than the table suggests. If an avatar is visible to anyone holding a profile link β public leaderboards, spectator pages, anything a search engine should index β the defensible design is a separate delivery bucket holding only derived, resized, moderated avatars, with originals staying private. If avatars are only ever shown to authenticated players, guild officers, or a moderation queue, stick with signed reads and pay the cache penalty knowingly.
The catch is revocation, and it is the trade-off that should decide the argument. A signed URL cannot be individually withdrawn before it expires; the practical remedies are short lifetimes, deleting or re-keying the object, or rotating the signing credential and invalidating every outstanding link at once. For a moderation flow where a player uploads something that must stop being reachable within seconds, a seven-day signature is not a good fit, and neither is a public object with a year-long immutable cache header sitting in a hundred edge nodes.
There is no configuration that gives you instant revocation, a stable cache key, and zero application code. Pick two.
Rollout without breaking links already in the wild
Migrating a live game is where this gets tedious rather than hard. Stop persisting signed URLs anywhere β database columns, notification payloads, cached API responses β and store the bucket and key instead. Introduce one stable route per asset class, ship it behind a flag, and keep the old field populated until client versions in the wild age out, because a mobile build from four months ago is still calling your old endpoint.
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
app = FastAPI()
SIGNED_TTL = 600 # seconds the signature stays valid
REDIRECT_TTL = 240 # always shorter, so a cached redirect can't outlive its target
@app.get("/players/{player_id}/avatar")
def avatar(player_id: str):
target = presign_avatar_read(player_id, seconds=SIGNED_TTL)
response = RedirectResponse(target, status_code=302)
response.headers["Cache-Control"] = f"private, max-age={REDIRECT_TTL}"
return response
Keep the redirect's max-age strictly inside the signature's lifetime and that whole class of expiry bug disappears. Then instrument the thing: the 403 rate on media paths, the edge hit ratio, and the age distribution of signatures at the moment they're served. Two tests earn their keep in CI β one that freezes the clock and asserts behaviour on both sides of the expiry boundary, and one CORS preflight test against a self-hosted MinIO node, which signs identically and costs nothing to run per commit. As far as I can tell, most teams find the clock-skew case only in production, probably because it needs a deliberately wrong clock to reproduce and nobody writes that test until it has already cost them a night.
None of this makes private storage harder than it should be. It just means the delivery path is a design decision you make on purpose, rather than one you inherit from whichever ACL happened to be the default.
Sources
- MDN, Cross-Origin Resource Sharing (CORS): https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- MDN, HTTP caching: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching
- AWS, Using presigned URLs (Amazon S3): https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- AWS, Signing AWS API requests (Signature Version 4): https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv.html
- Cloudflare R2 documentation: https://developers.cloudflare.com/r2/
- MinIO client SDK documentation: https://min.io/docs/minio/linux/developers/python/API.html
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.