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

Authorization

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.

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.

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". 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

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 }; {} 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 constraint

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
  maxResults?: number;    // clamp a list/search page
};

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 require both ends under the prefix, preventing exfiltration across scopes.

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.

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.

Recipes

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

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:

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:

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. The client rebuilds a FilesError from the envelope, so try/catch in the browser behaves like the SDK.

Was this page helpful?