A signed URL lets someone who has no Google identity read or write one Cloud Storage object for a short time. The typical uses are a browser uploading a 2 GB video straight to a bucket, a partner downloading a nightly export, or a mobile app fetching a private image. Your backend decides who may do what. It turns that decision into a URL that carries a cryptographic signature, and from then on Cloud Storage serves the bytes without routing them through your servers.

The topic title pairs signed URLs with cookies, and that pairing hides the most common misunderstanding. Cloud Storage itself has no signed-cookie feature. Signed cookies belong to Cloud CDN. They protect a whole URL prefix served through a backend bucket, and they use a different key type, a different algorithm and a different verifier. This article covers both mechanisms, what each one checks, how to generate them in code without long-lived keys, a worked browser-upload example, the failures that show up in production, and how to choose between them. For background on buckets and IAM, see Google Cloud Storage and Google Cloud IAM.

What a signed URL is and what it proves

A V4 signed URL is a normal Cloud Storage XML API request with authentication moved into the query string. The signature binds the HTTP method, the bucket and object path, a set of headers, the signing time and the lifetime. When the request arrives, Cloud Storage rebuilds the same canonical request from what it actually received, recomputes the signature with the signer's public material, and compares the two. If any bound element differs, the request fails with SignatureDoesNotMatch. That covers a GET used where PUT was signed, a different object name, or a missing Content-Type that was signed.

Three facts follow from that design and shape everything else:

  • It is a bearer token. Anyone holding the URL has the access until it expires. Google's documentation says access lasts until the expiration time is reached or the signing key is rotated. Treat signed URLs like passwords in logs, analytics and referrer headers.
  • It acts as the signer. The request is authorised as the service account (or HMAC key owner) that signed it, so that principal needs the object permission, for example storage.objects.create for an upload. Grant the signer narrow roles on the target bucket only.
  • XML API only. Signed URLs work only against XML API endpoints such as https://storage.googleapis.com/BUCKET/OBJECT. They do not work against the JSON API paths under /storage/v1/.

V4 URLs can live at most 604,800 seconds (seven days). Because a signed URL is not a public grant, public access prevention does not affect it. Google states this directly, so you can enforce public access prevention on the bucket and still hand out signed URLs, which is the right default.

Anatomy of a V4 signed URL

The backend signs; the client talks to storage or the CDN directlyBrowser / append userYour backendauthn + authz decisionIAM signBlobor local key / HMAC secret1 ask for access2 sign bytes3 URL or cookiestorage.googleapis.comXML API: checks V4 signatureCloud CDN edgechecks HMAC URL or cookie4a signed URL4b cookieBucket (not public)PAP may stay enforcedread / write as signerfill SA readsTwo independent mechanisms: V4 signatures are verified by Cloud Storage, HMAC-SHA1 cookies by Cloud CDN.
Signing happens in your trust boundary; verification happens at Cloud Storage or at the CDN edge, never in your servers.

A V4 URL signed with a service account carries these query parameters:

ParameterMeaning
X-Goog-AlgorithmGOOG4-RSA-SHA256 for service account keys, GOOG4-HMAC-SHA256 for HMAC keys
X-Goog-Credentialsigner and scope: SA_EMAIL/20261004/auto/storage/goog4_request
X-Goog-Datesigning time in basic ISO 8601, for example 20261004T101400Z
X-Goog-Expireslifetime in seconds, at most 604800
X-Goog-SignedHeaderssemicolon list of headers bound into the signature; always includes host
X-Goog-Signaturehex-encoded signature

The string that gets signed is built in two steps. First comes the canonical request: the method, the percent-encoded path, the sorted canonical query string (every parameter above except the signature), the lower-cased canonical headers, the signed-header list and the literal UNSIGNED-PAYLOAD. Second, the string to sign is the algorithm, the timestamp, the credential scope and the hex SHA-256 of that canonical request, joined by newlines. Hand-rolling this is possible and documented, but the client libraries already implement it. Hand-rolled signers are where subtle encoding bugs live, for example spaces versus %20 or unsorted query parameters. Use the library unless you are on a platform without one.

Signing without long-lived keys

The worst way to sign is with a downloaded service account JSON key baked into a container. Keys leak, and rotating them invalidates every outstanding URL at once. On Cloud Run, GKE or Compute Engine, sign through the IAM Credentials signBlob API instead. The private key never leaves Google, and your runtime identity only needs roles/iam.serviceAccountTokenCreator on the signing service account. The Python library does this when you pass service_account_email and access_token:

from datetime import timedelta

import google.auth
from google.auth.transport.requests import Request
from google.cloud import storage

credentials, project = google.auth.default()
client = storage.Client(credentials=credentials, project=project)


def signed_download(bucket: str, name: str, minutes: int = 10) -> str:
    credentials.refresh(Request())          # the token must be fresh for signBlob
    blob = client.bucket(bucket).blob(name)
    return blob.generate_signed_url(
        version="v4",
        expiration=timedelta(minutes=minutes),
        method="GET",
        service_account_email=credentials.service_account_email,
        access_token=credentials.token,
        response_disposition=f'attachment; filename="{name.rsplit("/", 1)[-1]}"',
    )

