Skip to main content

Quick experience

This guide walks you through the full Postgres-Native cloud storage loop in 5 minutes: create a bucket → write policies → upload → download → intentionally trigger an authorization failure to verify RLS.

Prerequisites
  • A PostgreSQL database environment (i.e. Postgres-Native mode); see Postgres-Native overview
  • Console or SQL client access to the environment's PostgreSQL instance
  • Basic familiarity with Authentication: the three roles (anon / authenticated / service_role) and auth.uid()

Step 1: Create a bucket

The following examples create an avatars bucket for user avatars. Choose CLI, manager-node SDK, JS SDK, HTTP API, or SQL depending on your scenario:

Use CloudBase CLI to execute PostgreSQL SQL and create the bucket:

tcb db execute -e <envId> --sql "INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) VALUES ('avatars', 'avatars', false, 5 * 1024 * 1024, ARRAY['image/png', 'image/jpeg', 'image/webp']);"

Main columns of storage.buckets:

ColumnTypeDescription
idtextBucket identifier (primary key)
nametextBucket name, length ≤ 100
publicbooleanPublic flag (default false). Does not bypass RLS automatically — still need an explicit policy.
file_size_limitbigintPer-file size limit (bytes)
allowed_mime_typestext[]Allowed MIME-type whitelist
owner_idtextUser ID of the creator

Step 2: Write RLS policies

Object keys follow the convention <uid>/<filename> — "one folder per user". We want:

  • A signed-in user can upload to their own folder
  • A signed-in user can read / update / delete files under their own folder
  • All other access is denied
-- Anyone may read bucket metadata (e.g. for the front-end to enumerate buckets; optional)
CREATE POLICY buckets_select_all ON storage.buckets
FOR SELECT TO anon, authenticated
USING (true);

-- ↓↓↓ Four policies on storage.objects ↓↓↓

-- Only the owner can read
CREATE POLICY avatars_select_own ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
);

-- Only signed-in users can upload, and only to their own folder
CREATE POLICY avatars_insert_own ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
);

-- Only the owner can overwrite or update metadata
CREATE POLICY avatars_update_own ON storage.objects
FOR UPDATE TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
)
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
);

-- Only the owner can delete
CREATE POLICY avatars_delete_own ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()
);
Key points
  • storage.foldername(name) is a built-in helper that turns '<uid>/avatar.png' into {'<uid>'}.
  • (storage.foldername(name))[1] gets the first folder segment and compares it with auth.uid().
  • auth.uid() returns the sub of the current JWT — see Authentication: reading login state in SQL.
  • You do not need to GRANT anything on storage.objects. All three roles are already granted ALL on it; the only gate is RLS.

Step 3: Sign in and upload from the client

Use app.storage.from(bucketId) in @cloudbase/js-sdk to enter native Postgres-Native bucket object operations:

import cloudbase from '@cloudbase/js-sdk';

const app = cloudbase.init({ env: '<env-id>' });
const auth = app.auth;

// 1. Sign in (anonymously is fine — anonymous sign-in still gets a real sub)
const { data: loginState } = await auth.signInAnonymously();
const uid = loginState?.user?.id;

// 2. Enter the avatars bucket. Paths below are object names inside the bucket; do not pass cloud:// fileIDs
const bucket = app.storage.from('avatars');

// 3. Upload to your own folder
const { error } = await bucket.upload(`${uid}/avatar.png`, file, {
contentType: file.type || 'image/png',
upsert: true,
});

if (error) {
throw error;
}

// 4. Create a signed download URL
const { data: signed } = await bucket.createSignedUrl(`${uid}/avatar.png`, 600);
console.log(signed.fullSignedURL);

Step 4: Inspect the metadata via SQL

Run the following in the SQL console:

SELECT id, bucket_id, name, owner_id, metadata, created_at
FROM storage.objects
WHERE bucket_id = 'avatars'
ORDER BY created_at DESC
LIMIT 5;

You should see the row you just uploaded; owner_id is filled with the current user's sub.

Step 5: Verify RLS by intentionally violating it

Open a new browser window (or sign out), sign in anonymously again to get a different uid, and try:

const bucket = app.storage.from('avatars');

// Try to write into someone else's folder — must fail
await bucket.upload(`<other-user-uid>/hack.png`, file);
// → Throws: insufficient privilege (violates the WITH CHECK of avatars_insert_own)

await bucket.createSignedUrl(`<other-user-uid>/avatar.png`, 600);
// → Throws or returns empty: the SELECT policy filters out the row.

You have just seen RLS in action: it is the sole gate, and it is fully transparent to the client — no special handling on the front end, the database simply denies unauthorized requests.

Troubleshooting cheat sheet

SymptomLikely cause
Upload returns 403The WITH CHECK of the INSERT policy was not satisfied; check that the first segment of the object name equals the current uid.
createSignedUrl throws or returns nothingThe SELECT policy filtered the row out; cross-check by querying with service_role in the SQL console.
DELETE FROM storage.objects errors outDirect deletion is forbidden by the protect_delete trigger. Always go through the SDK / Storage API.
Upload fails for files > 5 MBbuckets.file_size_limit is too small — raise it or use a different bucket.
Non-image upload failsbuckets.allowed_mime_types does not allow it — adjust the whitelist.

See FAQ and RLS policy patterns for more.

Next steps