# Pattern 18 — Signed URL access for private files

## What this is

Signed URLs let you share private files with unauthenticated parties (clients downloading invoices, users clicking export links in emails) without making the bucket public. They are time-limited, generated server-side, and require no Supabase token to use.

## Critical: RLS does not protect signed URL downloads

This is the most important thing to understand about Supabase Storage signed URLs:

- **Supabase REST API requests** (`/storage/v1/object/<bucket>/<path>` with a Bearer token) → RLS is evaluated
- **CDN-served signed URL requests** (`/storage/v1/object/sign/<bucket>/<path>?token=...`) → RLS is NOT evaluated

The signed URL IS the credential. Once generated, anyone who has it can download the file until it expires, regardless of their Supabase auth state.

**Your server-side permission check before generating the URL is the real security gate — not RLS.**

## The correct flow

```
Client request (download invoice)
  ↓
Your API layer (verify user is authorised to access this file)
  ↓  ← THIS IS THE SECURITY CHECK. RLS is not helping here.
supabaseAdmin.storage.from('private-files').createSignedUrl(path, expiry)
  ↓
Return signed URL to client
  ↓
Client downloads from CDN (RLS not in play)
```

## Expiry windows

| File type | Recommended expiry |
|---|---|
| Invoice / contract download | 1 hour (3600s) |
| Export file (large data) | 15-30 minutes |
| Sensitive document | 5-10 minutes |
| One-time email link | 24 hours max |

Short expiry = smaller blast radius if the URL is forwarded or leaked.

## Never log the URL itself

A signed URL is a credential. Logging it creates a second copy that could be accessed by anyone who can read your audit table. Log the metadata — who requested what, when, and for how long — but not the URL string.

## Server-side implementation

```ts
// In your API route / Server Action — requires service_role key
async function getInvoiceDownloadUrl(userId: string, invoiceId: string) {
  // 1. Verify authorisation in your application logic
  const invoice = await db.invoice.findUnique({
    where: { id: invoiceId, userId }  // ensure this user owns this invoice
  });
  if (!invoice) throw new Error('Not authorised');

  // 2. Log the request (not the URL)
  await supabase.from('signed_url_log').insert({
    requested_by: userId,
    file_path: invoice.storagePath,
    expiry_secs: 3600
  });

  // 3. Generate the URL
  const { data } = await supabaseAdmin.storage
    .from('private-files')
    .createSignedUrl(invoice.storagePath, 3600);

  return data.signedUrl;
}
```

## Common mistake

Assuming RLS on `storage.objects` protects signed URL downloads. It does not. Developers who test authenticated API access and see RLS working correctly may assume signed URLs inherit the same protection. They do not — signed URLs bypass the authenticated API path entirely.
