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
- GCS:
@google-cloud/storage7.x; the runtime service account grantedroles/iam.serviceAccountTokenCreatoron itself (so it can callsignBlob) androles/storage.objectVieweron the bucket. - Azure:
@azure/storage-blob12.x and@azure/identity4.x; the app’s managed identity grantedStorage Blob Delegatoron the account andStorage Blob Data Readeron the container. - Node 20+ with a clock synchronised by NTP — both signatures embed timestamps.
- Uniform bucket-level access on GCS and “Allow blob public access” disabled on the Azure account.
Where the signature comes from
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
keyFilenameorcredentialsonnew Storage(). With Application Default Credentials on Cloud Run or GKE, the library has no private key, sogetSignedUrlcallsiam.serviceAccounts.signBlobautomatically. That call needs the Token Creator role on the service account itself — the most common cause ofPermission '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 changeinlinetoattachmentor 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 afilename*=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. startsOnfive 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?
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
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.