Two details matter. refresh() runs first because metadata-server credentials start without a token. And response_disposition is signed too, so a client cannot change it to render an uploaded HTML file inline in your domain's context. Every signBlob call is a remote request with its own quota. If you sign thousands of URLs per second, cache them per object for part of their lifetime rather than signing on every page view.

Worked example: direct browser uploads

Here is the common case end to end: users upload profile videos up to 500 MB without the bytes touching your API servers. The backend authenticates the user, picks the object name itself (never trust a client-chosen path), and signs a PUT that binds the content type:

import uuid


def signed_upload(user_id: str, content_type: str) -> dict:
    if content_type not in {"video/mp4", "video/webm"}:
        raise ValueError("unsupported type")
    name = f"uploads/{user_id}/{uuid.uuid4()}.bin"
    credentials.refresh(Request())
    url = client.bucket("media-incoming").blob(name).generate_signed_url(
        version="v4",
        expiration=timedelta(minutes=15),
        method="PUT",
        content_type=content_type,          # client MUST send exactly this header
        headers={"x-goog-content-length-range": "0,524288000"},   # 500 MB cap
        service_account_email=credentials.service_account_email,
        access_token=credentials.token,
    )
    return {"url": url, "object": name,
            "headers": {"Content-Type": content_type,
                        "x-goog-content-length-range": "0,524288000"}}

The browser sends fetch(url, {method: 'PUT', headers, body: file}). Because that is a cross-origin request with a custom content type, the bucket needs a CORS policy, or the preflight fails and the console shows a CORS error that looks like an authentication problem:

# cors.json
[{"origin": ["https://app.example.com"],
  "method": ["PUT"],
  "responseHeader": ["Content-Type", "x-goog-content-length-range"],
  "maxAgeSeconds": 3600}]

gcloud storage buckets update gs://media-incoming --cors-file=cors.json

The size cap comes from the x-goog-content-length-range header. Because it is signed, the client must send it, and Cloud Storage rejects a PUT outside the range with 400. Still validate content after the fact: a finalize event triggers a function that inspects the object, then copies it into a serving bucket or deletes it. Keep the incoming bucket separate, with a lifecycle rule that deletes anything older than a day, so abandoned or rejected uploads cost nothing.

Resumable uploads, POST policies and HMAC keys

Resumable uploads. For large files, sign a POST with the header x-goog-resumable: start. The response returns a session URI, and Google's documentation notes that later chunk PUT requests use that session URI as their authentication. Only the initiating request needs a signature, and the session outlives the URL's short expiry, which is what you want for a flaky mobile connection.

POST policy documents. For HTML form uploads, or when you need server-enforced conditions, the library generates a signed policy that the form posts with the file. Conditions can bound the size and pin the key prefix:

credentials.refresh(Request())
policy = client.generate_signed_post_policy_v4(
    "media-incoming", f"uploads/{user_id}/{uuid.uuid4()}.bin",
    expiration=timedelta(minutes=15),
    conditions=[["content-length-range", 1, 500 * 1024 * 1024]],
    fields={"Content-Type": "video/mp4"},
    service_account_email=credentials.service_account_email,
    access_token=credentials.token,
)
# policy["url"] is the form action; policy["fields"] become hidden inputs

HMAC keys. HMAC keys tied to a service account give GOOG4-HMAC-SHA256 URLs and suit non-Google runtimes or S3-compatible tooling. They are long-lived secrets, so store them in Secret Manager and rotate them.

Signed cookies come from Cloud CDN

A signed URL covers exactly one object. That fails for HLS or DASH video, where a player fetches a manifest and then hundreds of segment URLs it builds itself, and for a static site behind a login. Cloud CDN signed cookies solve this. One cookie authorises every URL under a prefix until it expires. The setup is a bucket fronted by a backend bucket on an external Application Load Balancer with Cloud CDN enabled:

head -c 16 /dev/urandom | base64 | tr +/ -_ > cdn-key      # 128-bit key
gcloud compute backend-buckets add-signed-url-key media-backend \
    --key-name key-2026-10 --key-file cdn-key
gcloud storage buckets add-iam-policy-binding gs://media-serving \
    --member=serviceAccount:service-PROJECT_NUMBER@cloud-cdn-fill.iam.gserviceaccount.com \
    --role=roles/storage.objectViewer

The cache-fill service account reads the private bucket for the CDN, and the bucket must not grant allUsers read, or the signature protects nothing. The cookie is named Cloud-CDN-Cookie. Its value is four colon-separated fields in this order: a URL-safe base64 URLPrefix, a Unix Expires timestamp, KeyName, and a URL-safe base64 HMAC-SHA-1 Signature over the first three:

import base64, hashlib, hmac, time


