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.