
File Uploads in Lovable Forms: Drag & Drop, Supabase Storage, RLS and Signed URLs
TL;DR: „Drag & drop UI with react-dropzone, secure upload to Supabase Storage, private buckets with RLS and signed URLs for time-limited access – the complete file upload setup."
— Till Freitag📌 Lovable Forms Series · Part 6 of 6
The closing chapter: when your form needs to receive applications, contracts, screenshots or PDFs, you need more than<input type="file">. Here's how to do it right.
File Uploads in Lovable Forms: Drag & Drop, Supabase Storage, RLS and Signed URLs
File uploads are the classic feature MVPs fail on: either the UX is ugly (browser default), validation is missing (50 MB application PDF crashes the server), or security is wide open (public bucket with every applicant's CV).
In this part we build the full stack: a slick drag-and-drop UI, client and server validation, secure Supabase Storage buckets with RLS, and time-limited access via signed URLs.
Architecture
[react-dropzone UI]
↓ (client validation)
[Supabase Storage upload]
↓ (RLS policies)
[Bucket: form-uploads (private)]
↓ (Edge Function)
[signed URL for sales/HR team]Golden rule: uploads ALWAYS land in private buckets. Never public, not even "just briefly".
Step 1: Bucket + RLS in Lovable Cloud
In the Supabase console, create a bucket form-uploads. Public off. Then this migration:
-- Bucket setup (if not via UI)
insert into storage.buckets (id, name, public)
values ('form-uploads', 'form-uploads', false)
on conflict (id) do nothing;
-- RLS: anon users may ONLY upload to their own submission folder
-- Folder structure: form-uploads/{submission_id}/{filename}
create policy "anon can upload to own submission folder"
on storage.objects
for insert
to anon
with check (
bucket_id = 'form-uploads'
and (storage.foldername(name))[1] = current_setting('request.jwt.claims', true)::json->>'submission_id'
);
-- Nobody except service_role may read (downloads via signed URL)
create policy "no public read"
on storage.objects
for select
to anon, authenticated
using (false);⚠️ Important: the
submission_idcomes from a short-lived JWT issued by an Edge Function – not from the frontend. Otherwise anyone could guess IDs.
Step 2: Upload-token Edge Function
Before uploading, the frontend grabs an upload token (valid ~10 min) and a fresh submission_id.
// supabase/functions/issue-upload-token/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { create } from "https://deno.land/x/djwt@v3.0.1/mod.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const submissionId = crypto.randomUUID();
const secret = Deno.env.get("SUPABASE_JWT_SECRET")!;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const jwt = await create(
{ alg: "HS256", typ: "JWT" },
{
role: "anon",
submission_id: submissionId,
exp: Math.floor(Date.now() / 1000) + 600, // 10 min
},
key
);
return new Response(JSON.stringify({ submissionId, uploadToken: jwt }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
});Step 3: Drag & Drop UI with react-dropzone
import { useDropzone } from "react-dropzone";
import { Upload, X, FileText } from "lucide-react";
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
const ACCEPTED = {
"application/pdf": [".pdf"],
"image/jpeg": [".jpg", ".jpeg"],
"image/png": [".png"],
};
export function FileUploadField({ onChange }: { onChange: (files: UploadedFile[]) => void }) {
const [files, setFiles] = useState<UploadedFile[]>([]);
const onDrop = useCallback(async (accepted: File[]) => {
// 1. Get token
const { data: tokenData } = await supabase.functions.invoke("issue-upload-token");
const { submissionId, uploadToken } = tokenData;
// 2. Upload
const uploaded: UploadedFile[] = [];
for (const file of accepted) {
const path = `${submissionId}/${crypto.randomUUID()}-${file.name}`;
const { error } = await supabase.storage
.from("form-uploads")
.upload(path, file, {
headers: { Authorization: `Bearer ${uploadToken}` },
cacheControl: "3600",
upsert: false,
});
if (error) {
toast.error(`Upload failed: ${file.name}`);
continue;
}
uploaded.push({ name: file.name, path, size: file.size });
}
const next = [...files, ...uploaded];
setFiles(next);
onChange(next);
}, [files, onChange]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: ACCEPTED,
maxSize: MAX_SIZE,
multiple: true,
});
return (
<div className="space-y-2">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition
${isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/30"}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="mt-2 text-sm">
{isDragActive ? "Drop to upload" : "Drop PDF, JPG or PNG here, or click"}
</p>
<p className="text-xs text-muted-foreground">Max. 10 MB per file</p>
</div>
{files.map((f, i) => (
<div key={i} className="flex items-center justify-between rounded border p-2">
<span className="flex items-center gap-2 text-sm"><FileText className="h-4 w-4" />{f.name}</span>
<button onClick={() => removeFile(i)}><X className="h-4 w-4" /></button>
</div>
))}
</div>
);
}Step 4: Server-side validation (mandatory)
Client validation is UX only. Real validation happens in the submit Edge Function:
// supabase/functions/submit-application/index.ts
const ALLOWED_TYPES = ["application/pdf", "image/jpeg", "image/png"];
const MAX_BYTES = 10 * 1024 * 1024;
// For every uploaded file:
const { data: head } = await supabaseAdmin.storage
.from("form-uploads")
.info(file.path); // metadata
if (!ALLOWED_TYPES.includes(head.contentType)) {
await supabaseAdmin.storage.from("form-uploads").remove([file.path]);
throw new Error("Invalid file type");
}
if (head.size > MAX_BYTES) {
await supabaseAdmin.storage.from("form-uploads").remove([file.path]);
throw new Error("File too large");
}💡 Bonus for regulated industries: chain a virus scan via ClamAV as a second Edge Function. On
infected→ delete file + reject submission.
Step 5: Signed URLs for read access
When the HR team opens an application from your internal dashboard, never make the bucket public. Instead:
const { data, error } = await supabaseAdmin.storage
.from("form-uploads")
.createSignedUrl(file.path, 60 * 5); // 5 minutes
return data.signedUrl;The URL has a one-time token, expires, can't be shared. Perfect for "Click to open the CV".
Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| 50 MB limit (Supabase Free) | Bucket default | Upgrade plan or use multi-part upload |
| CORS error on direct upload | Bucket CORS config missing | In Supabase dashboard: Storage → Configuration → Allowed Origins |
| User sees bucket listing | Wrong RLS policy | Explicit for select using (false) |
| File lingers after form abort | No cleanup | Cron Edge Function: delete form-uploads/* older than 24h without DB row |
| Mimetype spoof (.jpg = .exe) | Only extension checked | Server-side: check head.contentType + magic-number check |
Conclusion
File uploads in Lovable aren't complicated – they just have many layers that all need to be right: UI, token, bucket, RLS, server validation, signed URLs. Skip one and you either have no upload (UI/token) or a data leak (RLS).
With the stack shown here you get both: nice drag & drop UX and HR-/GDPR-grade security.
That wraps up the series
You now have the full spectrum:
| Part | Focus |
|---|---|
| 1 | SaaS tools (Tally, Typeform, WorkForms) |
| 2 | Custom build (RHF + zod + Lovable Cloud) |
| 3 | Production-ready best practices |
| 4 | monday.com integration via GraphQL |
| 5 | Smart forms with AI |
| 6 | File uploads & storage (you are here) |
Want the whole series as a consulting workshop? Talk to us – we build form stacks from the first line of code all the way to the CRM entry.
Lovable Forms Series
1 of 6 read · 17%Six articles taking you from tool choice to AI-powered forms with file uploads.
- PART 1Unread
Form Tools Compared
Tally, Typeform & monday WorkForms – when SaaS is worth it.
Read - PART 2Unread
Custom Build in Lovable
React Hook Form + zod + Lovable Cloud – the DIY foundation.
Read - PART 3Unread
Production-Ready Best Practices
Validation, GDPR, spam protection, UX feedback.
Read - PART 4Unread
Connect to monday.com
GraphQL API, Edge Function, lead → item on the board.
Read - PART 5Unread
Smart Forms with AI
Auto-complete, AI validation, conversational forms.
Read - PART 6ReadHere
File Uploads & Storage
Drag & drop, Supabase Storage, RLS, signed URLs.
Reading progress is stored locally in your browser (localStorage).








