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
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-urlRequirements:
- 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 requests
import 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
<?php
function 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
// ❌ BAD
const signedUrl = await fetch('https://api.pichr.io/api/v1/enterprise/sign-url', {
headers: { 'Authorization': `Bearer ${PICHR_API_KEY}` },
});
// ✅ GOOD - Generate in backend
app.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/shareable
const friendsOnly = 1800; // 30 min for friends-only
const 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