Building File Storage and Sharing in a SaaS: Safe Uploads, Done Right
Founder & Lead Engineer, RAITHub
RAITHub ships and tests production software. See QA as a Service or talk to us.
Build file storage in a SaaS as five parts: direct-to-storage presigned uploads so file bytes never pass through your server; object keys namespaced by tenant; type, size and count limits enforced on the server, not just the browser; a scan step that keeps a file unusable until it passes; and short-lived signed URLs for every download or share. The upload button is the easy part.
This is engineering guidance for multi-tenant SaaS on PostgreSQL and S3-compatible object storage, with TypeScript. If you would rather have it built and tested for you, see how RAITHub would build this below.
Why not just upload files through your own server?
Because every uploaded byte would travel to your application server, use its memory and its request timeout, and then travel again to storage. On a serverless platform that means large request bodies, slow functions and a hard size ceiling. The pattern that scales is direct-to-storage: your server signs a short-lived permission to upload, the browser sends the file straight to the storage bucket, and your server only records metadata.
AWS documents this as the presigned URL flow: a URL you generate with your credentials that "grants temporary access" to upload one object, expiring after a set time. The same works on any S3-compatible service.
| Approach | Bytes through your server? | Scales for large files? | Use when |
|---|---|---|---|
| Multipart POST to your API | Yes | No, bounded by request limits | Tiny files only (avatars under a few MB), never on serverless |
| Presigned PUT to storage | No | Yes | Most SaaS uploads |
| Presigned multipart upload | No | Yes, resumable | Very large files (video, backups) |
How should file metadata and object keys be modelled?
Keep the bytes in object storage and the facts about each file in your database. The object key starts with the tenant ID, so one customer's files can never be listed or guessed from another's, and a mis-scoped query returns nothing instead of someone else's document. The same tenant-scoping rule governs every table, as in the PostgreSQL row-level security guide.
CREATE TABLE files (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id),
object_key text NOT NULL UNIQUE, -- 'tenant/{tenantId}/{fileId}/report.pdf'
filename text NOT NULL, -- original name, for display only
content_type text NOT NULL,
size_bytes bigint NOT NULL,
checksum text, -- sha256 the client claims; verified after upload
status text NOT NULL DEFAULT 'pending', -- pending | scanning | ready | rejected
uploaded_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON files (tenant_id, created_at DESC);
The original filename is for display only. Never use it as the object key: it can contain path separators, duplicate another user's name, or carry a misleading extension. A generated fileId is safe and unique.
How do you sign an upload and keep it within limits?
Enforce the type, size and per-tenant count on the server before you sign anything. The browser can be bypassed, so an accept attribute on an input is a convenience, not a control.
import { S3Client } from '@aws-sdk/client-s3'
import { createPresignedPost } from '@aws-sdk/s3-presigned-post'
const ALLOWED = new Set(['application/pdf', 'image/png', 'image/jpeg'])
const MAX_BYTES = 25 * 1024 * 1024 // 25 MB
export async function signUpload(input: {
tenantId: string
fileId: string
filename: string
contentType: string
}) {
if (!ALLOWED.has(input.contentType)) throw new Error('unsupported type')
const key = 'tenant/' + input.tenantId + '/' + input.fileId + '/' + input.filename
// createPresignedPost lets the policy CAP the size; a presigned PUT cannot.
return createPresignedPost(new S3Client({}), {
Bucket: process.env.UPLOAD_BUCKET!,
Key: key,
Conditions: [
['content-length-range', 0, MAX_BYTES], // storage rejects a bigger file
['eq', '$Content-Type', input.contentType],
],
Fields: { 'Content-Type': input.contentType },
Expires: 60, // the browser must start the upload within a minute
})
}
A presigned POST policy with a content-length-range condition makes the storage service itself reject an oversized file, so a client cannot lie about the size. The S3 POST policy reference lists content-length-range among its conditions. After the browser confirms the upload, your server reads the object's real size and content type, compares them with what was claimed, and only then moves the row towards ready.
Do you need to scan uploaded files?
If users share files with each other, yes. A file one customer uploads and another downloads is a path for malware, so treat every upload as untrusted until a scan clears it. Keep the row at scanning, run an antivirus check (an open-source engine such as ClamAV, or a cloud scanning service), and only set ready on a pass. The OWASP File Upload Cheat Sheet recommends validating the file type by content rather than extension, limiting size, storing uploads outside the web root, and running antivirus scanning on user content.
Never serve a file inline from your own domain if you cannot fully trust it: an HTML or SVG file served inline can run scripts in your origin. Set Content-Disposition: attachment for downloads, or serve user files from a separate domain.
How should file sharing and downloads work?
Do not make the bucket public. Generate a short-lived signed URL each time an authorised user downloads or shares a file, after checking that the file belongs to their tenant and they have permission to see it.
import { GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
export async function downloadUrl(file: { objectKey: string; filename: string }) {
const command = new GetObjectCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: file.objectKey,
// Force a download, so an untrusted file never runs in your origin.
ResponseContentDisposition:
'attachment; filename="' + encodeURIComponent(file.filename) + '"',
})
return getSignedUrl(new S3Client({}), command, { expiresIn: 300 }) // 5 minutes
}
For a "share by link" feature, store the share as its own row with its own expiry, optional password and revocation, rather than handing out a raw signed URL that cannot be taken back. The authorisation check before you sign is the one that matters: the most common API flaw is still broken object-level authorization, covered in users can see another tenant's data.
Do-it-yourself estimate: 1–2 weeks for presigned uploads, limits, metadata and signed downloads if your back end is already tenant-safe; add a week for scanning and share links. The main risk is a public bucket or a missing authorisation check that leaks files across tenants.
Buy, build or hire?
| Option | Examples | Choose this when | Watch out for |
|---|---|---|---|
| A managed file/upload service | Hosted upload widgets and file APIs | You want an upload widget, transforms and a CDN fast, and per-file pricing is acceptable | Tenant isolation and your own permission model still live in your code |
| A storage SDK plus a template | S3 SDK with a community upload component | You have back-end developers and a simple file model | Templates often skip scanning, size caps and signed-download auth |
| Custom build on object storage | The design in this guide | Files are core to the product and must follow your tenancy and roles | You own scanning, limits, retention and the share model |
| Hire a team to build it | RAITHub or another studio | You need it isolated and tested across tenants quickly | Get the isolation tests and the scan pipeline in the handover |
How do you test file storage before customers trust it with documents?
- Tenant isolation: sign a download for tenant A's file using tenant B's session and expect a refusal, for every file endpoint.
- Limits: attempt an oversized file and a disallowed type, and assert the storage service and the server both reject them.
- Scan gate: upload a known test file (the EICAR test string) and assert it never reaches
ready. - Link expiry: use a signed URL after its window and expect a 403.
- Disposition: download an HTML file and assert it arrives as an attachment, not rendered.
How RAITHub would build this
- Upload pipeline: presigned direct-to-storage uploads with server-enforced type, size and count limits, and metadata recorded in PostgreSQL.
- Isolation and scanning: per-tenant object keys, an authorisation check before every signed URL, and a scan step that keeps files unusable until they pass.
- Sharing: revocable, expiring share links with optional passwords, and forced-download responses for untrusted files.
- Tests: cross-tenant isolation, limit and scan-gate tests in CI.
Timeline: file storage inside a new SaaS build fits the 4–6 week fixed scope; added to an existing back end it is a well-bounded piece in the 6–12 week backend range. You receive: isolation and limit tests in CI, handover docs and runbooks, and full IP under NDA.
Next step: a free 15-minute technical audit, then a written fixed quote. See the SaaS development service, read the public API design guide for the keys that protect file endpoints, and compare the neighbouring PDF report generation build. Book the audit.
Frequently asked questions
Should SaaS file uploads go through my server or straight to storage?
Straight to storage, through a presigned URL your server generates. Routing bytes through your application uses its memory and timeouts and hits request-size limits, especially on serverless. Your server records metadata; the file travels browser-to-bucket.
How do I stop one tenant downloading another tenant's files?
Namespace every object key by tenant ID, keep the bucket private, and check the file belongs to the caller's tenant before you sign any download URL. Signed URLs are short-lived and issued per request, never shared.
Do I have to scan uploaded files for viruses?
If users share files with each other, yes. Keep each file unusable until a scan passes. OWASP's File Upload Cheat Sheet recommends antivirus scanning of user content, validating type by content, and limiting size.
How long should a signed download URL last?
As short as the use allows, often a few minutes. For sharing, store a revocable share record with its own expiry rather than handing out a raw signed URL you cannot take back.
Why force downloads instead of showing files inline?
An HTML or SVG file served inline from your domain can run scripts in your origin. Setting Content-Disposition to attachment, or serving user files from a separate domain, removes that risk for untrusted uploads.
Related posts
Generating PDF Reports and Invoices in a SaaS Without Breaking Production
8 min readInternationalising a SaaS: i18n, RTL and Locale Data Without a Rewrite
8 min readSafe User Impersonation for SaaS Support Teams: Consent, Scope and Audit
8 min readReady to discuss your project?
Book a free 15-minute technical audit with our engineering team.