Skip to content

Authn & Authz

ConvexFS relies on the same conventions as any other Convex project for authentication and authorization. This means it’s composable with whatever your app is already doing—including middleware patterns like customFunction from convex-helpers.

When you call fs.stat(), fs.list(), fs.transact(), or any other ConvexFS method from your own queries, mutations, or actions, you’re in full control. Just perform your auth checks before calling the fs method—exactly like you would for any other Convex function.

convex/files.ts
import { query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import { fs } from "./fs";
export const listMyFiles = query({
args: {
prefix: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
// Standard Convex auth check
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Not authenticated");
}
// Check any other access privileges specific to your app here
// e.g., verify user has permission to access this prefix
// Now call ConvexFS - user is authenticated
return await fs.list(ctx, {
prefix: args.prefix,
paginationOpts: args.paginationOpts,
});
},
});

This pattern works for all ConvexFS methods. For more sophisticated patterns like role-based access control or per-file permissions, add your logic before calling the fs method.

Access control for the component’s HTTP routes

Section titled “Access control for the component’s HTTP routes”

In the App setup guide, you registered ConvexFS’s HTTP routes with placeholder auth callbacks:

registerRoutes(http, components.fs, fs, {
pathPrefix: "/fs",
uploadAuth: async () => {
// TODO: Add real auth check
return true;
},
downloadAuth: async () => {
// TODO: Add real auth check
return true;
},
});

These callbacks are your gatekeepers for the upload and download endpoints. Let’s look at each one.

The uploadAuth callback is called before any upload is accepted. This is critical to secure properly—returning true commits your app to accepting arbitrary content from anyone on the internet.

At minimum, you should verify the user is authenticated:

registerRoutes(http, components.fs, fs, {
pathPrefix: "/fs",
uploadAuth: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
return identity !== null;
},
// ...
});

You can also implement more granular checks—for example, verifying the user has a specific role or checking rate limits.

The downloadAuth callback is called before redirecting to a signed download URL. The callback receives both the context and the blobId being requested.

Whether you need download authentication depends on your use case:

  • Public assets (marketing images, public documents): You may want to allow unauthenticated access. The simple return true is fine here.

  • Private user content (personal files, confidential documents): You’ll want to verify the user has permission to access this specific blob.

For private content, you’ll typically need to look up which file(s) reference this blob and check if the user has access:

registerRoutes(http, components.fs, fs, {
pathPrefix: "/fs",
// ...
downloadAuth: async (ctx, blobId) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
return false;
}
// Look up the file and verify the user can access it
// This depends on your app's data model
const canAccess = await ctx.runQuery(api.files.canUserAccessBlob, {
userId: identity.subject,
blobId,
});
return canAccess;
},
});

The download component endpoint (hosted on Convex) doesn’t serve file data directly. Instead, it returns a 302 redirect to a time-limited signed URL on the Bunny.net CDN. This has important security implications:

Once a user has the signed URL, they can access the blob directly from the CDN—no further auth checks occur. The URL remains valid until it expires, even if you revoke the user’s access in your app.

This is a deliberate tradeoff. Serving files through Convex would be slower and more expensive than serving directly from a global CDN. But it means your downloadAuth callback is a gate, not a continuous guard.

In practice, when a user’s access is revoked, your app’s queries and reactivity will stop providing them with the CDN URL. However, it’s technically possible for the user to have copied the URL or retained it in their browser cache. In that case, they can continue to access the asset directly from the CDN until the signed token expires—even if your app no longer displays it.

You can control how long signed URLs remain valid with the downloadUrlTtl option (in seconds) when creating your ConvexFS instance:

const fs = new ConvexFS(components.fs, {
storage: {
/* ... */
},
downloadUrlTtl: 300, // URLs expire after 5 minutes (default: 3600 = 1 hour)
});

Shorter TTLs (e.g., 60–300 seconds):

  • Reduce the window where a revoked user can still access content
  • Better for highly sensitive content
  • Users may need to re-request URLs more often for long viewing sessions

Longer TTLs (e.g., 3600+ seconds):

  • Better user experience for media playback and large downloads
  • Fewer requests to your Convex backend
  • Larger window where revoked access still works

For most apps, the default of 1 hour is a reasonable balance. If you’re building something with strict access control requirements (e.g., paid content, confidential documents), consider shorter TTLs and accept the UX tradeoff.