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. 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
params: Readonly<Record<string, unknown>>; // expiresIn, range, …
})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 expiry (seconds)
disposition?: "attachment" | "inline" | string; // download 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.
Recipes
Read-only session — deny every write:
authorize: ({ operation }) => {
const writes = ["upload", "delete", "copy", "move"];
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 against allowedOrigins (an array or a predicate). It is off by default but strongly recommended:
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.
Gateway
createFilesRouter exposes the whole Files API over one HTTP endpoint. Mount it in Next (or any Web-Request runtime) and the browser bindings talk to it.
Astro
Mount the Files gateway in an Astro endpoint. createRouteHandler returns { GET, POST, PUT } over the Web Request Astro hands each route - runs on Node and edge adapters.