Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

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/storage 8.4, and google-auth-library 11.2, which @google-cloud/storage installs and which does the signing. The route examples are Next.js 16.4 route handlers.
npm install files-sdk @google-cloud/storage
pnpm add files-sdk @google-cloud/storage
yarn add files-sdk @google-cloud/storage
bun add files-sdk @google-cloud/storage
nub add files-sdk @google-cloud/storage
aube add files-sdk @google-cloud/storage

How 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.objectUser includes storage.objects.get, list, create, delete, and update, which covers download links, upload links, and the server-side calls. An app that only hands out download links can use roles/storage.objectViewer.
  • The signing role. “The iam.serviceAccounts.signBlob permission is included in the roles/iam.serviceAccountTokenCreator role.” The binding goes on the service account’s own IAM policy, with the same account as the member, because on Cloud Run the account calls signBlob for itself. Token Creator also lets the account mint access and ID tokens for itself. If you want only signing, create a custom role with iam.serviceAccounts.signBlob and 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).

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() calls getSignedUrl and nothing else, so it doesn’t check that the object exists. A link to a missing key is signed fine, and Cloud Storage answers 404 when the browser follows it. Call files.head() first if you want to fail in your route instead.
  • Every link is one signBlob request. The library doesn’t cache signatures, so a page that renders 50 signed thumbnails makes 50 calls to IAM. For public assets, set publicBaseUrl on the adapter. A plain url(key) then returns the public link with no signing; passing expiresIn or responseContentDisposition still signs.
  • The disposition is signed into the URL. responseContentDisposition becomes the V4 response-content-disposition parameter, 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 * 86400 threw Invalid with gcs: signed URLs must expire within 604800 seconds (7 days), the V4 signing limit; got expiresIn 691200. But signBlob signs 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() and signedUploadUrl() 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 through publicBaseUrl.
  • Signing failures are Provider errors. @google-cloud/storage wraps a refused signBlob in a SigningError that carries IAM’s message but not the HTTP status. The adapter therefore reports a denied permission as Provider, not Unauthorized, and a retries setting retries it. In the stubbed run, a 403 from signBlob became FilesError code Provider with IAM’s message unchanged. Read error.message when 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.objectUser on 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.

Last updated on

Was this page helpful?