Skip to Content
APISigned URLs for Private Images

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-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" } }
FieldTypeRequiredDescription
fileIdstring (UUID)The Pichr file ID to generate signed URL for
expiresInnumberSeconds until URL expires (default: 3600 = 1 hour)
customClaimsobjectOptional 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}
PartDescription
{fileId}The file ID (UUID format)
signatureHMAC-SHA256 signature (hex-encoded)
expiresUnix timestamp (seconds) when URL expires
claimsBase64-encoded custom claims (optional)

Validation Process

When a signed URL is accessed:

  1. ✅ Extract query parameters: signature, expires, claims
  2. ✅ Check expiration: Date.now() <= expires * 1000
  3. ✅ Fetch file owner’s API keys from database
  4. ✅ Regenerate signature using each API key hash
  5. ✅ Compare signatures
  6. ✅ Grant access if valid and not expired
  7. ❌ 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

ErrorStatusCauseSolution
Unauthorized - API key required401No API key providedInclude Authorization header
Forbidden - Enterprise plan required403Not on Enterprise planUpgrade to Enterprise
API key not found404No active API keysCreate API key in dashboard
Invalid input400Invalid request bodyCheck fileId format
Forbidden403Accessing private image without authProvide 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.

FeatureFreeProEnterprise
Signed URLs
Private images
Custom claims

View pricing → 

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

Last updated: 13 November 2025