Signed URLs for Private Images
Signed URLs provide secure, time-limited access to private images. This allows you to implement custom privacy controls in your application while leveraging Pichr's global CDN infrastructure.
Overview
When you upload images with visibility: "private", they cannot be accessed directly by URL. Signed URLs solve this by generating cryptographically secure, temporary URLs that grant access to specific images for authorized viewers.
Use cases:
- Social apps with "friends only" or custom privacy levels
- SaaS platforms with per-customer data isolation
- Mobile apps requiring secure image delivery
- Any application with granular access control needs
How It Works
sequenceDiagram participant User as Your Application participant Pichr as Pichr Platform participant Viewer as Authorized Viewer participant Public as Unauthorized User
Note over User,Pichr: Step 1: Upload Private Image User->>Pichr: POST /upload/presign<br/>visibility: "private"<br/>metadata: {privacyLevel: "friends"} Pichr-->>User: fileId, uploadUrl User->>Pichr: PUT uploadUrl (image data) Pichr-->>User: Upload complete
Note over User,Viewer: Step 2: Authorized Viewer Requests Image Viewer->>User: Request image User->>User: Check authorization<br/>(is viewer a friend?) User->>Pichr: POST /enterprise/sign-url<br/>{fileId, expiresIn: 3600} Pichr-->>User: signedUrl (valid 1 hour) User-->>Viewer: Return signed URL Viewer->>Pichr: GET signedUrl Pichr-->>Viewer: ✅ Image served
Note over Public,Pichr: Step 3: Unauthorized Direct Access Public->>Pichr: GET https://i.pichr.io/{fileId} Pichr-->>Public: ❌ 403 Forbidden<br/>(no valid auth)Complete Example: Social App with Privacy Controls
Let's walk through implementing privacy controls for a social application.
Scenario
Your application allows users to:
- Upload photos with custom privacy settings
- Set privacy levels: "everyone", "friends only", "just me"
- Share content with specific users or groups
Step 1: Upload Private Image
async function uploadPrivateImage( userId: string, imageFile: File, privacyLevel: 'everyone' | 'friends' | 'private') { const PICHR_API_KEY = process.env.PICHR_API_KEY;
const response = await fetch('https://api.pichr.io/api/v1/upload/presign', { method: 'POST', headers: { 'Authorization': `Bearer ${PICHR_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ filename: imageFile.name, mime: imageFile.type, bytes: imageFile.size, sha256: await calculateSHA256(imageFile), visibility: 'private', metadata: { userId: userId, privacyLevel: privacyLevel, }, }), });
const { fileId, uploadUrl } = await response.json(); await uploadFile(uploadUrl, imageFile);
await db.posts.create({ userId, imageFileId: fileId, privacyLevel, });
return fileId;}Step 2: Check Authorization & Generate Signed URL
async function getImageUrl( postId: string, viewerId: string): Promise<string | null> { const post = await db.posts.findById(postId);
const canView = await checkPrivacy(post, viewerId); if (!canView) { return null; }
const signedUrl = await generateSignedUrl(post.imageFileId, { viewerId, postId, });
return signedUrl;}
async function checkPrivacy(post: Post, viewerId: string): Promise<boolean> { if (post.privacyLevel === 'everyone') return true; if (post.privacyLevel === 'private') return post.userId === viewerId; if (post.privacyLevel === 'friends') { return await db.friendships.exists(post.userId, viewerId); } return false;}Step 3: Generate Signed URL
async function generateSignedUrl( fileId: string, customClaims?: Record<string, any>): Promise<string> { const PICHR_API_KEY = process.env.PICHR_API_KEY;
const response = await fetch('https://api.pichr.io/api/v1/enterprise/sign-url', { method: 'POST', headers: { 'Authorization': `Bearer ${PICHR_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ fileId, expiresIn: 3600, customClaims, }), });
const { signedUrl } = await response.json(); return signedUrl;}Step 4: Display in Frontend
async function displayImage(postId: string) { const response = await fetch(`/api/posts/${postId}/image`, { headers: { 'Authorization': `Bearer ${userToken}`, }, });
if (response.status === 403) { showError('You do not have permission to view this content.'); return; }
const { imageUrl } = await response.json(); document.querySelector('#post-image').src = imageUrl;}API Reference
Generate Signed URL
POST https://api.pichr.io/api/v1/enterprise/sign-url
Requirements:
- Enterprise plan subscription
- Valid API key
- File must be owned by API key owner
Request Body:
{ "fileId": "abc-123-def-456", "expiresIn": 3600, "customClaims": { "viewerId": "user-789", "resourceId": "post-456" }}| Field | Type | Required | Description |
|---|---|---|---|
fileId | string (UUID) | ✅ | The Pichr file ID to generate signed URL for |
expiresIn | number | ❌ | Seconds until URL expires (default: 3600 = 1 hour) |
customClaims | object | ❌ | Optional metadata to embed in signed URL |
Response:
{ "signedUrl": "https://i.pichr.io/abc-123?signature=a1b2c3d4e5f6...&expires=1700000000&claims=eyJ2aWV3...", "expiresAt": "2024-11-15T10:00:00.000Z", "expiresIn": 3600}cURL Example
curl -X POST https://api.pichr.io/api/v1/enterprise/sign-url \ -H "Authorization: Bearer pk_your_enterprise_key" \ -H "Content-Type: application/json" \ -d '{ "fileId": "abc-123-def-456", "expiresIn": 3600, "customClaims": { "viewerId": "user-789" } }'How Signatures Work
Signature Algorithm
Pichr uses HMAC-SHA256 to generate cryptographically secure signatures:
messageToSign = fileId + "|" + expiresTimestamp + "|" + (customClaims || "")
signature = SHA-256(apiKeyHash + messageToSign)
URL Structure
https://i.pichr.io/{fileId}?signature={hex}&expires={timestamp}&claims={base64}
| Part | Description |
|---|---|
{fileId} | The file ID (UUID format) |
signature | HMAC-SHA256 signature (hex-encoded) |
expires | Unix timestamp (seconds) when URL expires |
claims | Base64-encoded custom claims (optional) |
Validation Process
When a signed URL is accessed:
- ✅ Extract query parameters:
signature,expires,claims - ✅ Check expiration:
Date.now() <= expires * 1000 - ✅ Fetch file owner's API keys from database
- ✅ Regenerate signature using each API key hash
- ✅ Compare signatures
- ✅ Grant access if valid and not expired
- ❌ Return 403 if validation fails
This supports API key rotation - any active API key can validate the signature.
Code Examples
JavaScript/TypeScript
const PICHR_API_KEY = process.env.PICHR_API_KEY;const API_BASE = 'https://api.pichr.io/api/v1';
async function generateSignedUrl( fileId: string, expiresIn: number = 3600, customClaims?: Record<string, any>): Promise<string> { const response = await fetch(`${API_BASE}/enterprise/sign-url`, { method: 'POST', headers: { 'Authorization': `Bearer ${PICHR_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ fileId, expiresIn, customClaims, }), });
if (!response.ok) { const error = await response.json(); throw new Error(`Failed to generate signed URL: ${error.message}`); }
const { signedUrl } = await response.json(); return signedUrl;}
const imageUrl = await generateSignedUrl('abc-123', 3600, { userId: 'user-456', resourceType: 'post',});Python
import requestsimport os
PICHR_API_KEY = os.getenv('PICHR_API_KEY')API_BASE = 'https://api.pichr.io/api/v1'
def generate_signed_url(file_id: str, expires_in: int = 3600, custom_claims: dict = None) -> str: response = requests.post( f'{API_BASE}/enterprise/sign-url', headers={ 'Authorization': f'Bearer {PICHR_API_KEY}', 'Content-Type': 'application/json', }, json={ 'fileId': file_id, 'expiresIn': expires_in, 'customClaims': custom_claims, } )
response.raise_for_status() return response.json()['signedUrl']
image_url = generate_signed_url( file_id='abc-123', expires_in=3600, custom_claims={'userId': 'user-456', 'resourceType': 'post'})PHP
<?phpfunction generateSignedUrl(string $fileId, int $expiresIn = 3600, ?array $customClaims = null): string { $apiKey = getenv('PICHR_API_KEY'); $apiBase = 'https://api.pichr.io/api/v1';
$payload = ['fileId' => $fileId, 'expiresIn' => $expiresIn]; if ($customClaims) { $payload['customClaims'] = $customClaims; }
$curl = curl_init("$apiBase/enterprise/sign-url"); curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer $apiKey", 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($payload), ]);
$response = curl_exec($curl); $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl);
if ($httpCode !== 200) { throw new Exception("Failed to generate signed URL: $response"); }
return json_decode($response, true)['signedUrl'];}
$imageUrl = generateSignedUrl('abc-123', 3600, ['userId' => 'user-456']);?>Security Best Practices
1. Never Expose API Keys to Frontend
// ❌ BADconst signedUrl = await fetch('https://api.pichr.io/api/v1/enterprise/sign-url', { headers: { 'Authorization': `Bearer ${PICHR_API_KEY}` },});
// ✅ GOOD - Generate in backendapp.post('/api/images/get-url', authenticateUser, async (req, res) => { const { imageId } = req.body; const canView = await checkUserAccess(req.user.id, imageId);
if (!canView) { return res.status(403).json({ error: 'Access denied' }); }
const signedUrl = await generateSignedUrl(imageId); res.json({ signedUrl });});2. Set Appropriate Expiration Times
// Recommended expiration times:const publicIsh = 3600; // 1 hour for unlisted/shareableconst friendsOnly = 1800; // 30 min for friends-onlyconst sensitive = 900; // 15 min for highly sensitive
const url = await generateSignedUrl(fileId, friendsOnly);3. Implement Your Own Authorization
async function getImageUrl(imageId: string, viewerId: string) { const image = await db.images.findById(imageId);
switch (image.privacyLevel) { case 'public': return image.publicUrl;
case 'friends': const isFriend = await db.friendships.exists(image.ownerId, viewerId); if (!isFriend) throw new Error('Access denied'); break;
case 'private': if (image.ownerId !== viewerId) throw new Error('Access denied'); break; }
return await generateSignedUrl(image.pichrFileId);}4. Use Custom Claims for Audit Trails
const signedUrl = await generateSignedUrl(fileId, 3600, { viewerId: user.id, viewerEmail: user.email, resourceId: post.id, resourceType: 'post', generatedAt: new Date().toISOString(), reason: 'friend-access',});5. Rotate API Keys Regularly
- Rotate Enterprise API keys every 90 days
- Keep old key active for 24 hours during rotation
- Monitor "Last Used" timestamp in dashboard
6. Validate Expiration Before Serving Cached URLs
function isSignedUrlExpired(signedUrl: string): boolean { const url = new URL(signedUrl); const expires = parseInt(url.searchParams.get('expires') || '0', 10); return Date.now() > expires * 1000;}
const cachedUrl = cache.get(`image:${imageId}`);if (cachedUrl && !isSignedUrlExpired(cachedUrl)) { return cachedUrl;}
const newUrl = await generateSignedUrl(imageId);cache.set(`image:${imageId}`, newUrl, 3000);return newUrl;Error Handling
Common Errors
| Error | Status | Cause | Solution |
|---|---|---|---|
Unauthorized - API key required | 401 | No API key provided | Include Authorization header |
Forbidden - Enterprise plan required | 403 | Not on Enterprise plan | Upgrade to Enterprise |
API key not found | 404 | No active API keys | Create API key in dashboard |
Invalid input | 400 | Invalid request body | Check fileId format |
Forbidden | 403 | Accessing private image without auth | Provide valid signed URL |
Example Error Handling
async function generateSignedUrlSafely(fileId: string): Promise<string | null> { try { return await generateSignedUrl(fileId); } catch (error) { if (error.response?.status === 403) { console.error('Enterprise plan required'); return null; }
if (error.response?.status === 404) { console.error('File not found'); return null; }
console.error('Failed to generate signed URL:', error); throw error; }}Rate Limits
Enterprise plan limits:
- Signed URL generation: 100,000 requests/hour
- Image delivery: Unlimited (Cloudflare CDN)
Pricing
Signed URLs require an Enterprise plan subscription.
| Feature | Free | Pro | Enterprise |
|---|---|---|---|
| Signed URLs | ❌ | ❌ | ✅ |
| Private images | ✅ | ✅ | ✅ |
| Custom claims | ❌ | ❌ | ✅ |
FAQ
Q: Can I use signed URLs with public images?
A: Yes, but unnecessary. Public/unlisted images are already accessible. Signed URLs are for visibility: "private".
Q: What happens during API key rotation? A: Pichr validates against all active API keys. Keep old key active for 24 hours during rotation.
Q: Can I revoke a signed URL before expiration? A: Not directly. Revoke the API key (affects all URLs from that key) or wait for expiration. Use short TTLs for granular control.
Q: How do custom claims work? A: Metadata embedded in URL as base64 JSON. Pichr includes them in signature but doesn't validate content. Useful for audit trails.
Q: Can I cache signed URLs? A: Yes! Set cache TTL shorter than expiration. Check expiration before serving cached URLs.
Next Steps
- Authentication - Set up your API key
- Upload Guide - Upload private images
- API Endpoints - Complete API reference
Last updated: 13 November 2025