
File-Uploads in Lovable-Formularen: Drag&Drop, Supabase Storage, RLS und signed URLs
TL;DR: „Drag&Drop UI mit react-dropzone, sicherer Upload in Supabase Storage, private Buckets mit RLS und signed URLs für temporären Zugriff – das komplette Datei-Upload-Setup."
— Till Freitag📌 Lovable-Forms-Serie · Teil 6 von 6
Der Abschluss der Serie: Wenn dein Formular Bewerbungen, Verträge, Screenshots oder PDFs entgegennehmen soll, brauchst du mehr als nur einen<input type="file">. So machst du es richtig.
File-Uploads in Lovable-Formularen: Drag&Drop, Supabase Storage, RLS und signed URLs
File-Uploads sind der Klassiker, an dem MVPs scheitern: Entweder das UX ist hässlich (Browser-Default), die Validierung fehlt (50 MB-Bewerbungs-PDF crasht den Server) oder die Sicherheit ist offen wie ein Scheunentor (öffentlicher Bucket mit allen Bewerber-Lebensläufen).
In diesem Teil bauen wir den kompletten Stack: schicke Drag&Drop-UI, Client- und Server-Validierung, sichere Supabase Storage Buckets mit RLS und temporären Zugriff via signed URLs.
Architektur
[react-dropzone UI]
↓ (Client-Validierung)
[Supabase Storage Upload]
↓ (RLS-Policies prüfen)
[Bucket: form-uploads (private)]
↓ (Edge Function)
[signed URL für Sales/HR-Team]Goldene Regel: Uploads landen IMMER in privaten Buckets. Niemals public, auch nicht „nur kurz".
Schritt 1: Bucket + RLS in Lovable Cloud
Lege in der Supabase-Konsole einen Bucket form-uploads an. Public off. Dann diese Migration:
-- Bucket-Setup (falls nicht via UI angelegt)
insert into storage.buckets (id, name, public)
values ('form-uploads', 'form-uploads', false)
on conflict (id) do nothing;
-- RLS: anonyme User dürfen NUR in den eigenen Submission-Folder hochladen
-- Folder-Struktur: 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'
);
-- Niemand außer service_role darf lesen (Downloads laufen via signed URL)
create policy "no public read"
on storage.objects
for select
to anon, authenticated
using (false);⚠️ Wichtig: Die
submission_idkommt aus einem Kurzzeit-JWT, das die Edge Function ausstellt – nicht aus dem Frontend. Sonst könnte jeder beliebige IDs raten.
Schritt 2: Upload-Token-Edge-Function
Bevor der User hochlädt, holt das Frontend ein Upload-Token (gültig ~10 min) und eine frische 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" },
});
});Schritt 3: Drag&Drop-UI mit 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. Token holen
const { data: tokenData } = await supabase.functions.invoke("issue-upload-token");
const { submissionId, uploadToken } = tokenData;
// 2. Hochladen
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 fehlgeschlagen: ${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 ? "Loslassen zum Hochladen" : "PDF, JPG oder PNG hier ablegen oder klicken"}
</p>
<p className="text-xs text-muted-foreground">Max. 10 MB pro Datei</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>
);
}Schritt 4: Server-seitige Validierung (Pflicht)
Client-Validierung ist nur UX. Die echte Prüfung passiert in der Submit-Edge-Function:
// supabase/functions/submit-application/index.ts
const ALLOWED_TYPES = ["application/pdf", "image/jpeg", "image/png"];
const MAX_BYTES = 10 * 1024 * 1024;
// Für jede angegebene Datei:
const { data: head } = await supabaseAdmin.storage
.from("form-uploads")
.info(file.path); // metadata holen
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 für regulierte Branchen: Hänge einen Virus-Scan via ClamAV als zweite Edge Function dahinter. Bei
infected→ File löschen + Submission rejecten.
Schritt 5: Signed URLs für Lese-Zugriff
Wenn das HR-Team die Bewerbung im internen Dashboard öffnen will, niemals den Bucket öffentlich machen. Stattdessen:
const { data, error } = await supabaseAdmin.storage
.from("form-uploads")
.createSignedUrl(file.path, 60 * 5); // 5 Minuten gültig
return data.signedUrl;URL hat einen einmaligen Token, läuft ab, lässt sich nicht teilen. Perfekt für „Klicke hier, um den Lebenslauf zu öffnen".
Pitfalls
| Problem | Ursache | Lösung |
|---|---|---|
| 50 MB-Limit (Supabase Free) | Bucket-Default | Plan upgraden oder Multi-Part-Upload |
| CORS-Error beim direkten Upload | Bucket-CORS-Config fehlt | In Supabase-Dashboard: Storage → Configuration → Allowed Origins |
| User sieht Bucket-Listing | Falsche RLS-Policy | Explizit for select using (false) |
| Datei bleibt liegen nach Form-Abbruch | Kein Cleanup | Cron-Edge-Function: lösche form-uploads/* älter als 24h ohne zugehörigen DB-Eintrag |
| Mimetype-Spoof (.jpg = .exe) | Nur Extension geprüft | Server-seitig head.contentType prüfen + Magic-Number-Check |
Fazit
File-Uploads in Lovable sind nicht „kompliziert" – sie haben nur viele Schichten, die alle stimmen müssen: UI, Token, Bucket, RLS, Server-Validierung, signed URLs. Wer eine davon vergisst, hat entweder kein Upload (UI/Token) oder ein Datenleck (RLS).
Mit dem hier gezeigten Stack hast du beides: schickes Drag&Drop UX und HR-/DSGVO-taugliche Sicherheit.
Damit ist die Serie komplett
Du hast jetzt das volle Spektrum:
| Teil | Fokus |
|---|---|
| 1 | SaaS-Tools (Tally, Typeform, WorkForms) |
| 2 | Custom-Bau (RHF + zod + Lovable Cloud) |
| 3 | Production-Ready Best Practices |
| 4 | monday.com-Anbindung via GraphQL |
| 5 | Smart Forms mit AI |
| 6 | File-Uploads & Storage (du bist hier) |
Du willst die ganze Serie als Beratungs-Workshop? Sprich mit uns – wir bauen Form-Stacks von der ersten Zeile bis zum CRM-Eintrag.
Lovable Forms Serie
1 von 6 gelesen · 17%Sechs Artikel, die dich von der Tool-Wahl bis zu AI-gestützten Formularen mit File-Uploads bringen.
- TEIL 1Ungelesen
Formular-Tools im Vergleich
Tally, Typeform & monday WorkForms – wann sich SaaS lohnt.
Lesen - TEIL 2Ungelesen
Custom-Bau in Lovable
React Hook Form + zod + Lovable Cloud – die Eigenbau-Basis.
Lesen - TEIL 3Ungelesen
Production-Ready Best Practices
Validierung, DSGVO, Spam-Schutz, UX-Feedback.
Lesen - TEIL 4Ungelesen
Anbindung an monday.com
GraphQL API, Edge Function, Lead → Item im Board.
Lesen - TEIL 5Ungelesen
Smart Forms mit AI
Auto-Complete, AI-Validierung, Conversational Forms.
Lesen - TEIL 6GelesenHier
File-Uploads & Storage
Drag & Drop, Supabase Storage, RLS, signed URLs.
Lesefortschritt wird lokal in deinem Browser gespeichert (localStorage).








