---
title: Authorization
description: The gateway is deny-by-default. One authorize hook decides who can do what, and scopes every key - the single most important thing to get right.
related:
  - /guides/multi-tenant-file-storage
  - /guides/supabase-private-uploads-rls
  - /guides/firebase-storage-authenticated-uploads
---

Exposing `download`, `list`, `delete`, and `move` to the browser is a large attack surface. The gateway is **deny-by-default**: with no `authorize` and no `operations`, only `capabilities` answers and every other verb returns `403`.

You open it up with one hook.

```ts lineNumbers
createFilesRouter({
  files,
  authorize: async ({ operation, key, req }) => {
    const session = await auth(req);
    if (!session) {
      throw new FilesError("Unauthorized", "sign in"); // → 401
    }
    return { keyPrefix: `users/${session.id}/` }; // scope every key to this user
  },
});
```

`authorize` runs on every request except the proxy-upload `PUT` of a keyless upload. That request carries the HMAC token `presign` minted, and the token is what authorizes it, since `presign` already ran `authorize` as `"upload"`. It is a bearer token, like a presigned URL: whoever holds it can write the one key `presign` minted (within the size and type it binds), at the origin, path, and query it was minted under, until it expires, and nothing else. Because that `PUT` runs no `authorize` and the browser client sends it without your `headers`, a per-request `files` factory must choose the instance from the URL, never from a cookie or header (see [Multiple buckets](/docs/ui/server/multiple-buckets#one-route-selected-per-request)). The `complete` step that follows does run `authorize`, and refuses a token whose key lies outside the calling request's `keyPrefix`, so one tenant can't complete another tenant's upload or read its stored metadata. The token is signed, not encrypted, so its payload (including the minted key) is readable by whoever holds it. **Throw** to deny — a `FilesError` maps to its status (`Unauthorized` → 401, `ReadOnly` → 403, …). **Return** a constraint object to allow, optionally narrowing what the caller can do. **Return nothing** (`void`) to allow as-is.

## The context

```ts lineNumbers
authorize(ctx: {
  operation: FilesOperation;  // "download" | "upload" | "delete" | "list" | …
  req: Request;               // cookies, headers, session live here
  key?: string;               // single-key ops (client-supplied, pre-prefix)
  keys?: string[];            // bulk ops
  from?: string; to?: string; // copy / move
  // Parsed op params: url → { expiresIn }; signedUploadUrl → { expiresIn,
  // maxSize?, minSize? }; restoreVersion → { versionId }; upload: presign →
  // { files: [{ name, size?, type }], expiresIn? }, the keyed PUT →
  // { contentType?, size? }, complete → {}; {} for the rest.
  params: Readonly<Record<string, unknown>>;
})
```

`operation` is the coarse verb, so one predicate covers the bulk and byte variants too (a `head-many` request authorizes as `"head"`; `presign`/`complete`/the upload `PUT` all authorize as `"upload"`). The upload `params` are what the client declared, enough for a per-user quota or type gate. The presign token then holds the upload to the declared `size` (capped by `maxUploadSize`): the proxy `PUT` and `complete` refuse a body larger than what `authorize` approved, so understating the size can't get past a quota. A client that doesn't know a file's size omits `size`, and that upload is held to `maxUploadSize` alone; refuse a missing `size` here if your quota needs one. A content type that isn't exactly one media type (`image/png, text/html`) is refused with `422` before `authorize` runs.

## The constraint

```ts lineNumbers
return {
  keyPrefix?: string;     // prepended to every key/from/to; stripped from results
  maxExpiresIn?: number;  // ceiling on url()/download/upload-URL expiry (seconds)
  disposition?: "attachment" | "inline" | string; // download/url Content-Disposition
  filterKeys?: (key: string) => boolean;           // narrow a bulk op; hide keys from versions / restoreVersion / restoreTrashed / complete
  maxResults?: number;    // clamp a list/search page
  context?: TContext;     // per-request data for onUploadComplete (the user, the tenant)
};
```

`keyPrefix` is the workhorse. It is **prepended** server-side, so a client that calls `download("avatar.jpg")` actually reads `users/123/avatar.jpg` — it literally cannot address outside its scope. The prefix is stripped from `list`/`search` results and returned keys, so the client only ever sees relative paths. `copy`/`move` re-root **both** ends under the caller's prefix: a `from` that names another tenant's key just becomes a key inside the caller's own prefix, so neither end can reach outside the scope.

Keys are checked after the prefix is applied: a `..` or `.` segment, a NUL byte, a leading `/` or `\`, or an empty key gets `422`, and a backslash counts as a path separator for the `..` check (the `fs` adapter on Windows would resolve `..\u2\secret.txt` into a sibling tenant).

The storage prefixes that [`versioning()`](/docs/plugins/versioning), [`softDelete()`](/docs/plugins/soft-delete), and [`dedup()`](/docs/plugins/dedup) keep their own objects under (`.versions`, `.trash`, `.dedup`) are reserved: a key or a `list`/`search` prefix inside one gets `403`, so a client allowed to `delete` but not `purge` can't hard-delete `.trash/notes.txt`, forge a version, or read or delete a de-duplicated blob by its hash. Use the plugin verbs (`versions`, `restoreVersion`, `trashed`, `restoreTrashed`, `purge`) instead; on a gateway whose `Files` lacks the plugin, those answer `422` `Unsupported` (reason `capability`). The check runs on the resolved key, so under a `keyPrefix` a tenant's own `users/u1/.trash/…` is an ordinary key. It also matches however the key is spelled on a lenient backend: `.TRASH/notes.txt` (a case-insensitive filesystem or drive) and `.trash\notes.txt` (a Windows host, where `\` is a separator) are refused too. A `restoreVersion` `versionId` containing a slash, a backslash, `..`, or a NUL byte gets `422`.

`maxExpiresIn` is the only expiry ceiling. The router's `defaultExpiresIn` is just what applies when the client doesn't ask, and a client may request a longer `expiresIn` for `url()` or an upload. Return `maxExpiresIn` to cap it. On an adapter that only has permanent links (Vercel Blob public, UploadThing `public-read`, …), a capped `url` can't be honored, so it fails with a `422` `Unsupported` error. In the default `"auto"` `downloadMode`, `download` proxies the bytes instead of redirecting.

`context` carries whatever you resolved while authorizing (the signed-in user, the tenant) to the gateway's [lifecycle hook](/docs/ui/server/upload-lifecycle), typed, so `onUploadComplete` doesn't re-read the session.

`disposition` is the server-side override for both proxy downloads and URL minting. The gateway defaults untrusted URLs to `attachment`; return `"inline"` only when that operation is safe for the current user, key, and content type.

Some adapters can't bind a `Content-Disposition` into their URLs at all (Vercel Blob, Box, Dropbox, the local `fs` adapter, …; see [provider gaps](/docs/provider-gaps#responsecontentdisposition)). On those, an `"inline"` disposition from `authorize` makes the gateway mint the URL without one, since inline rendering is already your policy. Without it, `download` proxies the bytes to apply `attachment` (unless `downloadMode` is `"redirect"`), and the `url` operation fails with a `422` `Unsupported` error rather than hand out a URL that may render inline.

## Recipes

**Read-only session** — deny every write (including the plugin verbs that restore or purge):

```ts lineNumbers
authorize: ({ operation }) => {
  const writes = [
    "upload", // also covers presign/complete and the explicit-key PUT
    "signedUploadUrl",
    "delete",
    "copy",
    "move",
    "restoreVersion",
    "restoreTrashed",
    "purge",
  ];
  if (writes.includes(operation)) {
    throw new FilesError("ReadOnly", "read-only"); // → 403
  }
  return { keyPrefix: "public/" };
};
```

For defense in depth, also pass `files: files.readonly()` so writes are refused at the SDK layer even if the hook is misconfigured.

**Declarative allow-list** — for a simple, uniform policy, skip the hook entirely:

```ts lineNumbers
createFilesRouter({
  files,
  operations: ["list", "head", "url", "download"], // read-only, no prefix scoping
});
```

`operations` is a hard gate that runs _before_ `authorize`. Use both together for "only these verbs, and only under this prefix".

## Origin & CSRF

State-changing actions check the `Origin` header before mutating storage. When `allowedOrigins` is omitted, requests with an `Origin` header must match the route's own origin; use an array or predicate to allow additional origins:

```ts lineNumbers
createFilesRouter({
  files,
  authorize,
  allowedOrigins: ["https://app.example.com"],
});
```

The gateway reads no cookies except inside your `authorize` hook, so all authentication is in one place.

## What crosses the wire

Errors are serialized to `{ error: { code, reason?, message } }` and the underlying `FilesError.cause` (provider request IDs, headers) is **never** included. A message the storage layer wrote (a `NotFound`, `Unauthorized`, `Conflict`, or `Provider` error from the adapter) can name a filesystem path, an internal hostname, or a bucket, so the client gets its code and status with a fixed message such as `not found`, and a `Provider` or `Unauthorized` one also goes to `onError`. The SDK's own refusals (`Invalid`, `Unsupported`, `ReadOnly`) and any `FilesError` your `authorize`, `files` factory, or `onUploadComplete` throws keep their message, rewritten to name the key the client sent so the `keyPrefix` never leaks. Anything thrown that isn't a `FilesError` or `RouterError` (a database error from your `authorize`, say) is answered with a generic `500` ("internal server error") and handed to the router's [`onError`](/docs/ui/server/gateway#options), which defaults to `console.error`. The client rebuilds a `FilesError` from the envelope, so `try/catch` in the browser behaves like the SDK.
