Upload Files
Use the shipped image upload flow and understand the validation you must add for other files.
Before uploading images, configure the storage provider.
Shipped upload flow
The repository includes purpose-specific tRPC mutations that return presigned PutObject URLs. The server derives the object key from the authenticated user or active organization:
import {
createTRPCRouter,
protectedOrganizationProcedure,
protectedProcedure
} from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { storageConfig } from '@/config/storage.config';
import { canUploadOrganizationLogo } from '@/lib/auth/organization-permissions';
import { getSignedUploadUrl } from '@/lib/storage';
export const storageRouter = createTRPCRouter({
userAvatarUploadUrl: protectedProcedure.mutation(async ({ ctx }) => {
const path = `${ctx.user.id}-${crypto.randomUUID()}.png`;
const signedUrl = await getSignedUploadUrl(
path,
storageConfig.bucketNames.images
);
return { path, signedUrl };
}),
organizationLogoUploadUrl: protectedOrganizationProcedure.mutation(
async ({ ctx }) => {
if (!canUploadOrganizationLogo(ctx.membership.role)) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
const path = `logo-${ctx.organization.id}-${crypto.randomUUID()}.png`;
const signedUrl = await getSignedUploadUrl(
path,
storageConfig.bucketNames.images
);
return { path, signedUrl };
}
)
});The avatar procedure requires a signed-in user. The organization-logo procedure additionally requires owner or admin membership in the active organization. Neither procedure accepts a client-selected bucket or object path.
The avatar and organization logo components then:
- Accept PNG or JPEG input in the browser.
- Open
CropImageModaland produce a cropped image blob. - Request a signed upload URL and server-generated
.pngobject key. - Upload the blob directly with
PUT. - Save the object key to Better Auth after the upload succeeds.
Requesting an upload URL
const { path, signedUrl } =
await trpc.storage.userAvatarUploadUrl.mutateAsync();
const response = await fetch(signedUrl, {
method: 'PUT',
body: imageBlob,
headers: {
'Content-Type': 'image/png'
}
});
if (!response.ok) {
throw new Error('Failed to upload image');
}This example mirrors the included avatar and logo components. It is not a generic file upload API.
Current validation
getSignedUploadUrl rejects absolute paths, hidden path segments, null bytes, .. and characters outside its allowlist. It signs the URL for 60 seconds.
The shipped server derives avatar and organization-logo keys from the authenticated context. It does not:
- Enforce a maximum byte size
- Inspect the uploaded file contents
- Create file metadata or enforce storage plan limits
The browser file picker accepts image types, but client validation is not a security boundary.
Content type detail
The current PutObjectCommand sets ContentType to image/jpeg, while the included crop upload components send Content-Type: image/png. Providers can enforce signed headers differently. If uploads fail with a signature mismatch, make the signer and client use the same content type.
When adding multiple upload types, accept a small server-validated content type enum and pass the validated value into PutObjectCommand. Do not forward an arbitrary header from the client.
Production hardening
The following controls are customizations and do not ship in the repository:
- Validate an allowed content type and file size before signing.
- Enforce provider-side upload limits where your S3-compatible provider supports them.
- Add a file metadata record with ownership and an upload lifecycle state.
- Confirm the object after upload before marking the record ready.
- Add rate limits, quotas, malware scanning and orphan cleanup as required.
Do not add private document uploads to the existing image signer without also implementing the authorized read flow described in Access Files.