# Supabase Storage Standard

> **Scope:** nextjs-supabase
> **Layer:** 2 (on keyword)
> **Keywords:** supabase, storage, bucket, upload, file storage
> **Load When:** supabase storage keywords detected

Stack: Next.js 15 + Supabase + .NET Backend

## Core Rules

- ALWAYS use RLS policies on storage.objects for access control
- NEVER serve private files without signed URLs
- ALWAYS validate file type and size before upload
- Public buckets: anyone can read, still need auth for write
- Private buckets: require signed URLs or authenticated access

## Bucket Configuration

```sql
-- Public bucket (avatars, logos)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES ('avatars', 'avatars', true, 2097152, ARRAY['image/jpeg','image/png','image/webp']);

-- Private bucket (documents, reports)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES ('documents', 'documents', false, 10485760, ARRAY['application/pdf','image/jpeg','image/png']);
```

| Setting | Public Bucket | Private Bucket |
|---------|--------------|----------------|
| Read access | Anyone via URL | Signed URL or auth required |
| Write access | RLS policy required | RLS policy required |
| Use case | Avatars, public images | Documents, reports |

## Storage RLS Policies

```sql
-- Users upload/view/delete in their own folder
CREATE POLICY "user_upload" ON storage.objects FOR INSERT
  WITH CHECK (bucket_id = 'documents' AND (storage.foldername(name))[1] = auth.uid()::text);

CREATE POLICY "user_select" ON storage.objects FOR SELECT
  USING (bucket_id = 'documents' AND (storage.foldername(name))[1] = auth.uid()::text);

CREATE POLICY "user_delete" ON storage.objects FOR DELETE
  USING (bucket_id = 'documents' AND (storage.foldername(name))[1] = auth.uid()::text);

-- Public bucket: anyone reads, auth users upload to own folder
CREATE POLICY "public_read" ON storage.objects FOR SELECT
  USING (bucket_id = 'avatars');

CREATE POLICY "auth_upload_avatar" ON storage.objects FOR INSERT
  WITH CHECK (bucket_id = 'avatars' AND (storage.foldername(name))[1] = auth.uid()::text);
```

## Path Naming Convention

```
{bucket}/{user_id}/{category}/{filename}
avatars/{user_id}/profile.webp
documents/{user_id}/invoices/2026-01-invoice.pdf
```

First folder segment = user_id (enables simple RLS). Use lowercase, hyphens, include extension.

## Upload Patterns

### Browser Upload (Next.js)

```ts
async function uploadFile(file: File, userId: string) {
  if (file.size > 10 * 1024 * 1024) throw new Error("File too large");
  const allowedTypes = ["application/pdf", "image/jpeg", "image/png"];
  if (!allowedTypes.includes(file.type)) throw new Error("Invalid type");

  const path = `${userId}/uploads/${Date.now()}-${file.name}`;
  const { data, error } = await supabase.storage
    .from("documents")
    .upload(path, file, { cacheControl: "3600", upsert: false, contentType: file.type });
  if (error) throw error;
  return data.path;
}
```

### Server Upload (.NET)

```csharp
public sealed class StorageService(Supabase.Client client, ILogger<StorageService> logger)
{
    private static readonly string[] AllowedTypes = ["application/pdf", "image/jpeg", "image/png"];

    public async Task<string> UploadAsync(
        string bucket, string path, Stream stream,
        string contentType, CancellationToken ct = default)
    {
        if (!AllowedTypes.Contains(contentType))
            throw new ValidationException($"Invalid content type: {contentType}");
        var response = await client.Storage.From(bucket)
            .Upload(stream, path, new FileOptions { ContentType = contentType, Upsert = false });
        return response;
    }
}
```

## Download and Signed URLs

```ts
// Public bucket: direct URL
const { data } = supabase.storage.from("avatars").getPublicUrl("user-id/profile.webp");

// Private bucket: signed URL (time-limited)
const { data } = await supabase.storage
  .from("documents").createSignedUrl("user-id/report.pdf", 3600); // 1 hour

// Download file content (returns Blob)
const { data } = await supabase.storage.from("documents").download("user-id/report.pdf");
```

## Image Transformations

```ts
const { data } = supabase.storage.from("avatars").getPublicUrl("user-id/photo.jpg", {
  transform: { width: 200, height: 200, resize: "cover", quality: 80 },
});
```

| Parameter | Options | Default |
|-----------|---------|---------|
| `width` / `height` | 1-2500 px | original |
| `resize` | cover, contain, fill | cover |
| `quality` | 20-100 | 80 |
| `format` | origin, avif, webp | origin |

## File Validation

| Check | Client-Side | Server-Side | Bucket Config |
|-------|------------|-------------|---------------|
| File size | `file.size` | Stream length | `file_size_limit` |
| MIME type | `file.type` | Content-Type | `allowed_mime_types` |
| Extension | File name | File name | -- |

Always validate at all three levels. Client-side can be bypassed.

## Common Mistakes

| Wrong | Right | Why |
|-------|-------|-----|
| No RLS on storage.objects | Policies for each bucket | Anyone can read/write without policies |
| Private files via public URL | `createSignedUrl()` | Private bucket returns 403 without auth |
| User-provided filename as-is | Sanitize + timestamp prefix | Path traversal attacks, collisions |
| `upsert: true` by default | `upsert: false` | Prevents accidental file overwrites |
| Files without user folder | `{user_id}/...` path pattern | Cannot write simple RLS policies |
