Generate GCS signed URLs without a service account key
Sign Cloud Storage download and upload URLs on Cloud Run, GKE, or your own machine with no JSON key file, and fix the setup where reads work but signing fails.
A Cloud Storage V4 signed URL is signed with a service account’s private key, but your app doesn’t need to hold that key. When gcs() gets no keyFilename or credentials, @google-cloud/storage uses Application Default Credentials (ADC). If those credentials carry no private key, the library sends the string to sign to the signBlob method of the IAM Service Account Credentials API, which signs it as the service account with a Google-managed key. files.url() and files.signedUploadUrl() don’t change. IAM does.
Signing needs two things that reading and writing objects don’t: the IAM Service Account Credentials API must be enabled, and the identity making the call needs iam.serviceAccounts.signBlob on the service account it signs as. That’s why a Cloud Run service can download objects all day and then fail the first time it calls url(). And a plain gcloud user login can’t sign at all, because a user account has no service account email to sign as.
Before you start
- A Google Cloud project with a bucket. This guide uses one called
uploads, with uniform bucket-level access. - The gcloud CLI, logged in as someone who can create service accounts and change IAM policies.
- Written against files-sdk 3.0,
@google-cloud/storage8.4, andgoogle-auth-library11.2, which@google-cloud/storageinstalls and which does the signing. The route examples are Next.js 16.4 route handlers.
npm install files-sdk @google-cloud/storagepnpm add files-sdk @google-cloud/storageyarn add files-sdk @google-cloud/storagebun add files-sdk @google-cloud/storagenub add files-sdk @google-cloud/storageaube add files-sdk @google-cloud/storageHow each environment signs
google-auth-library signs locally only when its client holds a private key. Otherwise it reads the service account email from the credential and calls signBlob for it, authorized by whatever token the credential produces. Who has to hold the signBlob permission follows from that:
| Where the code runs | ADC authenticates as | How a URL is signed | Grant that signing needs |
|---|---|---|---|
Any host with a JSON key (keyFilename, credentials, or GOOGLE_APPLICATION_CREDENTIALS pointing at a key) |
The key’s service account | Locally, with the private key | None |
| Cloud Run, Compute Engine, and other hosts with a metadata server | The attached service account | signBlob as itself, with its own token |
Token Creator for the service account on itself |
| GKE, Kubernetes ServiceAccount linked to an IAM service account | The linked service account | Same as Cloud Run | Same, plus the Workload Identity link |
Your machine, gcloud auth application-default login --impersonate-service-account |
You, impersonating the service account | signBlob as the service account, with your token |
Token Creator for you on the service account |
Your machine, plain gcloud auth application-default login |
You | It can’t. | Use impersonation instead |
| Workload Identity Federation without impersonation, including GKE direct access | A federated principal | It can’t. | Impersonate a service account |
| Workload Identity Federation with service account impersonation | The impersonated service account | signBlob as that account, with its token |
Workload Identity User for the principal, Token Creator for the account on itself |
To see the requests behind each row, the adapter’s url() was run in Bun against stubbed Google endpoints with each kind of credential. The stubs show which calls the library makes, not how IAM decides; IAM’s rules come from Google’s docs, linked below. On the metadata-server path, one files.url("reports/q3.pdf", { expiresIn: 300 }) sent:
GET http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email
GET http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email
GET http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token?scopes=…
POST https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/files-signer@my-project.iam.gserviceaccount.com:signBlob
The returned URL’s X-Goog-Credential parameter named files-signer@my-project.iam.gserviceaccount.com, so the signature carries that account’s permissions. With an impersonated_service_account ADC file, the call refreshed the user’s token at oauth2.googleapis.com/token, then called the same signBlob endpoint with it. With a plain user ADC file, or a federation config that impersonates nothing, no request was sent at all. url() rejected with Cannot sign data without \client_email`.`
Create a signing service account
Give the app its own service account, let it use the bucket, and let it sign as itself:
PROJECT_ID=my-project
SA=files-signer@$PROJECT_ID.iam.gserviceaccount.com
gcloud services enable iamcredentials.googleapis.com --project=$PROJECT_ID
gcloud iam service-accounts create files-signer --project=$PROJECT_ID
gcloud storage buckets add-iam-policy-binding gs://uploads \
--member="serviceAccount:$SA" \
--role="roles/storage.objectUser"
gcloud iam service-accounts add-iam-policy-binding $SA \
--member="serviceAccount:$SA" \
--role="roles/iam.serviceAccountTokenCreator" \
--project=$PROJECT_ID
Each step maps to a requirement in Google’s docs:
- The API. Creating signatures tells you to “enable the Service Account Credentials API, if it is not already enabled.” Reads and writes don’t use it, so a project can go a long time without it.
- The bucket role. A signed URL can do only what the signer can. Google’s signed URL overview says you “must specify an account that has sufficient permission to make the request that the signed URL will make.”
roles/storage.objectUserincludesstorage.objects.get,list,create,delete, andupdate, which covers download links, upload links, and the server-side calls. An app that only hands out download links can useroles/storage.objectViewer. - The signing role. “The
iam.serviceAccounts.signBlobpermission is included in theroles/iam.serviceAccountTokenCreatorrole.” The binding goes on the service account’s own IAM policy, with the same account as the member, because on Cloud Run the account callssignBlobfor itself. Token Creator also lets the account mint access and ID tokens for itself. If you want only signing, create a custom role withiam.serviceAccounts.signBloband bind that instead.
IAM changes are eventually consistent. Google’s propagation table gives “typically 2 minutes, potentially 7 minutes or longer” for a role grant, so a call made right after the binding can still be denied.
Configure the adapter without credentials
import { createFiles } from "files-sdk";
import { gcs } from "files-sdk/gcs";
// No keyFilename, no credentials: @google-cloud/storage uses Application
// Default Credentials, and signs through IAM signBlob when there's no key.
export const files = createFiles({
adapter: gcs({ bucket: "uploads", defaultUrlExpiresIn: 900 }),
});
projectId is optional. The adapter falls back to GOOGLE_CLOUD_PROJECT, then GCLOUD_PROJECT, and ADC carries a project too. The two options to leave out are the key options: keyFilename and credentials both hand the library a private key. Also check that nothing in the deployed environment sets GOOGLE_APPLICATION_CREDENTIALS to a key file. ADC reads that variable first, and the key would quietly take over.
defaultUrlExpiresIn sets the expiry of a url() call that doesn’t pass one; the adapter’s default is 3600 seconds. A short default is worth setting here, because signatures from signBlob don’t reliably last as long as V4 allows (see Limits and tradeoffs).
Sign download links in a route
import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";
// One path segment: letters, digits, dots, dashes, underscores.
const SAFE_NAME = /^[\w-][\w.-]*$/;
export async function GET(request: Request) {
const session = await getSession(request.headers);
if (!session) {
return new Response("Sign in first", { status: 401 });
}
const name = new URL(request.url).searchParams.get("name") ?? "";
if (!SAFE_NAME.test(name)) {
return new Response("Invalid file name", { status: 400 });
}
const url = await files.url(`users/${session.user.id}/${name}`, {
expiresIn: 300,
responseContentDisposition: `attachment; filename="${name}"`,
});
return Response.redirect(url, 302);
}
getSession stands in for your auth library (Auth.js, Clerk, Better Auth, or your own cookie check). This guide assumes it takes request headers and returns { user: { id: string } } or null.
What the adapter does with that call:
- It signs without contacting Cloud Storage.
url()callsgetSignedUrland nothing else, so it doesn’t check that the object exists. A link to a missing key is signed fine, and Cloud Storage answers404when the browser follows it. Callfiles.head()first if you want to fail in your route instead. - Every link is one
signBlobrequest. The library doesn’t cache signatures, so a page that renders 50 signed thumbnails makes 50 calls to IAM. For public assets, setpublicBaseUrlon the adapter. A plainurl(key)then returns the public link with no signing; passingexpiresInorresponseContentDispositionstill signs. - The disposition is signed into the URL.
responseContentDispositionbecomes the V4response-content-dispositionparameter, so Cloud Storage serves the file as an attachment and nobody can strip that from the link.
The gateway calls the same url() for its redirect downloads, so createFilesRouter({ files, authorize }) needs exactly the IAM setup above and nothing more.
Sign upload URLs the same way
signedUploadUrl() goes through the same path. Pass maxSize and the adapter returns a V4 POST policy instead of a PUT URL. In the stubbed run, the policy document was signed with one signBlob call, the same as a download link:
import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";
export async function POST(request: Request) {
const session = await getSession(request.headers);
if (!session) {
return new Response("Sign in first", { status: 401 });
}
const { type } = (await request.json()) as { type: string };
const key = `users/${session.user.id}/${crypto.randomUUID()}`;
const target = await files.signedUploadUrl(key, {
contentType: type,
expiresIn: 300,
maxSize: 20 * 1024 * 1024,
});
// { method: "POST", url: "https://storage.googleapis.com/uploads/", fields }
return Response.json({ key, target });
}
The policy’s content-length-range starts at 1 byte unless you pass minSize: 0, so an empty file is refused by default. Enforce file-size and content-type limits on presigned uploads covers what the policy enforces and how to send the form from the browser.
The browser posts that form cross-origin, so the bucket needs a CORS rule. Save it as cors.json:
[
{
"origin": ["http://localhost:3000", "https://app.example.com"],
"method": ["POST"],
"responseHeader": ["Content-Type"],
"maxAgeSeconds": 3600
}
]
Then apply it with gcloud storage buckets update gs://uploads --cors-file=cors.json, as in Google’s CORS guide.
Run it on Cloud Run
Deploy the service with the signing account as its identity:
gcloud run deploy files-app \
--image IMAGE_URL \
--region us-central1 \
--service-account files-signer@my-project.iam.gserviceaccount.com
The person or pipeline that deploys needs the Service Account User role on files-signer. Cloud Run’s service identity docs say that role “contains the iam.serviceAccounts.actAs permission, which is required to attach a service account on the service or revision.”
Without --service-account, Cloud Run runs as the project’s default service account. Reads may work with it, but signing still needs the same Token Creator self-binding, and that account is shared with everything else in the project that uses the default. Give the app its own account.
Nothing else changes. The metadata server supplies the token and the email, and the adapter needs no environment variables beyond whatever your app reads.
Run it on GKE
Workload Identity Federation for GKE gives pods two ways to reach Google APIs, and only one of them can sign. Granting roles directly to the Kubernetes ServiceAccount’s principal is the simpler setup, but Google’s federation limits list this for Cloud Storage: “identity federation users and workloads cannot generate signed URLs.”
So link the Kubernetes ServiceAccount to the IAM service account instead. The GKE Workload Identity docs say “both the IAM allow policy and the annotation are required when you use this method”:
gcloud iam service-accounts add-iam-policy-binding $SA \
--role="roles/iam.workloadIdentityUser" \
--member="serviceAccount:$PROJECT_ID.svc.id.goog[NAMESPACE/KSA_NAME]"
kubectl annotate serviceaccount KSA_NAME \
--namespace NAMESPACE \
iam.gke.io/gcp-service-account=$SA
With the link in place, the pod’s metadata server hands out the IAM service account’s token and email, and signing works the same way as on Cloud Run. Keep the Token Creator self-binding from earlier.
Develop locally with impersonation
Plain gcloud auth application-default login writes your user credentials for ADC. Reads and writes work if your user has a bucket role, but every url() call fails with Cannot sign data without \client_email`.`, because there’s no service account to sign as. Impersonate the app’s service account instead:
gcloud iam service-accounts add-iam-policy-binding $SA \
--member="user:you@example.com" \
--role="roles/iam.serviceAccountTokenCreator"
gcloud auth application-default login --impersonate-service-account $SA
Google’s local ADC docs list Node.js among the client libraries that support impersonated ADC files. Here, the first command lets you impersonate the account. Every call your dev server makes, reads and writes included, then runs as files-signer, so local development has exactly the permissions production has. Signing works through signBlob with your token, as in the stubbed run above. To go back to your own identity, run gcloud auth application-default login without the flag.
Run it outside Google Cloud
On AWS, Azure, GitHub Actions, or another OIDC provider, Workload Identity Federation replaces the key file. Generate the credential configuration with gcloud iam workload-identity-pools create-cred-config, and pass --service-account=$SA so the configuration impersonates the signing account. Without impersonation, the federated principal has no service account email, and url() fails with the same Cannot sign data without \client_email`.` error.
In the stubbed run with an impersonating configuration, one url() call exchanged the external token at sts.googleapis.com, called generateAccessToken for files-signer, and then called signBlob with the resulting token. Because that last call runs as the service account, the Token Creator self-binding is needed here too. The external principal also needs roles/iam.workloadIdentityUser on the service account to impersonate it.
Point GOOGLE_APPLICATION_CREDENTIALS at the configuration file. It contains no private key. gcs() has no option for a ready-made auth client, so the credentials have to come from a file ADC can load. A token you only have in memory, such as one delivered in a request header, can’t be plugged in.
Limits and tradeoffs
- Keep expiries short. V4 allows up to 7 days, and the adapter rejects anything longer before signing:
expiresIn: 8 * 86400threwInvalidwithgcs: signed URLs must expire within 604800 seconds (7 days), the V4 signing limit; got expiresIn 691200.ButsignBlobsigns with Google-managed keys that rotate. Google’s signature docs say that past 12 hours “the signature is usable for at least 12 hours, but might stop working prior to the expiration time due to key rotation,” and that these signatures “are best used for short-lived access.” Use minutes, and sign again when a link is needed. - A signed URL can’t be revoked one at a time. Google’s overview says anyone who has it can use it “until the expiration time for the URL is reached or the key used to sign the URL is rotated”, and describes no way to cancel a single link. Short expiries are the control you have.
- Signing is a network call. Each
url()andsignedUploadUrl()waits on IAM, and the calls count against the IAM Service Account Credentials API’s quotas. Cache a link for a request’s lifetime rather than signing the same key repeatedly, and serve public files throughpublicBaseUrl. - Signing failures are
Providererrors.@google-cloud/storagewraps a refusedsignBlobin aSigningErrorthat carries IAM’s message but not the HTTP status. The adapter therefore reports a denied permission asProvider, notUnauthorized, and aretriessetting retries it. In the stubbed run, a403fromsignBlobbecameFilesErrorcodeProviderwith IAM’s message unchanged. Readerror.messagewhen diagnosing signing errors; don’t branch on the code. - The signer’s scope is the link’s scope. A URL signed by an account with
roles/storage.objectUseron the whole bucket is still limited to one object and one method, but the signer itself can reach everything. Keep tenant boundaries in your route, as the download example does, and see Isolate each tenant’s files for prefix scoping.
Troubleshooting
Cannot sign data without `client_email`. The credential has no service account to sign as. Locally, you ran gcloud auth application-default login without --impersonate-service-account. Elsewhere, a federation config was created without --service-account. The FilesError code is Provider, and its cause is a SigningError. No request reached Google.
Permission 'iam.serviceAccounts.signBlob' denied on resource (or it may not exist). The identity calling signBlob lacks the permission on the account it’s signing as. On Cloud Run or GKE, add the Token Creator binding for the service account on itself. With impersonation, add it for your user. A misspelled service account email produces the same message, and a binding made in the last few minutes may not have propagated yet.
A 403 saying the IAM Service Account Credentials API is disabled or hasn’t been used in the project. Run gcloud services enable iamcredentials.googleapis.com, wait a few minutes, and retry.
Could not load the default credentials. ADC found nothing: no GOOGLE_APPLICATION_CREDENTIALS, no gcloud ADC file, and no metadata server. Locally, run the impersonated login above. In a container outside Google Cloud, mount the federation configuration and set the variable.
gcs: signed URLs must expire within 604800 seconds (7 days)… An expiresIn or defaultUrlExpiresIn above 7 days. The code is Invalid, so retries skip it. Lower it.
The link works, then stops working before it expires. The Google-managed key that signed it rotated; past 12 hours, signBlob signatures aren’t guaranteed. Sign links close to when they’re used.
The link is refused straight away. The signer may not be allowed to do what the URL asks: storage.objects.get for a download, storage.objects.create for an upload. Check the bucket binding for the account named in the URL’s X-Goog-Credential parameter. Also check the server’s clock, since the signature covers the signing time.