Serving Private GCS and Azure Media with Signed URLs

On Google Cloud Storage, sign V4 read URLs with file.getSignedUrl({ version: "v4", action: "read", expires }) using a service account that signs through the IAM Credentials API instead of a downloaded key; on Azure Blob, request a user delegation key with the app’s Entra ID identity and build a read-only SAS with generateBlobSASQueryParameters, scoped to one blob and a few minutes — and in both cases set the response headers you need (Content-Disposition, Content-Type) as signed overrides.

Keeping uploaded media private is the default any serious product needs: a user’s documents, paid content, unpublished drafts. The storage services all support time-limited signed access, but each has a way to do it that leaves a long-lived secret lying around — a service account JSON key, a storage account key — and a way that does not. This page is part of secure media delivery in media processing and delivery pipelines. The upload-side configuration for the same buckets is in configuring CORS for GCS and Azure Blob uploads.

When to use this approach

  • Media lives in GCS or Azure Blob and must not be publicly readable.
  • Access is per user or per request — a download button, a preview, a private feed — rather than a whole package fetched by a player (for HLS, use a CDN with signed cookies, as in CloudFront signed URLs vs signed cookies for media, or Cloud CDN signed cookies).
  • Your application runs with a workload identity (GKE Workload Identity, Cloud Run service account, Azure managed identity) and you want no storage keys in configuration.

Prerequisites

  1. GCS: @google-cloud/storage 7.x; the runtime service account granted roles/iam.serviceAccountTokenCreator on itself (so it can call signBlob) and roles/storage.objectViewer on the bucket.
  2. Azure: @azure/storage-blob 12.x and @azure/identity 4.x; the app’s managed identity granted Storage Blob Delegator on the account and Storage Blob Data Reader on the container.
  3. Node 20+ with a clock synchronised by NTP — both signatures embed timestamps.
  4. Uniform bucket-level access on GCS and “Allow blob public access” disabled on the Azure account.

Where the signature comes from

Keyless signing on GCS and Azure On GCS, the app's service account asks the IAM Credentials API to sign the canonical request, receiving a signature without ever holding a private key. On Azure, the app's managed identity requests a user delegation key valid for up to seven days and signs SAS tokens locally with it. No downloaded keys on either cloud Google Cloud Storage Cloud Run app service account IAM Credentials signBlob one API call per signature max expiry 7 days (V4) X-Goog-Algorithm=GOOG4-RSA-SHA256 Azure Blob Storage App Service managed identity delegation key ≤ 7 days, cached sign many SAS locally revocable by revoking the key skoid / sktid in the SAS Account-key SAS and JSON service-account keys work too — and are exactly what leaks in a repository.
GCS signs remotely per URL; Azure fetches a short-lived key once and signs locally, so cache that key.

Implementation

import { Storage } from "@google-cloud/storage";
import {
  BlobServiceClient,
  BlobSASPermissions,
  generateBlobSASQueryParameters,
  SASProtocol,
  type UserDelegationKey,
} from "@azure/storage-blob";
import { DefaultAzureCredential } from "@azure/identity";

// ---------- Google Cloud Storage ----------
const gcs = new Storage();   // Application Default Credentials: the runtime service account

export async function gcsReadUrl(
  bucket: string,
  key: string,
  opts: { ttlSeconds?: number; downloadName?: string; contentType?: string } = {},
): Promise<string> {
  const [url] = await gcs.bucket(bucket).file(key).getSignedUrl({
    version: "v4",
    action: "read",
    expires: Date.now() + (opts.ttlSeconds ?? 300) * 1000,
    // Signed response overrides: the object's stored metadata is not changed.
    responseDisposition: opts.downloadName
      ? `attachment; filename="${opts.downloadName.replace(/["\\]/g, "_")}"`
      : undefined,
    responseType: opts.contentType,
  });
  return url;
}

// ---------- Azure Blob Storage ----------
const ACCOUNT = process.env.AZURE_STORAGE_ACCOUNT!;
const blobService = new BlobServiceClient(`https://${ACCOUNT}.blob.core.windows.net`, new DefaultAzureCredential());

