Storage
Upload and serve files in buckets. Access rules live as rows in your database, under the same Row-Level Security as everything else.
Buckets
Bucket management needs the secret key (kbs_…,
service_role). Names are lowercase [a-z0-9._-],
2–63 characters:
curl -X POST "https://<ref>.kethosbase.com/storage/v1/bucket" \
-H "Authorization: Bearer kbs_<secret-key>" \
-H "Content-Type: application/json" \
-d '{"name": "avatars", "public": true}'
Objects in a public bucket are downloadable without any
key via /storage/v1/object/public/…. A bucket must be empty
before it can be deleted.
Uploading
POST the raw bytes (up to 50 MiB per object):
curl -X POST \
"https://<ref>.kethosbase.com/storage/v1/object/avatars/users/alice.png" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: image/png" \
--data-binary @alice.png
Response (201):
{
"bucket": "avatars",
"name": "users/alice.png",
"id": "…",
"size": 12345,
"content_type": "image/png"
}
Downloading
# Authenticated (RLS applies):
curl "https://<ref>.kethosbase.com/storage/v1/object/avatars/users/alice.png" \
-H "Authorization: Bearer <token>"
# Public bucket, no auth:
curl "https://<ref>.kethosbase.com/storage/v1/object/public/avatars/users/alice.png"
Content-Disposition: attachment; images, text,
JSON, PDF, audio, and video render inline.Signed URLs
Mint a time-limited URL that grants access to a single private object without exposing a key — for sharing a file or letting a browser download it directly:
curl -X POST \
"https://<ref>.kethosbase.com/storage/v1/object/sign/avatars/users/alice.png" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"expiresIn": 60}'
→ { "signedURL": "/object/sign/avatars/users/alice.png?token=…" }
The link works for expiresIn seconds and is bound to that one
object; a tampered link is rejected with 400. Minting it still
runs under RLS, so you can only sign objects you may read. To sign several
objects at once, POST an array of keys to
/storage/v1/object/sign/{bucket}.
Signed upload URLs
The reverse direction: hand a client a one-time URL to upload into a private bucket without giving it a key. Mint it as a user who may write the object, then upload to it with no auth header:
# 1. Mint (requires write access under RLS)
curl -X POST \
"https://<ref>.kethosbase.com/storage/v1/object/upload/sign/avatars/users/alice.png" \
-H "Authorization: Bearer <token>"
→ { "url": "/object/upload/sign/avatars/users/alice.png?token=…", "token": "…" }
# 2. Upload the bytes to that URL — no bearer needed
curl -X PUT \
"https://<ref>.kethosbase.com/storage/v1/object/upload/sign/avatars/users/alice.png?token=…" \
-H "Content-Type: image/png" \
--data-binary @alice.png
The token is bound to that one object and to the upload operation only, and
is valid for 2 hours. The upload is insert-only
(a signed URL can't overwrite an existing object) and records the minting
user as the object's owner, so your owner_sub policies still hold.
Listing, copying, moving, removing
Manage objects with the standard operations, all under the caller's RLS:
POST /storage/v1/object/list/{bucket} {"prefix": "users/"}
POST /storage/v1/object/copy {"bucketId": "avatars", "sourceKey": "a.png", "destinationKey": "b.png"}
POST /storage/v1/object/move {"bucketId": "avatars", "sourceKey": "b.png", "destinationKey": "c.png"}
DELETE /storage/v1/object/{bucket} {"prefixes": ["a.png", "b.png"]} # bulk remove by exact key
A folder-style list returns the immediate children of a
prefix — files carry their metadata, sub-folders collapse to a single entry.
A same-bucket move is a metadata rename; a cross-bucket move
copies the bytes then drops the source. These are the standard storage client
calls (list, copy, move,
remove).
Access control
Object metadata lives in your project database, so who can upload, read,
and delete is governed by Row-Level Security on storage.objects
— exactly like your own tables. The secret key (kbs_…,
service_role, used from a server) bypasses these rules;
requests made with the publishable key or an end-user access token follow
them.
Defaults ship locked. A new project has a single storage
policy: read objects in public buckets. That is the only
access end-user requests have out of the box — so reading from or writing to a
private bucket is denied (row violates row-level
security policy) until you add a policy. This is by design: you
decide who can touch what.
Per-user files in a private bucket
Every upload records the uploader's id in the owner_sub column,
taken from the access token — the platform sets it for you, so a user can
never claim another user's files. Gate access with auth.uid(),
which returns the current user's id (and NULL when the request
is not authenticated). Add one policy per operation you need:
-- Each signed-in user manages only their own files in the 'avatars' bucket.
-- No "to <role>" clause: the policy applies to every request, and
-- auth.uid() is NULL for the publishable key, so only the owner passes.
create policy "avatars read own" on storage.objects for select
using ( bucket = 'avatars' and owner_sub = auth.uid() );
create policy "avatars write own" on storage.objects for insert
with check ( bucket = 'avatars' and owner_sub = auth.uid() );
create policy "avatars update own" on storage.objects for update
using ( bucket = 'avatars' and owner_sub = auth.uid() )
with check ( bucket = 'avatars' and owner_sub = auth.uid() );
create policy "avatars delete own" on storage.objects for delete
using ( bucket = 'avatars' and owner_sub = auth.uid() );
public buckets, so a
private bucket needs its own select policy for downloads too. Run
this SQL over Direct SQL or the dashboard. Bucket
creation stays service_role-only.For a bucket that should be readable by everyone, mark it
public instead of writing a read policy. For a shared team space,
scope a policy by a claim in auth.jwt() rather than by
owner_sub. To hand out one private object without broadening your
policies, mint a signed URL — it grants
time-limited access to just that object.
Storage counts against your plan's per-project quota; uploads over it
answer 422 quota_exceeded.