Why GCS Signed URLs Become Complex with Workload Identity — and What That Reveals About Google’s Security Model
Understanding delegation, identity, and cryptographic authority behind GCS signed URLs in a Workload Identity world.
The Real Problem: Delegating Access Without Permanent Credentials
Most systems are built around identity. The first question that naturally comes to mind is: who should have access to what? Signed URLs take the opposite approach. It doesn't matter who the caller is: if the request has a valid cryptographic proof, then it is allowed to perform a specific action.
This process is entirely stateless. A backend signs an intention, and the resulting signature binds together the action, the resource, and a time boundary. The target system can then verify this cryptographic proof and accept or reject the request without storing any state or maintaining a durable relationship. In this model, cryptography does not represent identity—it removes the need for it by encoding authorization, context, and constraints directly into the request.
The most important part: a signed URL is a time-bound capability that authorizes a single, well-defined operation on a specific resource, without relying on identity.
What a Signed URL Actually Authorizes
Unlike an IAM identity, a signed URL does not carry a role—it carries a capability and that capability is intentionally limited. One URL authorizes one specific operation, on one specific resource, for a bounded period of time. Nothing more, nothing less.
Because the signed request itself is the source of truth, all relevant constraints must be encoded into it. Google refers to this signed request representation as the canonical request: the HTTP method, the target resource, time boundaries, and other parameters are all part of what is cryptographically bound.
This level of precision makes signed URLs fundamentally different from identity-based mechanisms. The use case is too specific to be expressed as a reusable OAuth token, and too narrow to justify creating or extending an IAM identity.
Anything that is not explicitly signed is not delegated.
How GCS Implements Delegation (and Why It Feels Overengineered)
To support this delegation model, Google relies on a specific signing mechanism known as V4 signatures. This signature scheme defines a canonical form of the HTTP request, along with the cryptographic algorithm used to bind that request to a service account acting as the signing authority.
In practice, there are two ways a signed URL can be produced:
- The backend signs the request locally using a service account private key (JSON key).
- The backend does not have access to a private key (the recommended setup) and instead asks Google IAM to sign the request on behalf of the service account.
In both cases, the signature is always produced in the name of a service account. The signed URL does not represent the caller’s identity, but it is still rooted in an IAM principal that acts as the authority issuing the delegation.
At signing time, IAM enforces whether the caller is allowed to sign on behalf of the service account (iam.serviceAccounts.signBlob). At access time, Cloud Storage verifies the cryptographic signature, checks the integrity and expiration of the request, and applies the permissions associated with that service account. No caller identity is established, and no state is stored during request evaluation.
This separation between who is allowed to sign and what is allowed to be accessed is a core reason why the mechanism often feels overengineered at first glance.
Centralized Signing: Why Google Refuses Distributed Secrets
Google has made a clear architectural choice: prioritizing security governance over developer convenience.
A distributed secret comes with inherent risks:
- no fine-grained revocation
- a single leak results in a full compromise
- a significantly larger attack surface, as the secret must exist in multiple places
AWS chose an HMAC-based model, relying on long-lived access keys and shared secrets. Google did not completely close the door to HMAC either, but it is not the default path.
The act of signing is treated as sensitive operation, performed in a controlled and isolated environment managed by Google. Private key never leave this boundary, allowing for controlled rotation, and rapid containment in case of compromise.
The workload itself does not carry this responsability. It cannot sign, and it does not have a long-lived secret. RSA is a cryptographic preference here—the natural consequence of a centralized signing model.
When Workload Identity Makes Private Keys Obsolete
Using a service account JSON key is undoubtedly the simplest path.
The signing operation is performed locally by the workload, using a long-lived private key:
- no network dependency
- no additional latency
- no signing quota
However, this simplicity comes at a significant cost:
- complex lifecycle management (manual rotation)
- high blast radius in case of leakage (source control, compromised runtime)
- possession of the key grants all permissions of the service account
For these reasons, long-lived JSON private keys are considered an anti-pattern.
The ability to sign must be removed from the workload entirely.
The Only Coherent Option: Signing via IAMCredentials
At this point, the constraints are clear: there is no private key, no local signing, and no distributed secret. This inevitably leads to the need for a centralized, isolated, and secure service responsible for issuing signatures. That service is IAMCredentials.
Despite its name, IAMCredentials is not an authentication API. It is a signing service operated by Google, allowing cryptographic operations to be performed on behalf of a service account, without ever exposing its private key.
Let us assume a signing service account A.
- The workload retrieves a short-lived OIDC token from the metadata server. This token represents its ephemeral identity.
- The service account
Aauthorizes the workload to impersonate it via Workload Identity. From this point on, the workload can act as service accountA, within the boundaries of its IAM permissions. - The workload uses the Security Token Service (STS) to exchange its OIDC token for a short-lived OAuth2 access token issued in the name of service account
A. - Using this OAuth2 token, the workload calls the
signBlobmethod of the IAMCredentials service (iamcredentials.googleapis.com). IAM verifies that the caller (acting as service accountA) is allowed to sign (iam.serviceAccounts.signBlob), then performs the signature using the private key stored and managed by Google. The private key is never exposed; the workload only receives the resulting signature, which it uses to construct the signed URL.
Once issued, the signed URL is trivial to consume. Cloud Storage handles everything:
- it verifies the signature using the public key of the signing service account,
- it validates expiration and request integrity,
- it applies the service account’s permissions locally,
- and it does so without storing state, calling IAM, or establishing any client identity.
Operational Implications: Latency, Quotas, Failure Modes
Security is the backbone of this architecture. Treating signing as a centralized, controlled operation has clear benefits, but it also impacts other system qualities such as latency, availability, and operational complexity.
The signing path now involves multiple actors: the metadata server, the Security Token Service, and the IAMCredentials API. Each of these components introduces network calls, potential points of failure, and is subject to Google Cloud API quotas. Compared to local signing, the overhead is real and measurable.
This design is the direct consequence of removing long-lived secrets and enforcing centralized control over cryptographic authority. The system trades immediacy and simplicity for auditability, revocation, and reduced blast radius.
As with most security-driven designs, this approach is recommended by default, but it is not universally optimal. High-frequency signing, ultra-low-latency paths, or disconnected environments may justify different trade-offs. The important point is that the complexity is intentional, and the cost is paid in exchange for strong security guarantees.
Putting It All Together: A Working Pattern with GKE
Let's now connect the model described so far with a concrete working setup.
Assume the following context:
- A private GCS bucket
- An external client that must temporarily download an object.
- No IAM identity created for the client.
- No proxying of data through the backend.
- A GKE cluster (or Cloud Run) with Workload Identity enabled.
- The workload itself must never hold a private key.
1) Define the signing authority
We start by creating a Google Cloud service account that will act as the signing authority. This account will not run code. Its sole responsibility is to issue delegations in the form of signed URLs.
# Create the signer service account
gcloud iam service-accounts create signer \
--display-name="Service Account for GCS Signed URLs" \
--project=brainstacks
This service account must be allowed to perform two things:
- Sign requests (via IAMCredentials)
- Access the target GCS objects (at access time)
# Allow the signer to read objects in the bucket
gcloud storage buckets add-iam-policy-binding gs://my-bucket \
--member="serviceAccount:[email protected]" \
--role="roles/storage.objectViewer"
To follow the least-privilege principle, we grant only the signBlob permission on the service account itself. This allows cryptographic signing, and nothing else.
# Create a minimal custom role for signing
gcloud iam roles create BlobSigner \
--project=brainstacks \
--title="Blob Signer Only" \
--description="Allows signing blobs via IAMCredentials" \
--permissions=iam.serviceAccounts.signBlob \
--stage=GA
# Attach it to the signer service account
gcloud iam service-accounts add-iam-policy-binding \
[email protected] \
--member="serviceAccount:[email protected]" \
--role="projects/brainstacks/roles/BlobSigner"
At this point, the signing authority is fully defined.
2) Bind the workload identity (KSA → GCP service account)
The workload itself runs with a Kubernetes Service Account (KSA). Workload Identity allows this KSA to act as the signer service account, without any key material.
# Create the Kubernetes Service Account
kubectl create serviceaccount signer -n default
# Bind it to the GCP service account
kubectl annotate serviceaccount signer -n default \
iam.gke.io/[email protected]
We then allow this workload identity to impersonate the signer service account:
gcloud iam service-accounts add-iam-policy-binding \
[email protected] \
--role roles/iam.workloadIdentityUser \
--member "serviceAccount:brainstacks.svc.id.goog[default/signer]"
This step is critical: it does not grant storage access, and it does not grant signing rights. It only allows the workload to act as the signer service account and obtain short-lived credentials.
3) Generate a signed URL (minimal Python example)
With the identity chain in place, generating a signed URL becomes straightforward.
The code below runs inside the workload (GKE pod or Cloud Run service). No private key is loaded. No secret is configured
import os
import datetime
import google.auth
from google.auth.transport.requests import Request
from google.cloud import storage
SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
def generate_signed_url(bucket: str, blob_path: str) -> str:
# Explicit signer identity (part of the canonical request)
signer_sa = os.environ["SIGNER_SA_EMAIL"]
# Application Default Credentials
creds, project = google.auth.default(scopes=SCOPES)
creds.refresh(Request()) # short-lived OAuth2 access token
client = storage.Client(project=project, credentials=creds)
blob = client.bucket(bucket).blob(blob_path)
return blob.generate_signed_url(
version="v4",
method="GET",
access_token=creds.token,
service_account_email=signer_sa,
expiration=datetime.timedelta(seconds=3600),
)
Depending on the SDK, the OIDC → STS token exchange may be entirely hidden behind Application Default Credentials. What matters is the resulting access token, issued in the name of the signer service account.
4) What actually happens at runtime
This concrete setup maps directly to the theoretical model described earlier:
- The workload obtains an ephemeral identity via the metadata server.
- That identity is exchanged for a short-lived OAuth2 token through STS.
- The workload calls IAMCredentials signBlob using that token.
- Google signs the canonical request using the signer service account’s private key.
- The workload constructs a time-bound capability (the signed URL).
- When the client uses the URL, Cloud Storage verifies the signature and applies the signer’s permissions locally.
This is the delegation model, fully realized.
The implementation backing this model — including both runtime and impersonated signing paths — lives in the brainstacks-labs repository: https://github.com/Alkindi42/brainstacks-lab/tree/main/gcs-signed-urls-workload-identity.
What This Reveals About Google’s Security Philosophy
Early cloud platforms optimized for immediacy. Infrastructure was designed to be usable with minimal friction: launch a virtual machine, expose a service, and it was often reachable by default. That simplicity accelerated adoption, but it also normalized broad trust and long-lived credentials.
Google's approach to signed URLs and Workload Identity reflects a clear shift away from that model. Security is no longer treated as a layer to be added later, but as a foundational constraint that shapes the entire architecture. Capabilities are narrowly scoped, secrets are centralized, and authority is deliberately removed from workloads.
JSON service account keys still exist, but they increasingly feel like a legacy of an earlier security model—maintained for compatibility, yet misaligned with modern threat assumptions. The resulting complexity is not accidental. It is the cost of treating cryptographic authority as something to be governed, audited, and constrained, rather than distributed for convenience.