Guide · Cloud Storage

MCP Tools for Supabase Storage — bucket policies, RLS, signed URLs, image transforms

Three Supabase Storage behaviours catch developers building MCP storage tools: Supabase Storage enforces Row-Level Security (RLS) policies on the storage.objects table — if a bucket is not set to public and you have no RLS policy allowing the operation, every storage call from the supabase-js client returns a 400 "new row violates row-level security policy" error even though the object was successfully written to S3 (Supabase Storage uses S3 under the hood but its access control layer is RLS, not S3 ACLs); the fix is to add a Postgres RLS policy on storage.objects or call the admin SDK which bypasses RLS; public buckets expose all objects without authentication but still require explicit bucket-level public: true flag — creating a bucket in the dashboard or via API without setting public: true defaults to private regardless of RLS policies; switching from private to public retroactively changes all object URLs from signed to permanent, breaking existing links; and signed URLs expire at the time set during generation and cannot be extended — to grant long-lived access to a private object, you must generate a new signed URL; unlike S3 where you can extend a URL by re-signing with the same key and a later expiry, Supabase Storage signed URLs are opaque tokens that cannot be reused or extended after expiry.

TL;DR

Client: createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) for server-side (bypasses RLS). Upload: supabase.storage.from('bucket').upload(path, file). Signed URL: supabase.storage.from('bucket').createSignedUrl(path, expiresInSeconds). Public URL: supabase.storage.from('bucket').getPublicUrl(path). Health: supabase.storage.listBuckets() — non-empty array = ok, error = service key invalid or project paused.

Client setup — service role key vs anon key

Supabase has two main API keys: the anon key (safe for browser/client use, subject to RLS) and the service_role key (bypasses RLS, for server-side use only). MCP servers running server-side should always use the service_role key — this eliminates RLS policy debugging for backend storage operations. Never expose the service_role key to browser clients.

import { createClient } from '@supabase/supabase-js';

const SUPABASE_URL = process.env.SUPABASE_URL!;  // https://{ref}.supabase.co
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;  // from Project Settings → API

// Server-side client — bypasses RLS, unrestricted storage access
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
  auth: {
    persistSession: false,  // server doesn't need session persistence
    autoRefreshToken: false,
  },
});

// If you need to act on behalf of a specific user (respecting their RLS permissions):
async function createUserClient(userJwt: string) {
  const userClient = createClient(SUPABASE_URL, process.env.SUPABASE_ANON_KEY!, {
    global: { headers: { Authorization: `Bearer ${userJwt}` } },
    auth: { persistSession: false },
  });
  return userClient;
}

// Startup check — verify service role key is valid
async function verifySupabaseAccess() {
  const { data, error } = await supabase.storage.listBuckets();
  if (error) throw new Error(`Supabase Storage auth failed: ${error.message}`);
  console.log(`Supabase Storage: ${data.length} buckets accessible`);
}

The service_role key is a JWT with role: service_role claim. Supabase's PostgREST and Storage services check this role and bypass RLS automatically. The key does not expire (unlike user JWTs which expire after 1 hour by default), but you can rotate it in Project Settings → API → Reveal service_role key → Roll.

Creating buckets and uploading objects

Buckets in Supabase Storage are logical namespaces that map to prefixes in the underlying S3 bucket. When creating a bucket, the public option controls whether objects in that bucket can be accessed without authentication. Public bucket objects have permanent public URLs; private bucket objects require signed URLs. The bucket's public setting cannot be easily changed after objects are uploaded — existing signed URLs will stop working if you switch from private to public (because the URL format changes), and vice versa.

// Create a private bucket (default)
async function createPrivateBucket(name: string) {
  const { data, error } = await supabase.storage.createBucket(name, {
    public: false,           // private — objects need signed URLs
    fileSizeLimit: 50 * 1024 * 1024,  // 50 MB per file limit
    allowedMimeTypes: ['image/jpeg', 'image/png', 'application/pdf'],
  });
  if (error) throw error;
  return data;
}

// Create a public bucket (all objects are publicly readable without auth)
async function createPublicBucket(name: string) {
  const { data, error } = await supabase.storage.createBucket(name, {
    public: true,  // all objects readable without authentication
  });
  if (error) throw error;
  return data;
}

// Upload a file to a bucket
async function uploadFile(
  bucket: string,
  path: string,
  data: Buffer | Blob | File,
  contentType: string
) {
  const { data: result, error } = await supabase.storage
    .from(bucket)
    .upload(path, data, {
      contentType,
      upsert: false,  // fail if file already exists (use true to overwrite)
    });
  if (error) {
    if (error.message.includes('already exists')) {
      throw new Error(`File already exists at ${path} — set upsert: true to overwrite`);
    }
    throw error;
  }
  return result;
}

// Overwrite existing file (upsert)
async function upsertFile(bucket: string, path: string, data: Buffer, contentType: string) {
  const { data: result, error } = await supabase.storage
    .from(bucket)
    .upload(path, data, { contentType, upsert: true });
  if (error) throw error;
  return result;
}

// Move/rename a file (server-side — no re-download)
async function moveFile(bucket: string, from: string, to: string) {
  const { error } = await supabase.storage.from(bucket).move(from, to);
  if (error) throw error;
}

// Copy a file within the same bucket
async function copyFile(bucket: string, from: string, to: string) {
  const { error } = await supabase.storage.from(bucket).copy(from, to);
  if (error) throw error;
}

Signed URLs and public URLs

For private buckets, use createSignedUrl() to generate time-limited access URLs. For public buckets, use getPublicUrl() which returns a permanent URL. Public URLs do not expire and cannot be revoked (short of deleting the object or making the bucket private). Signed URLs can be generated in bulk with createSignedUrls() for efficiency when multiple files need to be shared simultaneously.