let cachedKey: { key: UserDelegationKey; refreshAt: number } | null = null;

async function delegationKey(): Promise<UserDelegationKey> {
  const now = Date.now();
  if (cachedKey && now < cachedKey.refreshAt) return cachedKey.key;
  const starts = new Date(now - 5 * 60_000);                 // tolerate clock skew
  const expires = new Date(now + 6 * 3_600_000);             // 6 h; refresh after 5 h
  const key = await blobService.getUserDelegationKey(starts, expires);
  cachedKey = { key, refreshAt: now + 5 * 3_600_000 };
  return key;
}

export async function azureReadUrl(
  container: string,
  blobName: string,
  opts: { ttlSeconds?: number; downloadName?: string; contentType?: string } = {},
): Promise<string> {
  const key = await delegationKey();
  const now = Date.now();
  const sas = generateBlobSASQueryParameters({
    containerName: container,
    blobName,
    permissions: BlobSASPermissions.parse("r"),              // read only
    startsOn: new Date(now - 5 * 60_000),
    expiresOn: new Date(now + (opts.ttlSeconds ?? 300) * 1000),
    protocol: SASProtocol.Https,
    contentDisposition: opts.downloadName
      ? `attachment; filename="${opts.downloadName.replace(/["\\]/g, "_")}"`
      : undefined,
    contentType: opts.contentType,
  }, key, ACCOUNT).toString();
  return `https://${ACCOUNT}.blob.core.windows.net/${container}/${encodeURI(blobName)}?${sas}`;
}

// Usage
console.log(await gcsReadUrl("media-private", "uploads/9c1f/report.pdf", { downloadName: "Q3 report.pdf" }));
console.log(await azureReadUrl("media", "uploads/9c1f/report.pdf", { ttlSeconds: 120 }));

Line-by-line on the parameters that matter

  • No keyFilename or credentials on new Storage(). With Application Default Credentials on Cloud Run or GKE, the library has no private key, so getSignedUrl calls iam.serviceAccounts.signBlob automatically. That call needs the Token Creator role on the service account itself — the most common cause of Permission 'iam.serviceAccounts.signBlob' denied.
  • V4 signing. V2 signatures are deprecated; V4 supports up to seven days’ expiry, is the only scheme that works with some newer bucket features, and has the same query shape as S3’s SigV4.
  • responseDisposition / contentDisposition. Signed response-header overrides let one stored object be viewed inline in one place and downloaded with a friendly filename in another. They are part of the signature, so a user cannot change inline to attachment or rename the file.
  • Sanitising the filename. User-supplied names end up in a header. Quotes and backslashes break the filename="…" syntax; replace them. For non-ASCII names, add a filename*=UTF-8''… parameter as well.
  • User delegation key cached for five of its six hours. Fetching one per request adds a round trip to Entra ID and can hit throttling. One key signs any number of SAS tokens; revoking it (with revokeUserDelegationKeys) invalidates every SAS signed with it — a clean kill switch.
  • startsOn five minutes in the past. Azure rejects a SAS whose start time is ahead of its own clock. Backdating by a few minutes absorbs skew between your host and the storage service.
  • SASProtocol.Https. A SAS over HTTP exposes the token to anything on the network path. Forcing HTTPS in the signature makes the service reject plain-HTTP use.

How long should a read URL live?

Recommended signed URL lifetimes by use A download button needs about two to five minutes. An image in a page needs about the page's session, up to an hour. An email link needs up to seven days but should route through your app to re-sign. A shared public link should not be a signed URL at all. Match the TTL to how the URL is used download button 2–5 min <img> on a page 15–60 min link in an email link to your app, which re-signs on click "anyone with link" not a signed URL — a share record your app checks A signed URL is a bearer token: anyone holding it has access until it expires.
Short TTLs are free when your app issues URLs on demand; long ones are a leak waiting to be forwarded.

The pattern that avoids long TTLs everywhere is indirection: store and share links to your own endpoint (/files/9c1f/download), which authorises the viewer and redirects with a fresh, minutes-long signed URL. The signed URL never appears in an email, a chat message or a browser history entry that outlives its usefulness.

