Signed URLs
Signed URLs provide temporary, secure access to private files without requiring authentication. Perfect for sharing files, embedding in emails, or displaying in client applications.
What are Signed URLs?
Signed URLs are time-limited URLs that grant temporary access to private files. They contain:
- File identifier
- Expiration timestamp
- Cryptographic signature (prevents tampering)
💡Signed URLs work only for files in private buckets. Public buckets use direct preview URLs instead.
Basic Usage
typescript
// Generate a 1-hour signed URL
const bucket = db.storage.bucket('documents');
const { data, error } = await bucket.createSignedUrl('file-id', '1h');
console.log(data.url);
// https://api.zendbx.in/p/demo/storage/signed/eyJhbGc...
// Use in your app
<img src={data.url} alt="Document" />
<a href={data.url} download>Download</a>Expiry Options
typescript
// 5 minutes
const { data } = await bucket.createSignedUrl('file-id', '5m');
// 15 minutes
const { data } = await bucket.createSignedUrl('file-id', '15m');
// 1 hour (recommended for most use cases)
const { data } = await bucket.createSignedUrl('file-id', '1h');
// 24 hours
const { data } = await bucket.createSignedUrl('file-id', '24h');
// 7 days (maximum)
const { data } = await bucket.createSignedUrl('file-id', '7d');| Parameter | Type | Required | Description |
|---|---|---|---|
| 5m | 5 minutes | optional | Quick shares, temporary previews |
| 15m | 15 minutes | optional | Short-term access |
| 1h | 1 hour | optional | Recommended for most use cases |
| 24h | 24 hours | optional | Day-long access |
| 7d | 7 days | optional | Email links, long-term shares (maximum) |
Common Use Cases
Email Attachments
typescript
// Generate signed URL for email
async function sendInvoiceEmail(userId, fileId) {
const bucket = db.storage.bucket('invoices');
// 7-day expiry for email links
const { data } = await bucket.createSignedUrl(fileId, '7d');
await sendEmail({
to: user.email,
subject: 'Your Invoice',
html: `
<p>Your invoice is ready:</p>
<a href="${data.url}">Download Invoice</a>
<p><small>Link expires in 7 days</small></p>
`,
});
}Temporary File Sharing
typescript
// Share file temporarily
async function shareFile(fileId, recipientEmail) {
const bucket = db.storage.bucket('shared');
// 24-hour access
const { data } = await bucket.createSignedUrl(fileId, '24h');
// Log the share event
await db.from('file_shares').insert({
file_id: fileId,
shared_with: recipientEmail,
url: data.url,
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
return data.url;
}Image Gallery
typescript
// Image gallery with signed URLs
async function loadGallery() {
const bucket = db.storage.bucket('photos');
const { data: files } = await bucket.list();
// Generate signed URLs for all images
const images = await Promise.all(
files.map(async (file) => {
const { data } = await bucket.createSignedUrl(file.id, '1h');
return {
id: file.id,
filename: file.filename,
url: data.url,
};
})
);
return images;
}
// Render gallery
<div className="grid grid-cols-3 gap-4">
{images.map(img => (
<img key={img.id} src={img.url} alt={img.filename} />
))}
</div>Caching Signed URLs
Avoid regenerating signed URLs on every page load:
typescript
// Cache signed URLs (avoid regenerating)
const urlCache = new Map();
async function getCachedSignedUrl(fileId) {
const cached = urlCache.get(fileId);
// Check if cached URL is still valid
if (cached && cached.expiresAt > Date.now()) {
return cached.url;
}
// Generate new signed URL
const bucket = db.storage.bucket('documents');
const { data } = await bucket.createSignedUrl(fileId, '1h');
// Cache with expiry (subtract 5 min buffer)
urlCache.set(fileId, {
url: data.url,
expiresAt: Date.now() + 55 * 60 * 1000,
});
return data.url;
}💡Cache signed URLs in memory or Redis with a buffer before expiry (e.g., 5 minutes).
REST API
bash
curl -X POST https://api.zendbx.in/p/demo/storage/files/{file-id}/signed-url \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "apikey: YOUR_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{
"expiry": "1h"
}'
# Response
{
"data": {
"url": "https://api.zendbx.in/p/demo/storage/signed/eyJhbGc...",
"expires_at": "2024-01-15T11:30:00Z"
},
"error": null
}Security Considerations
typescript
// Server-side: Generate signed URLs
// ✅ GOOD - Server generates URLs
export async function getFileUrl(fileId: string) {
const bucket = db.storage.bucket('documents');
const { data } = await bucket.createSignedUrl(fileId, '1h');
return data.url;
}
// ❌ BAD - Exposing service role key to client
// Never do this!
const publicClient = new ZendBXClient(
'https://api.zendbx.in',
'service_role_key_exposed_to_client' // NEVER DO THIS
);- Generate server-side: Never expose service role keys to clients
- Use short expiry: Minimize risk if URL is leaked
- Log access: Track who generated signed URLs and when
- Validate permissions: Check user access before generating URL
- Use HTTPS only: Signed URLs should never be sent over HTTP
Row-Level Security
sql
-- Restrict signed URL generation with RLS
CREATE POLICY "Users can only create signed URLs for own files"
ON storage_objects
FOR SELECT
USING (
auth.uid()::text = (metadata->>'userId')
OR
project_id IN (
SELECT project_id FROM project_members
WHERE user_id = auth.uid()
)
);💡RLS policies apply when generating signed URLs. Users can only create URLs for files they have access to.
Forced Downloads
typescript
// Force download with signed URL
const { data } = await bucket.createSignedUrl('file-id', '1h');
// Use in download link
<a
href={data.url}
download="document.pdf"
className="btn btn-primary"
>
Download PDF
</a>
// Or trigger programmatically
async function downloadFile() {
const { data } = await bucket.createSignedUrl('file-id', '5m');
const response = await fetch(data.url);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'filename.pdf';
a.click();
URL.revokeObjectURL(url);
}Handling Expired URLs
typescript
// Handle expired URLs gracefully
async function loadImage(fileId) {
try {
const { data } = await bucket.createSignedUrl(fileId, '1h');
return data.url;
} catch (error) {
if (error.code === 'file_not_found') {
console.error('File no longer exists');
} else if (error.code === 'permission_denied') {
console.error('No access to file');
} else {
console.error('Failed to generate URL:', error);
}
return null;
}
}
// Auto-refresh expired URLs
function useSignedUrl(fileId, expiry = '1h') {
const [url, setUrl] = useState(null);
useEffect(() => {
const loadUrl = async () => {
const bucket = db.storage.bucket('photos');
const { data } = await bucket.createSignedUrl(fileId, expiry);
setUrl(data.url);
};
loadUrl();
// Refresh before expiry
const expiryMs = parseExpiry(expiry);
const interval = setInterval(loadUrl, expiryMs - 5 * 60 * 1000);
return () => clearInterval(interval);
}, [fileId, expiry]);
return url;
}Best Practices
- Choose appropriate expiry: Balance security and user experience
- Cache URLs: Avoid regenerating on every request
- Handle expiry gracefully: Show user-friendly messages
- Log generation: Track signed URL creation for audit trails
- Use 1h for most cases: Good balance between security and UX
- Regenerate before expiry: Implement auto-refresh for long sessions
Limits & Quotas
| Parameter | Type | Required | Description |
|---|---|---|---|
| Max expiry | string | optional | 7 days |
| Min expiry | string | optional | 5 minutes |
| URLs per minute | string | optional | Subject to API rate limits |
| URL length | string | optional | ~200-500 characters (varies) |
Troubleshooting
| Parameter | Type | Required | Description |
|---|---|---|---|
| URL expired | error | optional | Generate a new signed URL |
| Invalid signature | error | optional | URL was tampered with or project keys changed |
| File not found | error | optional | File was deleted or moved |
| Permission denied | error | optional | User lost access to file or bucket |

