Private files and account pictures
Set up private personal files, change your account picture, and verify owner-only access.
Start locally
From the template root, install dependencies with bun install, then run:
bunx supabase start
bunx supabase migration list --localAll checked-in migrations must appear as applied. If checked-in versions are pending, apply them without replacing your local data:
bunx supabase db push --local
bunx supabase migration list --localStop on a migration mismatch or error; do not reset retained data. Once every version is applied, sync local settings and start the app:
./coremvp env sync
bun run devThe Storage migration creates user-files as a private bucket with a
4 MiB limit (4,194,304 bytes), accepting PDF, PNG, and JPEG. It adds five
storage.objects policies: upload, read, delete, avatar replacement, and a
restrictive ownership guard. A pre-existing bucket with different privacy,
size, or MIME settings makes the migration fail. Inspect that bucket and its
contents before deciding how to reconcile it; the migration does not change
its settings or delete objects.
Change your picture
Sign in and open Dashboard → Account. Choose a PNG or JPEG under Upload or replace picture to preview it locally, then click Save profile picture. After Profile picture saved, the new image appears in the Account page and account menu. Reset discards the unsaved selection. To return to initials, choose Remove picture, then Save profile picture. Refresh the page to confirm the stored state. An upload or read failure shows an error instead of a success. The public demo uses the same form but never persists picture changes.
Your picture is private to your account. Other users, including members of your Organization, cannot view it. The object has one fixed path per user; replacing it does not add a profile URL or another application table.
Use personal files
Call these same-origin routes from a signed-in browser. Upload one file
field using FormData; let the browser set the multipart boundary:
const form = new FormData();
form.append("file", file); // A PDF, PNG, or JPEG selected by your user.
const uploaded = await fetch("/api/files", { method: "POST", body: form });
const result = await uploaded.json();
if (!uploaded.ok || !result.ok) throw new Error(result.message);
const fileKey = result.data.fileKey;
const downloaded = await fetch(`/api/files/${fileKey}`, { cache: "no-store" });
if (!downloaded.ok) throw new Error("Download failed.");
const bytes = await downloaded.blob();
const removed = await fetch(`/api/files/${fileKey}`, { method: "DELETE" });
if (!removed.ok) throw new Error("Removal failed.");Keep the returned fileKey in your product's own state if you need to retrieve
that file later. There is no listing API or file-manager page. The server
creates an opaque key and a sanitized filename beneath the verified user's
folder. A file key never authorizes another user's folder. General files cannot
be overwritten; upload a new file, then remove the old one when appropriate.
| Route | Result |
|---|---|
POST /api/files | Upload a personal file; 201 with data.fileKey |
GET /api/files/:fileKey | Download the owner's bytes as an attachment |
DELETE /api/files/:fileKey | Remove the owner's file |
POST /api/files/avatar | Upload or replace a PNG/JPEG picture; 200 |
GET /api/files/avatar | Read the owner's raster picture inline |
DELETE /api/files/avatar | Remove the picture |
The avatar upload also takes one multipart file field. No session or an
anonymous Auth session receives 401. Foreign or missing objects receive
404 without disclosing another user's file; malformed keys receive 400.
Cross-site mutations receive 403, oversized files 413, and unsupported
or mismatched types 415. Provider/setup failures remain errors. Reads use
private, no-store and nosniff; avatar replacement bypasses cached bytes.
The service uses the request's Supabase user JWT, so Storage policies also protect direct SDK requests. The secret/admin client does not upload, read, or remove these user files. Never make this bucket public or return public or signed URLs as an alternative to authenticated reads.
Test the file service
From the template root, run:
bun test src/tests/storage.test.tsThese focused tests check file types and limits, safe paths, authenticated same-origin API behavior, and provider failure handling. They use controlled Storage responses; they do not require a browser or running Supabase instance and do not prove your deployed bucket policies.
With local Supabase and the app running, try the Account upload, replacement, and removal steps above. For the provider boundary, use two test accounts and check that B cannot read, overwrite, or remove A's objects through the Storage SDK. Always verify A's bytes remain after a denied mutation.
Deploy and check the hosted path
Follow the Vercel deployment guide. Apply migrations
to the selected linked Supabase project with bunx supabase db push before
using Storage. In Supabase Storage, confirm the bucket is private, has the
4 MiB limit and the three allowed MIME types. Stop if settings or migrations
differ from the shipped contract.
On your normal Production deployment, sign in with two test accounts. Upload, replace, refresh, and remove account A's picture; A's account menu must show the replacement bytes without a stale image. Repeat a personal file upload, download, and removal. With B's session, direct Storage SDK download, remove, and overwrite attempts against A's object paths must fail to expose or change A's bytes. An unauthorized remove can return an empty result rather than an error, so verify A's original bytes remain.
Check a 4 MiB upload through the deployed Hono route and verify that a larger file fails. Vercel's request limit includes multipart overhead; the 4 MiB file limit leaves room beneath its 4.5 MB function payload limit. Local Playwright does not prove Vercel payload handling or hosted Storage/CDN behavior.
There is no aggregate quota, malware scanning, public sharing, resumable large
upload, or automatic cleanup on account deletion. Remove a user's objects
through the Storage API before deleting their Auth account when your product
needs that cleanup. Never delete storage.objects rows with SQL: that does not
remove the stored bytes.
What's next
Use the API reference to extend your product's same-origin API, or review Authentication for session handling.
Was this page helpful?