def cdn_cookie(prefix: str, key_name: str, b64_key: str, ttl_s: int) -> str:
    enc_prefix = base64.urlsafe_b64encode(prefix.encode()).decode()
    policy = f"URLPrefix={enc_prefix}:Expires={int(time.time()) + ttl_s}:KeyName={key_name}"
    sig = hmac.new(base64.urlsafe_b64decode(b64_key), policy.encode(), hashlib.sha1).digest()
    value = f"{policy}:Signature={base64.urlsafe_b64encode(sig).decode()}"
    return (f"Cloud-CDN-Cookie={value}; Domain=media.example.com; Path=/; "
            f"Secure; HttpOnly; Max-Age={ttl_s}")

# prefix example: "https://media.example.com/videos/course-42/"

Your login endpoint sets this header after it checks entitlement. The player then fetches segments with no per-file signing. Cloud CDN also supports per-URL signing with Expires, KeyName and Signature query parameters using the same keys, which is handy for one-off links served through the CDN. A backend can hold more than one key, so rotation is: add the new key, start signing with it, wait one maximum lifetime, then delete the old key. Note the signed-url-cache-max-age setting, which defaults to one hour and caps how long signed responses stay cached.

Choosing a mechanism

NeedUseWhy
One private download or uploadGCS V4 signed URLno CDN needed; method and headers are bound
Upload with size or prefix limitsPOST policy documentserver-enforced conditions
Large or flaky uploadssigned resumable initiationsession URI survives a short expiry
Many files under one path (video, static site)Cloud CDN signed cookieone credential per prefix; edge caching
Hot public-ish content with a paywallCloud CDN signed URL or cookiecache hit ratio; storage never sees the traffic
Access that must be revocable instantlyyour own proxy or IAM-authenticated accessbearer tokens cannot be recalled individually

The last row is the real trade-off. Signed credentials are cheap and scale with no state, but you cannot revoke a single URL. Your only levers are short lifetimes, rotating or deleting the key (which kills every outstanding URL signed with it), or removing the signer's permission. If a compliance requirement says "revoke access within 60 seconds", use five-minute URLs issued on demand, or serve through an authenticating proxy.

Failure modes

  • SignatureDoesNotMatch on upload. The client sent a different Content-Type than the one signed, or a library added a header you signed by accident. Compare the canonical request in the error body with what you expected.
  • ExpiredToken or early expiry. The signer's clock is skewed, or a reverse proxy cached a page holding a URL. Never cache HTML that embeds signed URLs longer than the URL's own lifetime.
  • 403 when the signature is valid. The signing service account lacks the object permission, or the object name points at a bucket it has no role on. The signature proves identity; IAM still decides.
  • signBlob permission denied. The runtime identity lacks Token Creator on the signing account, or you forgot refresh() and passed an empty token.
  • CORS errors in the browser. No CORS policy on the bucket, the wrong origin, or Content-Type missing from responseHeader.
  • URLs leaking. Signed URLs pasted into tickets, captured by analytics, or sent in Referer headers. Use short lifetimes and set a strict referrer policy on pages that link to them.
  • Cookie ignored by the CDN. The cookie domain or path does not cover the media hostname, the prefix was encoded with standard rather than URL-safe base64, or the bucket is still public, so the CDN never needed the cookie.

Operating signed access

Turn on Data Access audit logs for Cloud Storage on buckets that serve signed traffic, because reads are not logged by default. In those logs, signed requests appear under the signing principal. Log the object name and the user ID in your backend when you sign, so a download can be traced back to the person it was issued to. Alert on spikes in signBlob calls and on 403 rates per bucket. Keep signing in one small, well-reviewed module with an allowlist of buckets, methods and maximum lifetimes, because one bug there becomes an access-control bug for every object. Pick lifetimes deliberately: minutes for uploads and sensitive downloads, hours for media sessions through the CDN, and the seven-day maximum only for partner batch transfers you can rotate around. For retention and immutability controls on the buckets themselves, see Cloud Storage retention.

What to do next

  1. Enforce public access prevention on every bucket you plan to sign for, and confirm signed URLs still work.
  2. Create a dedicated signing service account with only the object roles it needs on specific buckets.
  3. Switch to keyless signing: grant Token Creator to your runtime identity, delete any JSON keys, and call generate_signed_url with service_account_email and access_token.
  4. For browser uploads, add a bucket CORS policy, bind Content-Type and a length range, and validate objects on finalize before promoting them.
  5. For video or multi-file content, put a backend bucket behind Cloud CDN (see Cloud CDN), add two signing keys, and issue Cloud-CDN-Cookie from your login endpoint.
  6. Write down your revocation story: maximum lifetime, key rotation procedure, and who can delete a key.
  7. Enable Data Access audit logs and alert on 403 and signBlob anomalies.
Key takeaway: A V4 signed URL is a bearer credential that binds method, object, headers and lifetime, is verified by Cloud Storage through the XML API, and acts with the signer's permissions. Sign keylessly through signBlob, keep lifetimes short, and bind content types. Use POST policies for upload limits. Signed cookies are a Cloud CDN feature for whole prefixes, signed with HMAC-SHA-1 keys you rotate by overlapping them.