// Single signed URL (private bucket)
async function getSignedUrl(bucket: string, path: string, expiresInSeconds = 3600): Promise {
  const { data, error } = await supabase.storage
    .from(bucket)
    .createSignedUrl(path, expiresInSeconds);
  if (error) throw error;
  return data.signedUrl;
}

// Bulk signed URLs (more efficient than individual calls)
async function getSignedUrls(bucket: string, paths: string[], expiresInSeconds = 3600) {
  const { data, error } = await supabase.storage
    .from(bucket)
    .createSignedUrls(paths, expiresInSeconds);
  if (error) throw error;
  return data.map(item => ({ path: item.path, url: item.signedUrl, error: item.error }));
}

// Signed upload URL — let client upload directly without server intermediary
async function getSignedUploadUrl(bucket: string, path: string) {
  const { data, error } = await supabase.storage
    .from(bucket)
    .createSignedUploadUrl(path);
  if (error) throw error;
  // Returns { signedUrl, token, path } — client sends PUT to signedUrl
  return data;
}

// Public URL (public bucket only — no expiry)
function getPublicUrl(bucket: string, path: string): string {
  const { data } = supabase.storage.from(bucket).getPublicUrl(path);
  return data.publicUrl;
  // Format: https://{ref}.supabase.co/storage/v1/object/public/{bucket}/{path}
}

// Public URL with image transformation (Supabase Pro plan only)
function getResizedImageUrl(bucket: string, path: string, width: number, height: number): string {
  const { data } = supabase.storage.from(bucket).getPublicUrl(path, {
    transform: {
      width,
      height,
      resize: 'cover',   // 'cover' | 'contain' | 'fill'
      format: 'webp',    // auto-converts to WebP for smaller size
      quality: 80,
    },
  });
  return data.publicUrl;
}

Image transforms (resize, format conversion, quality) are available on Supabase Pro plans. On the Free plan, the transform option is silently ignored and the original image is returned. For MCP tools serving images, check if the project is on Pro before relying on transforms — the feature silently degrades rather than throwing an error on Free plans.

Listing and downloading objects

Supabase Storage's list API supports pagination via limit and offset (not cursor-based like S3), and supports searching within a prefix. The list returns objects and subfolders (virtual directories) in the same array, distinguished by the id field — folders have a null id.

// List objects in a bucket with prefix
async function listObjects(bucket: string, prefix = '') {
  const { data, error } = await supabase.storage.from(bucket).list(prefix, {
    limit: 100,
    offset: 0,
    sortBy: { column: 'created_at', order: 'desc' },
    search: '',  // filter by name within the prefix
  });
  if (error) throw error;
  return {
    files: data.filter(item => item.id !== null),  // actual files have an id
    folders: data.filter(item => item.id === null), // virtual folders have null id
  };
}

// Download file as ArrayBuffer
async function downloadFile(bucket: string, path: string): Promise {
  const { data, error } = await supabase.storage.from(bucket).download(path);
  if (error) throw error;
  return data.arrayBuffer();
}

// Download as Buffer (Node.js)
async function downloadBuffer(bucket: string, path: string): Promise {
  const { data, error } = await supabase.storage.from(bucket).download(path);
  if (error) throw error;
  return Buffer.from(await data.arrayBuffer());
}

// Delete files
async function deleteFiles(bucket: string, paths: string[]) {
  const { data, error } = await supabase.storage.from(bucket).remove(paths);
  if (error) throw error;
  return data;  // array of deleted objects
}

Health probe — bucket listing and upload round-trip

The quickest health check for Supabase Storage is listBuckets() — it verifies the service role key is valid and the Storage service is reachable. If the Supabase project is paused (free plan inactivity pause), this call returns an error with "Project paused" or a connection timeout. A full round-trip health check (upload, download, delete) verifies write access and the underlying S3 connectivity.

async function checkSupabaseStorageHealth(): Promise {
  const results: Record = {};

  // List buckets — verifies service role key and Storage service availability
  try {
    const start = Date.now();
    const { data, error } = await supabase.storage.listBuckets();
    results.latencyMs = Date.now() - start;
    if (error) {
      results.alive = false;
      results.error = error.message;
      // Check for common issues:
      if (error.message.includes('paused')) results.projectPaused = true;
      if (error.message.includes('JWT')) results.invalidKey = true;
    } else {
      results.alive = true;
      results.bucketCount = data.length;
      results.buckets = data.map(b => ({ name: b.name, public: b.public }));
    }
  } catch (err: any) {
    results.alive = false;
    results.error = err.message;
    results.networkError = err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT';
  }

  // Write round-trip (run periodically — not on every probe)
  if (results.alive && process.env.HEALTH_PROBE_BUCKET) {
    const testBucket = process.env.HEALTH_PROBE_BUCKET;
    const testPath = `.health-check-${Date.now()}`;
    try {
      await supabase.storage.from(testBucket).upload(testPath, 'ok', { contentType: 'text/plain' });
      const { data } = await supabase.storage.from(testBucket).download(testPath);
      await supabase.storage.from(testBucket).remove([testPath]);
      results.writePermission = true;
    } catch (err: any) {
      results.writePermission = false;
      results.writeError = err.message;
    }
  }

  return results;
}

		

Supabase free plan projects pause after 1 week of inactivity. When a project is paused, all API calls (including storage) return connection errors or HTTP 500s. AliveMCP detects this pattern by monitoring the health probe response shape — a sudden shift from fast 200s to connection timeouts (rather than explicit 4xx errors) is the signature of a paused Supabase project. Set up a free-tier AliveMCP probe to catch this before your users notice their uploads are failing.

Related guides