---
title: Generate GCS signed URLs without a service account key
description: 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.
sidebar:
  label: GCS signed URLs without a key
seo:
  title: GCS signed URLs without a service account key
related:
  - /docs/adapters/gcs
  - /docs/api/url
  - /docs/api/signed-upload-url
  - /guides/presigned-upload-validation
  - /guides/unified-storage-api-typescript
---

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.

```package-install
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:

```text
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:

```bash lineNumbers
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](https://docs.cloud.google.com/storage/docs/authentication/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](https://docs.cloud.google.com/storage/docs/access-control/signed-urls) 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](https://docs.cloud.google.com/iam/docs/access-change-propagation) 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

```ts title="lib/files.ts" lineNumbers
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](#limits-and-tradeoffs)).

## Sign download links in a route

```ts title="app/api/download/route.ts" lineNumbers
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](/docs/ui/server/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:

```ts title="app/api/uploads/route.ts" lineNumbers
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](/guides/presigned-upload-validation) 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`:

```json title="cors.json" lineNumbers
[
  {
    "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](https://docs.cloud.google.com/storage/docs/using-cors).

## Run it on Cloud Run

Deploy the service with the signing account as its identity:

```bash
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](https://docs.cloud.google.com/run/docs/configuring/services/service-identity) 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](https://docs.cloud.google.com/iam/docs/federated-identity-supported-services) 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](https://docs.cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) say "both the IAM allow policy **and** the annotation are required when you use this method":

```bash lineNumbers
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:

```bash lineNumbers
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](https://docs.cloud.google.com/docs/authentication/set-up-adc-local-dev-environment) 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](https://docs.cloud.google.com/storage/docs/authentication/creating-signatures) 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](/guides/multi-tenant-file-storage) 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.