Configuration gotchas

GCS: Permission 'iam.serviceAccounts.signBlob' denied on resource (or it may not exist). Grant the runtime service account roles/iam.serviceAccountTokenCreator on itself, and enable the IAM Service Account Credentials API in the project.

GCS: SignatureDoesNotMatch only for some files. Object names with spaces or + signs were double-encoded when you built the URL yourself. Use the URL getSignedUrl returns verbatim; do not re-encode it.

Azure: AuthorizationPermissionMismatch when requesting the delegation key. The identity has data roles but not Storage Blob Delegator (or a role with generateUserDelegationKey). It is a separate control-plane permission.

Azure: Signature not valid in the specified time frame. The SAS start time is in the future from the service’s view, or the delegation key had already expired. Backdate startsOn, and refresh cached delegation keys before their expiry rather than at it.

Auditing and revoking access

Signed URLs are bearer credentials, so the useful questions after an incident are “who was given access to this file, and can I take it back?” Neither storage service answers the first on its own, because signing happens in your application — so log it there. Record every signature you issue with the user, object key, expiry and the request that caused it. That log costs almost nothing and turns “a private file was shared on a forum” into a lookup of which account generated the URL.

Revocation differs between the two clouds. On GCS, a V4 signed URL made with a service account stays valid until it expires; the emergency lever is removing the service account’s read permission on the bucket or object, which invalidates every URL it signed, or disabling the service account itself. Short TTLs are the everyday control. On Azure, revoking user delegation keys for the account invalidates every SAS signed with any of them in one call, and new keys can be requested immediately — which is why caching the delegation key for hours is safe: the kill switch does not depend on its lifetime.

Pair both with storage access logs (GCS Data Access audit logs, Azure Storage analytics logs) so you can see which signed URLs were actually used, from where, and how many times. A URL used from dozens of IPs in a few minutes is a leak, whatever its TTL.

A proxy endpoint that signs on demand

Redirecting through an authorising endpoint The browser requests a stable app URL for a file. The app checks the session and permissions, signs a five-minute URL and responds with a 302 redirect. The browser follows the redirect and downloads directly from storage. Stable link out, fresh signature in browser your app GCS / Azure GET /files/9c1f/download 302 → signed URL (5 min) GET signed URL — bytes direct from storage The shared link never expires; the signature always does — and storage serves the bytes.
Authorisation stays in your app on every click, while the bytes never pass through it.

Verification

# GCS: signed URL works, bare object URL is refused.
curl -s -o /dev/null -w '%{http_code}\n' "$(node sign-gcs.mjs uploads/9c1f/report.pdf)"
# 200
curl -s -o /dev/null -w '%{http_code}\n' https://storage.googleapis.com/media-private/uploads/9c1f/report.pdf
# 403

# Azure: the SAS allows read only — a PUT with the same token is refused.
URL="$(node sign-azure.mjs uploads/9c1f/report.pdf)"
curl -s -o /dev/null -w '%{http_code}\n' "$URL"                                   # 200
curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'x-ms-blob-type: BlockBlob' --data 'x' "$URL"   # 403

# The signed Content-Disposition override is applied.
curl -s -D - -o /dev/null "$URL" | grep -i content-disposition

Frequently Asked Questions

Can I put Cloud CDN or Azure Front Door in front of these?

Yes, and for anything viewed repeatedly you should. Cloud CDN supports its own signed URLs and signed cookies with a keyset attached to the backend bucket; Azure Front Door can use a private link to storage with token authentication at the edge. Signed storage URLs, by contrast, bypass caching because every URL is unique.

Is an account-key SAS ever acceptable?

For local development against Azurite, sure. In production, a leaked account key grants full control of every container in the account until you rotate it; a user delegation SAS is limited to the identity’s own permissions and can be revoked centrally.

How do I sign many URLs quickly on GCS?

Each keyless V4 signature is an IAM API call. For pages listing dozens of private images, either sign in parallel with a small concurrency limit, cache signatures for a fraction of their TTL, or put Cloud CDN signed cookies in front so one credential covers the whole page.