AI image API signed URLs: Secure Asset Delivery Explained
A customer leaves a workspace, but a generated product image still opens from yesterday’s chat message. AI image API signed URLs help limit access windows for application-owned images; they do not automatically revoke a departed user’s access. Understanding that distinction matters when your application generates confidential designs, customer portraits, or unreleased campaign assets.
This explainer separates image generation from delivery authorization, then demonstrates a local Python signing scheme. The code describes your application’s potential delivery layer, not a GenRelay protocol, endpoint, or documented security feature.
What are AI image API signed URLs?
AI image API signed URLs are image addresses accompanied by cryptographic proof that a trusted issuer authorized particular access conditions. A typical signature covers an immutable asset identifier, expiration timestamp, and permitted HTTP method. Changing a covered field invalidates that proof.
A bearer capability is a credential whose possession grants access. Many storage signed URLs behave this way: anyone holding the address can retrieve the object until the delivery service rejects it. They do not establish the viewer’s identity.
A signed URL can instead be one requirement within a session-authenticated delivery service. In that design, the server also verifies current user membership and object permissions. That distinction changes whether a copied link works in another browser.
For applications integrating the GenRelay image API, treat generation and application-owned delivery as separate concerns. This architecture does not assume that a generation response URL has any particular lifetime, storage policy, or access-control behavior. Confirm the actual response contract before designing ingestion.
How should tenant authorization govern image delivery?
Tenant authorization should control both URL issuance and retrieval when your product requires current membership checks. A tenant is an organizational boundary, such as a customer workspace, whose resources must remain isolated from other customers.
Use this sequence for assets that your application has permission to store:
- Ingest generated bytes into private storage and assign an immutable, opaque asset identifier.
- Record the owning tenant, storage location, content type, and deletion state in trusted metadata.
- Authenticate the requesting user and check their current permission for that asset before issuing a link.
- At retrieval, validate the signature and expiration, then enforce current authorization if using session-bound delivery.
- Resolve the identifier through metadata rather than accepting a client-supplied filesystem path or storage key.
Authentication establishes identity; authorization decides what that identity may access. A tenant identifier supplied in a query string establishes neither. Derive the active tenant from a verified session and validate membership against your permission system.
Private storage must also reject unauthenticated direct reads. Otherwise, protecting the application route leaves another retrieval path exposed. Avoid accepting arbitrary download destinations during ingestion; constrain source hosts and redirects to reduce server-side request forgery, where attacker-controlled URLs make your server contact unintended destinations.
Why is expiration different from revocation?
Expiration rejects a credential after a predetermined deadline; revocation rejects previously valid access because policy changed before that deadline. A stateless signature verifier cannot discover that a customer canceled access unless it consults current state or a relevant signing key stops being accepted.
A five-minute link limits future authorized retrieval under that verifier’s rules. It cannot erase downloaded bytes, screenshots, browser caches, or copies forwarded elsewhere. Likewise, deleting an origin object does not necessarily remove an existing cached response.
For targeted revocation, maintain an asset status or access-version value in trusted metadata and check it on each request. For session-bound delivery, removing workspace membership can deny subsequent requests if the delivery service checks fresh permissions. Rotating a signing key affects every link associated with that key, making it a broader operational action.
Document the intended revocation behavior explicitly: rejection on the next request, after a permission-cache refresh, or after an edge-cache invalidation completes. These are different service properties, not interchangeable promises.
How can you test a local Python signer and verifier?
You can test signature integrity, expiration, tenant isolation, and method restrictions with Python’s standard library. HMAC is a keyed message authentication code: it lets a verifier holding the same secret detect changes to signed data.
Save the following as signed_images.py and run python3 signed_images.py. It makes no network requests. The .invalid hostname is a reserved illustrative address, not a working service. The random key lasts for this process; restarting intentionally invalidates earlier demonstration links.
import hashlib
import hmac
import re
import secrets
import time
from urllib.parse import parse_qsl, urlencode, urlsplit
ORIGIN = "https://assets.example.invalid"
KEY = secrets.token_bytes(32)
MAX_TTL = 300
ID = re.compile(r"[A-Za-z0-9_-]{1,64}\Z")
# Trusted metadata, standing in for an authorization database.
OWNERS = {"img_001": "team_a"}
def valid_id(value):
return isinstance(value, str) and ID.fullmatch(value) is not None
def payload(tenant, asset, exp):
return f"v1\n{ORIGIN}\nGET\n{tenant}\n{asset}\n{exp}".encode("ascii")
def digest(tenant, asset, exp):
return hmac.new(KEY, payload(tenant, asset, exp), hashlib.sha256).hexdigest()
def sign(asset, authenticated_tenant, ttl=60, now=None):
# Caller must already have authenticated the user and checked membership.
if not valid_id(asset) or not valid_id(authenticated_tenant):
raise ValueError("invalid identifier")
if OWNERS.get(asset) != authenticated_tenant:
raise PermissionError("asset not authorized")
if type(ttl) is not int or not 1 <= ttl <= MAX_TTL:
raise ValueError("invalid lifetime")
now = int(time.time()) if now is None else now
exp = str(now + ttl)
query = urlencode({"tenant": authenticated_tenant, "exp": exp,
"sig": digest(authenticated_tenant, asset, exp)})
return f"{ORIGIN}/assets/{asset}?{query}"
def verify(url, authenticated_tenant, method="GET", now=None):
# authenticated_tenant must come from a verified session, not the URL.
if method != "GET" or not valid_id(authenticated_tenant):
return False
if not isinstance(url, str) or len(url) > 2048:
return False
if any(ord(c) <= 32 or ord(c) >= 127 for c in url):
return False
try:
u = urlsplit(url)
if (u.scheme != "https" or u.netloc != "assets.example.invalid"
or u.fragment):
return False
match = re.fullmatch(r"/assets/([A-Za-z0-9_-]{1,64})", u.path)
if match is None:
return False
pairs = parse_qsl(u.query, keep_blank_values=True,
strict_parsing=True, max_num_fields=3)
q = dict(pairs)
if len(pairs) != 3 or set(q) != {"tenant", "exp", "sig"}:
return False
# Require one canonical query encoding and field order.
if urlencode(q) != u.query:
return False
if list(q) != ["tenant", "exp", "sig"]:
return False
tenant, exp, signature = q["tenant"], q["exp"], q["sig"]
if not valid_id(tenant) or tenant != authenticated_tenant:
return False
if re.fullmatch(r"[1-9][0-9]{0,11}", exp) is None:
return False
if re.fullmatch(r"[0-9a-f]{64}", signature) is None:
return False
now = int(time.time()) if now is None else now
if not 0 < int(exp) - now <= MAX_TTL:
return False
asset = match.group(1)
if not hmac.compare_digest(digest(tenant, asset, exp), signature):
return False
return OWNERS.get(asset) == authenticated_tenant
except (ValueError, UnicodeError):
return False
if __name__ == "__main__":
now = 1_800_000_000
link = sign("img_001", "team_a", now=now)
assert verify(link, "team_a", now=now)
assert not verify(link, "team_b", now=now)
assert not verify(link, "team_a", method="POST", now=now)
assert not verify(link, "team_a", now=now + 60)
assert not verify(link.replace("img_001", "img_002"), "team_a", now=now)
assert not verify(link + "&exp=1800000999", "team_a", now=now)
changed = link[:-1] + ("0" if link[-1] != "0" else "1")
assert not verify(changed, "team_a", now=now)
del OWNERS["img_001"]
assert not verify(link, "team_a", now=now)
print("8 signed-image checks passed")
The verifier uses hmac.compare_digest for signature comparison rather than ordinary equality. Strict parsing rejects duplicate fields, unexpected parameters, malformed identifiers, and noncanonical encodings. The origin and method are signed to keep the credential scoped to this delivery context.
This is an illustrative implementation, not production middleware. It lacks user authentication, per-user permission checks, durable metadata, key rotation, an HTTP server, and image streaming. Production code must manage secrets outside source control, synchronize clocks, rate-limit requests, and review parser behavior across proxies. Do not trust a request’s arbitrary host header to construct the expected origin.
The test’s injected time is for repeatability; clients must never choose verification time. Real image serving must perform these checks before returning bytes. Add integration tests for expired sessions, revoked membership, deleted assets, and concurrent metadata changes.
Where can caches and logs expose signed image links?
Caches and observability systems can expose either the image bytes or the credential in its URL. A content delivery network, or CDN, stores responses near users; its cache key determines which requests share a stored response.
A cache hit must not skip required authorization. Including the full signed query in a cache key reduces accidental sharing but does not enforce expiration when a cached response remains fresh. Conversely, removing the signature from a cache key can create cross-user disclosure if the edge serves bytes before validating the request.
For sensitive session-bound images, start with Cache-Control: private, no-store and verify that intermediaries respect the policy. If edge caching is necessary, require authorization before every cache hit and align freshness with the credential deadline. Review AI image API cache isolation when designing tenant-specific caching.
Redact credential query strings from application logs, reverse-proxy logs, error trackers, analytics, and support exports. Set an appropriate Referrer-Policy, such as no-referrer, on pages displaying sensitive assets. Do not depend on browser defaults to prevent every form of URL disclosure. Retain nonsecret asset identifiers and decision codes for troubleshooting instead.
How do generation prices differ from secure delivery costs?
Generation prices pay for producing images; storage and delivery expenses belong in a separate budget. The following GenRelay generation prices were supplied for November 6, 2026, and do not establish storage retention, bandwidth allowances, or signed-link capabilities.
| Model | Output size | Supplied generation price |
|---|---|---|
| Nano Banana Pro | 1K / 2K | $0.030/image |
| Nano Banana Pro | 4K | $0.042/image |
| Nano Banana 2 | 1K | $0.020/image |
| Nano Banana 2 | 4K | $0.036/image |
| GPT-image-2 | Not specified in supplied price | $0.014/image |
For example, 10,000 Nano Banana 2 images at the supplied 1K rate represent $200 in generation charges before other costs. Compare the required output sizes when choosing generation settings, and confirm current pricing before committing a workload.
Model storage separately as retained bytes over time, request counts, delivered bandwidth, and any image transformation charges. Authorization lookups, logging, and invalidation operations can also carry infrastructure costs. No storage or delivery rates are assumed here.
What questions do developers ask about signed image URLs?
Developers most often need to clarify sharing, expiration, key exposure, and whether a fresh URL requires fresh generation.
Can someone forward a signed image URL?
Yes. A bearer link generally remains usable by another holder while accepted. The session-bound example additionally requires the authenticated tenant to match, but it does not implement individual-user permissions. Add those checks when colleagues within a tenant have different access rights.
Does an expired link delete the generated image?
No. Expiration changes credential acceptance, not storage retention. Deletion requires a separate lifecycle policy covering origin storage, derived thumbnails, backups, and caches. Existing downloads remain outside that deletion process.
Should the browser receive the signing secret?
No. Keep the signing secret on trusted servers. The browser receives the resulting URL or an application asset identifier, never the HMAC key or a generation API credential. A leaked signing key lets an attacker produce signatures for its scope.
Must the application regenerate an image after its link expires?
No, provided the application still retains an authorized copy. Authenticate the user again, check current asset permissions, and issue a fresh delivery credential. Keeping asset identity independent from URL lifetime avoids unnecessary generation and preserves a consistent audit trail.