
Cloudflare Images
- 140 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
Helps with ai & agent building tasks during AI-assisted development.
About
cloudflare-images is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- cloudflare-images
- AI & Agent Building
- AI-coding skill
Cloudflare Images by the numbers
- 140 all-time installs (skills.sh)
- +11 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #3,488 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill cloudflare-imagesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 140 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Cloudflare Images
Status: Production Ready ✅ | Version: 3.0.0 | Last Verified: 2025-12-27
---
What Is Cloudflare Images?
Two powerful features:
1. Images API: Upload, store, serve images globally 2. Image Transformations: Resize/optimize ANY image
Key benefits:
- Global CDN delivery
- Automatic WebP/AVIF conversion
- Up to 100 variants
- Direct creator upload (no API keys in frontend)
- Signed URLs for private images
- Transform any image via URL or Workers
---
Quick Start (5 Minutes)
1. Enable Cloudflare Images
Dashboard → Images → Enable
Get your Account ID and create API token (Cloudflare Images: Edit permission)
2. Upload Image
curl --request POST \
--url https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1 \
--header 'Authorization: Bearer <API_TOKEN>' \
--header 'Content-Type: multipart/form-data' \
--form 'file=@./image.jpg'CRITICAL: Use multipart/form-data, not JSON
3. Serve Image
<img src="https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/public" />4. Enable Transformations
Dashboard → Images → Transformations → Enable for zone
Transform ANY image:
<img src="/cdn-cgi/image/width=800,quality=85/uploads/photo.jpg" />5. Transform via Workers
export default {
async fetch(request: Request): Promise<Response> {
return fetch("https://example.com/image.jpg", {
cf: {
image: {
width: 800,
quality: 85,
format: "auto" // WebP/AVIF
}
}
});
}
};Load `references/setup-guide.md` for complete walkthrough.
---
The 3 Core Features
Feature 1: Images API (Upload & Storage)
Upload methods: 1. File upload (server-side) 2. Upload via URL (ingest from external) 3. Direct creator upload (user uploads, no API keys)
Load `templates/upload-api-basic.ts` for file upload example. Load `references/direct-upload-complete-workflow.md` for user uploads.
Feature 2: Image Transformations
Optimize ANY image (uploaded or external).
Methods: 1. URL: /cdn-cgi/image/width=800,quality=85/path/to/image.jpg 2. Workers: cf.image fetch option
Load `references/transformation-options.md` for all options. Load `templates/transform-via-workers.ts` for Workers example.
Feature 3: Variants
Predefined transformations (up to 100).
Examples:
thumbnail: 200x200, fit=coverhero: 1920x1080, quality=90mobile: 640, quality=75
Load `references/variants-guide.md` for complete guide.
---
Critical Rules
Always Do ✅
1. Use multipart/form-data for uploads (not JSON) 2. Enable transformations for zones before using /cdn-cgi/image/ 3. Use direct creator upload for user uploads (don't expose API tokens) 4. Set CORS headers for direct uploads from browser 5. Use signed URLs for private images 6. Configure variants for common sizes (avoid dynamic transformations) 7. Use format=auto for automatic WebP/AVIF 8. Handle error codes (9401, 9403, 9413, 5408) 9. Set quality=85 for optimal size/quality balance 10. Use fit=cover for consistent aspect ratios
Never Do ❌
1. Never expose API tokens in frontend code 2. Never use JSON encoding for file uploads 3. Never skip CORS configuration for direct uploads 4. Never exceed 100 variants (hard limit) 5. Never use transformations without enabling for zone 6. Never hardcode account IDs in public code 7. Never skip error handling (uploads can fail) 8. Never use quality >90 (diminishing returns) 9. Never skip image validation (size, format, dimensions) 10. Never use transformations on non-proxied requests
---
Top 2 Use Cases
Use Case 1: User Profile Pictures
Direct creator upload pattern for user-uploaded images:
// Backend: Generate upload URL
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/images/v2/direct_upload`,
{ method: 'POST', headers: { 'Authorization': `Bearer ${API_TOKEN}` } }
);
const { result } = await response.json();
return Response.json({ uploadURL: result.uploadURL });
// Frontend: Upload file
const formData = new FormData();
formData.append('file', file);
await fetch(uploadURL, { method: 'POST', body: formData });Load `templates/direct-creator-upload-backend.ts` for complete example. See `examples/basic-upload/` for complete working project.
Use Case 2: Responsive Images
Responsive images with srcset for optimal performance:
<img
srcset="
https://imagedelivery.net/abc/xyz/width=400 400w,
https://imagedelivery.net/abc/xyz/width=800 800w,
https://imagedelivery.net/abc/xyz/width=1200 1200w
"
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
src="https://imagedelivery.net/abc/xyz/width=800"
/>Load `templates/responsive-images-srcset.html` for complete example. See `examples/responsive-gallery/` for complete working project.
Additional Use Cases:
- Transform Existing Images: Load
references/transformation-options.md - Private Images: Load
references/signed-urls-guide.mdor seeexamples/private-images/ - Batch Upload: Load
templates/batch-upload.ts - Framework Integration: Load
references/framework-integration.mdfor Next.js, Remix, Astro - Watermarking: Load
references/overlays-watermarks.mdandtemplates/overlay-watermark.ts - Custom Domains: Load
references/custom-domains.md - Webhooks: Load
references/webhooks-guide.mdandtemplates/webhook-handler.ts
---
Top 2 Errors Prevented
Error 1: CORS Issues with Direct Upload
Problem: Browser blocks direct upload from your domain.
Solution: Configure CORS headers when generating upload URL:
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${API_TOKEN}` },
body: JSON.stringify({
requireSignedURLs: false,
metadata: { source: 'user-upload' }
})
}
);Error 2: Multipart Form Data Encoding
Problem: JSON encoding fails for file uploads (must use multipart/form-data).
Solution:
// ✅ CORRECT
const formData = new FormData();
formData.append('file', file);
await fetch(uploadURL, { method: 'POST', body: formData });
// ❌ WRONG
const json = JSON.stringify({ file: base64File });Additional Common Errors:
- Error 9401 (Transformations not enabled): Load
references/top-errors.md - Error 9403 (Invalid transformation): Load
references/top-errors.md - Error 9413 (Variant limit exceeded): Load
references/top-errors.md - Error 5408 (Upload timeout): Load
references/top-errors.md - Missing requireSignedURLs: Load
references/signed-urls-guide.md
Load `references/top-errors.md` for all 10 errors with complete solutions.
---
When to Load References
Core References
Load `references/setup-guide.md` when:
- First-time Cloudflare Images setup
- Need step-by-step walkthrough
Load `references/api-reference.md` when:
- Need complete API documentation
- All endpoints and parameters
Load `references/top-errors.md` when:
- Encountering any error code (5408, 9401-9413)
- Troubleshooting upload/transformation issues
Upload References
Load `references/direct-upload-complete-workflow.md` when:
- Implementing user uploads
- Need frontend + backend example
- Configuring CORS
Load `references/signed-urls-guide.md` when:
- Implementing private images with access control
- Need HMAC-SHA256 signature generation
Load `references/webhooks-guide.md` when:
- Processing upload completion events
- Implementing webhook handlers with signature verification
Transformation References
Load `references/transformation-options.md` when:
- Need complete transformation reference
- Exploring all fit/format/effect options
Load `references/format-optimization.md` when:
- Optimizing format selection (WebP/AVIF)
- Quality vs size tradeoffs
Load `references/polish-compression.md` when:
- Need details on Lossless/Lossy/WebP compression modes
- Metadata handling (EXIF removal)
Load `references/overlays-watermarks.md` when:
- Adding text or logo watermarks
- Implementing branding/copyright protection
Advanced Features
Load `references/variants-guide.md` when:
- Creating/managing variants (up to 100 max)
- Need flexible variants vs named variants
Load `references/responsive-images-patterns.md` when:
- Building responsive images with srcset
- Implementing picture element for art direction
Load `references/framework-integration.md` when:
- Integrating with Next.js, Remix, Astro, SvelteKit
- Need framework-specific patterns and loaders
Load `references/custom-domains.md` when:
- Serving images from branded domains
- CNAME configuration and SSL setup
Load `references/content-credentials.md` when:
- Preserving EXIF/IPTC metadata
- Implementing C2PA Content Credentials for authenticity
Load `references/sourcing-kit.md` when:
- Migrating from Cloudinary, Imgix, or S3
- Bulk import from external CDNs
---
Using Bundled Resources
References (16 reference files)
Core: setup-guide.md, api-reference.md, top-errors.md
Upload: direct-upload-complete-workflow.md, signed-urls-guide.md, webhooks-guide.md
Transform: transformation-options.md, format-optimization.md, polish-compression.md, overlays-watermarks.md
Advanced: variants-guide.md, responsive-images-patterns.md, framework-integration.md, custom-domains.md, content-credentials.md, sourcing-kit.md
Templates (16 template files)
Upload: upload-api-basic.ts, upload-via-url.ts, direct-creator-upload-backend.ts, direct-creator-upload-frontend.html, batch-upload.ts
Transform: transform-via-url.ts, transform-via-workers.ts, overlay-watermark.ts
Variants: variants-management.ts, signed-urls-generation.ts, responsive-images-srcset.html
Integration: nextjs-integration.tsx, remix-integration.tsx, webhook-handler.ts
Config: wrangler-images-binding.jsonc, package.json
Agents (3 autonomous agents)
- troubleshooting-agent - Diagnose upload/transformation errors (5408, 9401-9413)
- upload-workflow-agent - Guide complete upload implementation (frontend + backend)
- optimization-agent - Recommend image optimization strategies
Use: /agent <agent-name> or let Claude auto-detect when relevant
Commands (3 slash commands)
- /check-images - Quick API health check and configuration validation
- /validate-config - Validate wrangler.jsonc bindings and configuration
- /generate-variant - Interactive variant generator
Use: /<command-name>
Examples (3 complete working projects)
- basic-upload/ - Minimal upload implementation with Hono + Workers
- responsive-gallery/ - Responsive image gallery with srcset and lazy loading
- private-images/ - Signed URLs with time-based expiry and access control
Clone and run: cd examples/<example-name> && npm install && npm run dev
Architecture Diagrams (3 diagrams)
- direct-upload-workflow.md - Sequence diagram of direct creator upload flow
- transformation-pipeline.md - Flowchart showing transformation processing
- variants-structure.md - Named vs flexible variants comparison
View in: assets/diagrams/
Utility Scripts (5 scripts)
- test-upload.sh - Test API connectivity with sample image upload
- generate-signed-url.sh - CLI tool to generate signed URLs with expiry
- validate-variants.sh - List all variants and check variant count (max 100)
- analyze-usage.sh - Query API for storage usage and estimated costs
- check-versions.sh - Verify package versions are current
Run: ./scripts/<script-name>.sh (requires CF_ACCOUNT_ID and CF_API_TOKEN in .env)
---
Pricing
Images API: $5/100k stored, $1/100k delivered Transformations: $0.50/1k (100k/month free per zone) Direct Upload: Included in API pricing
---
Official Documentation
- Images Overview: https://developers.cloudflare.com/images/
- Upload API: https://developers.cloudflare.com/images/upload-images/
- Transformations: https://developers.cloudflare.com/images/transform-images/
- Direct Creator Upload: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
- Variants: https://developers.cloudflare.com/images/manage-images/create-variants/
Direct Creator Upload Workflow
Visual architecture for Cloudflare Images Direct Creator Upload pattern (frontend + backend).
Workflow Diagram
sequenceDiagram
actor User
participant Browser
participant Backend as Your Backend<br/>(API/Worker)
participant CF_API as Cloudflare Images API
participant CF_Upload as Cloudflare Upload Endpoint
participant CF_CDN as Cloudflare CDN
Note over User,CF_CDN: Phase 1: User Initiates Upload
User->>Browser: Select image file
Browser->>Browser: Validate file<br/>(size, type)
Note over User,CF_CDN: Phase 2: Request One-Time Upload URL
Browser->>Backend: POST /api/upload-url
Backend->>CF_API: POST /accounts/{id}/images/v2/direct_upload
Note right of CF_API: Generate one-time URL<br/>Valid for 30 minutes
CF_API-->>Backend: {uploadURL, imageId}
Backend-->>Browser: {uploadURL, imageId}
Note over User,CF_CDN: Phase 3: Upload to Cloudflare
Browser->>CF_Upload: POST uploadURL<br/>multipart/form-data<br/>(file)
Note right of CF_Upload: Process image<br/>Generate variants<br/>Store in edge storage
CF_Upload-->>Browser: 200 OK
Note over User,CF_CDN: Phase 4: Display Uploaded Image
Browser->>Browser: Show success<br/>Store imageId
Browser->>CF_CDN: GET /imagedelivery.net/<br/>{hash}/{imageId}/public
Note right of CF_CDN: Serve optimized image<br/>(WebP/AVIF auto)
CF_CDN-->>Browser: Image (cached)
Browser->>User: Display image
Note over User,CF_CDN: Optional: Webhook Notification
CF_Upload->>Backend: POST /webhook<br/>(image.uploaded event)
Note left of Backend: Verify signature<br/>Save to database<br/>Trigger processing
Backend-->>CF_Upload: 200 OKKey Benefits
1. Secure: Upload URLs are one-time use, expire after 30 minutes 2. Scalable: Direct upload to Cloudflare edge, no backend bottleneck 3. Fast: Parallel upload + CDN delivery 4. Reliable: Cloudflare handles all image processing
Implementation Steps
1. Backend: Generate Upload URL
// POST /api/upload-url
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v2/direct_upload`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiToken}` },
body: JSON.stringify({ requireSignedURLs: false })
}
);
const { uploadURL, id } = (await response.json()).result;
return { uploadURL, imageId: id };2. Frontend: Upload to Cloudflare
// Get upload URL from backend
const { uploadURL, imageId } = await fetch('/api/upload-url', {
method: 'POST'
}).then(r => r.json());
// Upload file directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
await fetch(uploadURL, {
method: 'POST',
body: formData
});
// Display uploaded image
const imageUrl = `https://imagedelivery.net/${accountHash}/${imageId}/public`;3. Backend: Handle Webhook (Optional)
// POST /webhook
const signature = request.headers.get('X-Cloudflare-Signature');
const body = await request.text();
// Verify signature
const isValid = await verifySignature(body, signature, webhookSecret);
if (isValid) {
const { image } = JSON.parse(body);
// Save to database, trigger processing, etc.
await db.images.create({ cloudflareId: image.id });
}Security Considerations
- Upload URL: One-time use, expires after 30 minutes
- CORS: Configure allowed origins on backend
- File Validation: Validate size and type client-side AND server-side
- Webhook Signature: Always verify HMAC-SHA256 signature
- Rate Limiting: Implement on upload URL generation endpoint
Performance Optimizations
- Parallel Upload: Upload happens directly to Cloudflare edge
- CDN Caching: Images cached at edge locations worldwide
- Format Auto-Negotiation: WebP/AVIF served automatically
- Lazy Loading: Load images as user scrolls
Error Handling
Common Errors
1. Upload URL Expired: Generate new URL (after 30 minutes) 2. File Too Large: Validate < 10MB before upload 3. Invalid File Type: Accept only JPEG, PNG, GIF, WebP 4. CORS Error: Configure CORS headers on backend 5. Network Failure: Implement retry with exponential backoff
Example Error Handling
async function uploadWithRetry(file: File, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const { uploadURL, imageId } = await getUploadURL();
await uploadToCloudflare(uploadURL, file);
return imageId;
} catch (error) {
if (attempt === maxRetries) throw error;
await delay(Math.pow(2, attempt) * 1000);
}
}
}Related References
- Complete Guide:
references/direct-upload-complete-workflow.md - Backend Template:
templates/worker-upload.ts - Frontend Template:
templates/direct-upload-frontend.html - Webhook Handler:
templates/webhook-handler.ts
Related Diagrams
- Transformation Pipeline:
diagrams/transformation-pipeline.md - Variants Architecture:
diagrams/variants-structure.md
Cloudflare Images Transformation Pipeline
Visual architecture showing how Cloudflare Images processes transformation requests using URL-based transformations vs Workers API.
Transformation Pipeline Diagram
flowchart TB
User[User/Browser]
Request[HTTP Request]
Edge[Cloudflare Edge]
subgraph "URL-Based Transformations"
URL[Image URL with params<br/>?width=800&quality=85&format=webp]
ParseURL[Parse URL Parameters]
ValidateURL[Validate Parameters]
end
subgraph "Workers API Transformations"
Worker[Cloudflare Worker]
BindingAPI[env.IMAGES.get]
ParseWorker[Parse Options Object]
ValidateWorker[Validate Options]
end
subgraph "Cloudflare Images Engine"
Cache{CDN Cache?}
Original[Fetch Original Image]
Transform[Apply Transformations]
subgraph "Transformation Steps"
Resize[1. Resize<br/>width/height/fit]
Quality[2. Quality<br/>compression]
Format[3. Format<br/>WebP/AVIF/JPEG]
Effects[4. Effects<br/>blur/brightness/contrast]
Metadata[5. Metadata<br/>strip/keep/copyright]
end
Optimize[Optimize for Delivery]
Store[Store in Cache]
end
Response[HTTP Response]
User -->|1. Request Image| Request
Request --> Edge
Edge -->|URL Transform| URL
Edge -->|Worker Transform| Worker
URL --> ParseURL
ParseURL --> ValidateURL
ValidateURL --> Cache
Worker --> BindingAPI
BindingAPI --> ParseWorker
ParseWorker --> ValidateWorker
ValidateWorker --> Cache
Cache -->|HIT| Response
Cache -->|MISS| Original
Original --> Transform
Transform --> Resize
Resize --> Quality
Quality --> Format
Format --> Effects
Effects --> Metadata
Metadata --> Optimize
Optimize --> Store
Store --> Response
Response -->|2. Transformed Image| User
style Cache fill:#f9f,stroke:#333,stroke-width:2px
style Transform fill:#bbf,stroke:#333,stroke-width:2px
style Response fill:#bfb,stroke:#333,stroke-width:2pxTwo Transformation Methods
Method 1: URL-Based Transformations (Recommended)
Use Case: Frontend image delivery (HTML, React, Vue, etc.)
Example:
<img src="https://imagedelivery.net/{hash}/{id}/public?width=800&quality=85&format=auto" />Pros:
- Simple to use
- No backend code required
- Automatic caching
- Works in any framework
Cons:
- Limited to URL parameters
- Cannot use complex logic
Method 2: Workers API Transformations
Use Case: Backend processing, dynamic transformations, complex logic
Example:
// In Cloudflare Worker
const image = await env.IMAGES.get(imageId, {
cf: {
image: {
width: 800,
quality: 85,
format: 'auto'
}
}
});
return new Response(image.body, {
headers: { 'Content-Type': 'image/jpeg' }
});Pros:
- Programmatic control
- Can use complex logic
- Integrate with auth, watermarking, etc.
Cons:
- Requires Cloudflare Worker
- More complex setup
Transformation Parameters
Dimensions
width: 1-9999 pixels
height: 1-9999 pixelsFit Modes
flowchart LR
Original[Original Image<br/>1000x600]
subgraph "fit=scale-down"
SD[800x480<br/>Never enlarge]
end
subgraph "fit=contain"
Contain[800x480<br/>Fit within box]
end
subgraph "fit=cover"
Cover[800x800<br/>Cover box, crop]
end
subgraph "fit=crop"
Crop[800x800<br/>Exact crop]
end
subgraph "fit=pad"
Pad[800x800<br/>Fit + pad]
end
Original --> SD
Original --> Contain
Original --> Cover
Original --> Crop
Original --> PadQuality
quality: 1-100
- 60-70: High compression (visible artifacts)
- 80-85: Optimal (recommended)
- 90-95: High quality (larger file)
- 100: No compression (not recommended)Format
format: auto | webp | avif | jpeg | png
- auto: WebP/AVIF based on Accept header (recommended)
- webp: 25-35% smaller than JPEG
- avif: 50% smaller than JPEG
- jpeg: Universal compatibility
- png: Transparency supportEffects
blur: 1-250 pixels
brightness: -100 to 100
contrast: -100 to 100
gamma: 0.1 to 2.0Caching Strategy
flowchart TB
Request[Request with Params]
CacheKey[Generate Cache Key<br/>hash of URL + params]
Check{Check CDN Cache}
subgraph "Cache Hit Path"
EdgeCache[Edge Cache HIT]
Serve[Serve from Cache<br/>~10ms]
end
subgraph "Cache Miss Path"
Origin[Fetch Original]
Transform[Transform Image]
Store[Store in Cache<br/>TTL: ~30 days]
end
Response[Return Image]
Request --> CacheKey
CacheKey --> Check
Check -->|HIT| EdgeCache
EdgeCache --> Serve
Serve --> Response
Check -->|MISS| Origin
Origin --> Transform
Transform --> Store
Store --> Response
style EdgeCache fill:#bfb,stroke:#333,stroke-width:2px
style Transform fill:#bbf,stroke:#333,stroke-width:2pxCache Behavior
- Cache Key: URL + all transformation parameters
- TTL: ~30 days for transformed images
- Purge: Update original → invalidates all variants
- Global: Cached at 300+ edge locations worldwide
Performance Optimization Tips
1. Use Format Auto-Negotiation
<!-- Automatically serves WebP/AVIF based on browser -->
<img src="...?format=auto" />Savings: 25-50% file size reduction
2. Set Appropriate Quality
<!-- Thumbnails: Lower quality acceptable -->
<img src="...?quality=80" />
<!-- Hero images: Higher quality -->
<img src="...?quality=90" />Savings: 30-50% file size at quality=85 vs 100
3. Use Responsive Images
<img
srcset="
...?width=400 400w,
...?width=800 800w,
...?width=1200 1200w
"
sizes="(max-width: 640px) 100vw, 800px"
/>Savings: Only load appropriate size for device
4. Lazy Load Below-Fold Images
<img src="..." loading="lazy" />Savings: Defer loading until needed
Error Codes
Transformation Errors (9400-9413)
9401: Invalid width (must be 1-9999)
9402: Invalid height (must be 1-9999)
9403: Invalid fit (must be scale-down/contain/cover/crop/pad)
9404: Invalid quality (must be 1-100)
9406: Invalid background color (must be hex format)
9408: Invalid trim value
9411: Invalid rotation (must be 90/180/270/auto)
9412: Invalid brightness (-100 to 100)
9413: Invalid contrast (-100 to 100)Resolution
Load references/top-errors.md for complete error solutions.
Implementation Examples
React Component
interface CloudflareImageProps {
imageId: string;
width?: number;
quality?: number;
format?: 'auto' | 'webp' | 'avif' | 'jpeg';
}
export function CloudflareImage({
imageId,
width = 800,
quality = 85,
format = 'auto'
}: CloudflareImageProps) {
const params = new URLSearchParams({
width: width.toString(),
quality: quality.toString(),
format
});
const url = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/public?${params}`;
return <img src={url} alt="" loading="lazy" />;
}Cloudflare Worker
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const imageId = url.pathname.slice(1);
// Dynamic transformation based on request
const isMobile = /mobile/i.test(request.headers.get('user-agent') || '');
const image = await env.IMAGES.get(imageId, {
cf: {
image: {
width: isMobile ? 400 : 800,
quality: isMobile ? 80 : 85,
format: 'auto'
}
}
});
return new Response(image.body, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000'
}
});
}
};Related References
- Transformation Options:
references/transformation-options.md - Format Optimization:
references/format-optimization.md - Top Errors:
references/top-errors.md - API Reference:
references/api-reference.md
Related Diagrams
- Direct Upload Workflow:
diagrams/direct-upload-workflow.md - Variants Architecture:
diagrams/variants-structure.md
Cloudflare Images Variants Architecture
Visual architecture comparing Named Variants vs Flexible Transformations, including variant configuration, management, and usage patterns.
Variants Architecture Overview
flowchart TB
Upload[Image Upload]
subgraph "Cloudflare Images Storage"
Original[Original Image<br/>Stored Once]
end
subgraph "Named Variants"
Config[Variant Configuration<br/>Max 100 variants]
V1[thumbnail<br/>300x300 cover q=80]
V2[medium<br/>800x800 scale-down q=85]
V3[large<br/>1600x1600 scale-down q=90]
V4[avatar-sm<br/>48x48 cover q=80]
V5[Custom variants...]
Config --> V1
Config --> V2
Config --> V3
Config --> V4
Config --> V5
end
subgraph "Flexible Transformations"
URL1[URL: ?width=400&quality=80]
URL2[URL: ?width=800&quality=85&format=webp]
URL3[URL: ?width=1200&fit=cover&blur=20]
URL4[Any combination...]
end
subgraph "Delivery URLs"
Named1[imagedelivery.net/{hash}/{id}/thumbnail]
Named2[imagedelivery.net/{hash}/{id}/medium]
Flex1[imagedelivery.net/{hash}/{id}/public?width=400]
Flex2[imagedelivery.net/{hash}/{id}/public?width=800&fit=cover]
end
Upload --> Original
Original --> Config
Original --> URL1
V1 -.-> Named1
V2 -.-> Named2
URL1 -.-> Flex1
URL2 -.-> Flex2
style Original fill:#bbf,stroke:#333,stroke-width:2px
style Config fill:#f9f,stroke:#333,stroke-width:2px
style Named1 fill:#bfb,stroke:#333,stroke-width:2px
style Flex1 fill:#fbf,stroke:#333,stroke-width:2pxNamed Variants vs Flexible Transformations
Named Variants
Use Case: Pre-defined sizes you use frequently
Pros:
- ✅ Shorter URLs
- ✅ Consistent sizing across app
- ✅ Easier to manage centrally
- ✅ Can require signed URLs per-variant
Cons:
- ❌ Limited to 100 variants max
- ❌ Requires API call to create
- ❌ Less flexible (fixed parameters)
Example:
<!-- Named variant -->
<img src="https://imagedelivery.net/{hash}/{id}/thumbnail" />Flexible Transformations
Use Case: Dynamic transformations, one-off sizes
Pros:
- ✅ Unlimited combinations
- ✅ No setup required
- ✅ Dynamic parameters
- ✅ Great for responsive images
Cons:
- ❌ Longer URLs
- ❌ Potential for parameter misuse
- ❌ Harder to enforce consistency
Example:
<!-- Flexible transformation -->
<img src="https://imagedelivery.net/{hash}/{id}/public?width=300&height=300&fit=cover&quality=80" />Variant Configuration Workflow
sequenceDiagram
actor Admin
participant Dashboard as Cloudflare Dashboard
participant API as Cloudflare API
participant Storage as Variant Storage
participant CDN as CDN Edge
Note over Admin,CDN: Phase 1: Create Variant
Admin->>Dashboard: Define variant<br/>(name, dimensions, options)
Dashboard->>API: POST /variants<br/>{id, options}
API->>Storage: Store configuration
Storage-->>API: Variant created
API-->>Dashboard: Success
Dashboard-->>Admin: Variant ready
Note over Admin,CDN: Phase 2: Use Variant
Admin->>CDN: Request image<br/>/{hash}/{id}/variant-name
CDN->>Storage: Lookup variant config
Storage-->>CDN: {width, height, fit, quality}
CDN->>CDN: Apply transformations<br/>Cache result
CDN-->>Admin: Transformed image
Note over Admin,CDN: Phase 3: Update Variant
Admin->>Dashboard: Modify variant options
Dashboard->>API: PATCH /variants/{id}
API->>Storage: Update configuration
API->>CDN: Purge variant cache
CDN-->>Admin: New version servedVariant Limit Management
flowchart TB
Start[Start: Need New Variant]
Check{Variants < 100?}
Create[Create New Variant]
Audit[Audit Existing Variants]
subgraph "Cleanup Options"
Delete[Delete Unused Variants]
Merge[Merge Similar Variants]
Flexible[Use Flexible Transform]
end
Success[Variant Created]
Alternative[Use Alternative]
Start --> Check
Check -->|Yes| Create
Check -->|No| Audit
Create --> Success
Audit --> Delete
Audit --> Merge
Audit --> Flexible
Delete --> Create
Merge --> Create
Flexible --> Alternative
style Check fill:#f9f,stroke:#333,stroke-width:2px
style Success fill:#bfb,stroke:#333,stroke-width:2px
style Alternative fill:#fbf,stroke:#333,stroke-width:2pxVariant Limit Best Practices
100 Variant Limit: Plan carefully
1. Common Sizes Only: Create variants for 80% use cases 2. Flexible for Edge Cases: Use URL params for one-off sizes 3. Regular Audit: Delete unused variants 4. Naming Convention: Consistent naming (e.g., product-sm, product-md, product-lg)
Recommended Variant Sets
E-Commerce Product Images
{
"product-thumb": { "width": 150, "height": 150, "fit": "cover", "quality": 80 },
"product-sm": { "width": 300, "height": 300, "fit": "cover", "quality": 85 },
"product-md": { "width": 600, "height": 600, "fit": "scale-down", "quality": 85 },
"product-lg": { "width": 1200, "height": 1200, "fit": "scale-down", "quality": 90 },
"product-zoom": { "width": 2400, "height": 2400, "fit": "scale-down", "quality": 95 }
}Variants Used: 5/100
User Avatars
{
"avatar-xs": { "width": 24, "height": 24, "fit": "cover", "quality": 75 },
"avatar-sm": { "width": 48, "height": 48, "fit": "cover", "quality": 80 },
"avatar-md": { "width": 96, "height": 96, "fit": "cover", "quality": 85 },
"avatar-lg": { "width": 192, "height": 192, "fit": "cover", "quality": 85 },
"avatar-xl": { "width": 384, "height": 384, "fit": "cover", "quality": 90 }
}Variants Used: 5/100
Blog/Content Images
{
"content-thumb": { "width": 400, "height": 225, "fit": "cover", "quality": 80 },
"content-mobile": { "width": 768, "fit": "scale-down", "quality": 85 },
"content-tablet": { "width": 1024, "fit": "scale-down", "quality": 85 },
"content-desktop": { "width": 1600, "fit": "scale-down", "quality": 90 }
}Variants Used: 4/100
Hero/Banner Images
{
"hero-mobile": { "width": 768, "height": 432, "fit": "cover", "quality": 85 },
"hero-tablet": { "width": 1024, "height": 576, "fit": "cover", "quality": 90 },
"hero-desktop": { "width": 1920, "height": 1080, "fit": "cover", "quality": 90 },
"hero-4k": { "width": 3840, "height": 2160, "fit": "cover", "quality": 95 }
}Variants Used: 4/100
Variant Management API
Create Variant
curl -X POST \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"id": "thumbnail",
"options": {
"width": 300,
"height": 300,
"fit": "cover",
"metadata": "none"
},
"neverRequireSignedURLs": true
}'List Variants
curl -X GET \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants" \
-H "Authorization: Bearer ${CF_API_TOKEN}"Update Variant
curl -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants/thumbnail" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"options": {
"width": 350,
"height": 350,
"fit": "cover",
"quality": 85
}
}'Delete Variant
curl -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants/thumbnail" \
-H "Authorization: Bearer ${CF_API_TOKEN}"Variant Usage Patterns
Pattern 1: Named Variants with srcset
<img
src="https://imagedelivery.net/{hash}/{id}/product-md"
srcset="
https://imagedelivery.net/{hash}/{id}/product-sm 300w,
https://imagedelivery.net/{hash}/{id}/product-md 600w,
https://imagedelivery.net/{hash}/{id}/product-lg 1200w
"
sizes="(max-width: 640px) 100vw, 600px"
alt="Product"
/>Pattern 2: Flexible Transformations with srcset
<img
src="https://imagedelivery.net/{hash}/{id}/public?width=600"
srcset="
https://imagedelivery.net/{hash}/{id}/public?width=300 300w,
https://imagedelivery.net/{hash}/{id}/public?width=600 600w,
https://imagedelivery.net/{hash}/{id}/public?width=1200 1200w
"
sizes="(max-width: 640px) 100vw, 600px"
alt="Product"
/>Pattern 3: Hybrid Approach (Recommended)
// Named variants for common sizes
const VARIANTS = {
thumbnail: 'product-thumb',
small: 'product-sm',
medium: 'product-md',
large: 'product-lg'
};
// Flexible for one-off sizes
function getImageUrl(imageId: string, size: number | keyof typeof VARIANTS) {
const baseUrl = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}`;
if (typeof size === 'string' && size in VARIANTS) {
// Use named variant
return `${baseUrl}/${VARIANTS[size]}`;
} else {
// Use flexible transformation
return `${baseUrl}/public?width=${size}&quality=85&format=auto`;
}
}
// Usage
<img src={getImageUrl(imageId, 'medium')} /> // Named variant
<img src={getImageUrl(imageId, 450)} /> // Flexible transformVariant Caching Behavior
flowchart LR
Request[Request]
subgraph "CDN Cache"
VarCache[Variant Cache<br/>TTL ~30 days]
end
subgraph "Variant Processing"
Lookup[Lookup Config]
Transform[Apply Transform]
Store[Store Result]
end
Response[Response]
Request --> VarCache
VarCache -->|HIT| Response
VarCache -->|MISS| Lookup
Lookup --> Transform
Transform --> Store
Store --> Response
style VarCache fill:#bfb,stroke:#333,stroke-width:2px
style Transform fill:#bbf,stroke:#333,stroke-width:2pxCache Keys
- Named Variant:
{imageId}/{variantName} - Flexible:
{imageId}/public?{sortedParams}
Cache Invalidation
- Update Variant: Purges all cached images for that variant
- Delete Original: Purges all variants
- Manual Purge: API endpoint available
Related References
- Variants Guide:
references/variants-guide.md - Transformation Options:
references/transformation-options.md - Responsive Images:
references/responsive-images-patterns.md - API Reference:
references/api-reference.md
Related Diagrams
- Direct Upload Workflow:
diagrams/direct-upload-workflow.md - Transformation Pipeline:
diagrams/transformation-pipeline.md
Related Commands
- Generate Variant:
/generate-variant- Interactive variant creator - Check Images:
/check-images- List all configured variants
# Cloudflare Images Configuration
# Copy this file to .env and fill in your credentials
CF_ACCOUNT_ID=your_account_id_here
CF_API_TOKEN=your_api_token_here
CF_ACCOUNT_HASH=your_account_hash_here
# Get your credentials:
# - Account ID: Dashboard → Workers & Pages → Account ID (right sidebar)
# - API Token: Dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Images"
# - Account Hash: Dashboard → Images → Serving Images → Account Hash
{
"name": "cloudflare-images-basic-upload",
"version": "1.0.0",
"description": "Minimal example of uploading to Cloudflare Images",
"main": "src/index.ts",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"keywords": [
"cloudflare",
"images",
"upload",
"workers"
],
"author": "",
"license": "MIT",
"devDependencies": {
"@cloudflare/workers-types": "^4.20250110.0",
"wrangler": "^4.81.0"
},
"dependencies": {
"hono": "^4.12.12"
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare Images - Basic Upload Example</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.upload-form {
margin-bottom: 30px;
}
.file-input-wrapper {
position: relative;
margin-bottom: 20px;
}
input[type="file"] {
width: 100%;
padding: 15px;
border: 2px dashed #ddd;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: border-color 0.3s;
}
input[type="file"]:hover {
border-color: #667eea;
}
.upload-button {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, opacity 0.3s;
}
.upload-button:hover:not(:disabled) {
transform: translateY(-2px);
}
.upload-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.progress-container {
display: none;
margin-bottom: 20px;
}
.progress-bar {
width: 100%;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
width: 0%;
transition: width 0.3s;
}
.progress-text {
margin-top: 8px;
color: #666;
font-size: 14px;
text-align: center;
}
.error {
background: #fee;
color: #c33;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #c33;
display: none;
}
.success {
background: #efe;
color: #3c3;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #3c3;
display: none;
}
.uploaded-image-container {
display: none;
text-align: center;
}
.uploaded-image-container h2 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.uploaded-image {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.image-details {
margin-top: 15px;
padding: 15px;
background: #f8f8f8;
border-radius: 8px;
text-align: left;
font-size: 13px;
color: #666;
}
.image-details div {
margin: 5px 0;
}
.image-details strong {
color: #333;
}
</style>
</head>
<body>
<div class="container">
<h1>🖼️ Cloudflare Images Upload</h1>
<p class="subtitle">Basic Upload Example - Direct Creator Upload Pattern</p>
<div class="error" id="error"></div>
<div class="success" id="success"></div>
<form class="upload-form" id="upload-form">
<div class="file-input-wrapper">
<input
type="file"
id="file-input"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/>
</div>
<div class="progress-container" id="progress-container">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-text" id="progress-text">Uploading... 0%</div>
</div>
<button type="submit" class="upload-button" id="upload-button">
Upload Image
</button>
</form>
<div class="uploaded-image-container" id="uploaded-image-container">
<h2>✅ Upload Successful!</h2>
<img class="uploaded-image" id="uploaded-image" alt="Uploaded image">
<div class="image-details" id="image-details"></div>
</div>
</div>
<script>
// Configuration
const API_URL = 'http://localhost:8787'; // Change to your Worker URL in production
const ACCOUNT_HASH = 'your_account_hash_here'; // Replace with your actual account hash
// DOM elements
const form = document.getElementById('upload-form');
const fileInput = document.getElementById('file-input');
const uploadButton = document.getElementById('upload-button');
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const errorDiv = document.getElementById('error');
const successDiv = document.getElementById('success');
const uploadedImageContainer = document.getElementById('uploaded-image-container');
const uploadedImage = document.getElementById('uploaded-image');
const imageDetails = document.getElementById('image-details');
// Form submit handler
form.addEventListener('submit', async (e) => {
e.preventDefault();
await uploadImage();
});
async function uploadImage() {
// Get selected file
const file = fileInput.files[0];
if (!file) {
showError('Please select a file');
return;
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
showError(`File too large (${(file.size / 1024 / 1024).toFixed(1)}MB). Maximum size is 10MB.`);
return;
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
showError('Invalid file type. Please upload JPEG, PNG, WebP, or GIF.');
return;
}
try {
// Reset UI
hideError();
hideSuccess();
uploadedImageContainer.style.display = 'none';
// Disable form
uploadButton.disabled = true;
fileInput.disabled = true;
// Show progress
showProgress(10);
// Step 1: Get one-time upload URL from our Worker
console.log('Requesting upload URL from Worker...');
const urlResponse = await fetch(`${API_URL}/api/upload-url`, {
method: 'POST'
});
if (!urlResponse.ok) {
throw new Error(`Failed to get upload URL: ${urlResponse.statusText}`);
}
const { uploadURL, imageId } = await urlResponse.json();
console.log('Got upload URL. Image ID:', imageId);
showProgress(30);
// Step 2: Upload file directly to Cloudflare Images
console.log('Uploading file to Cloudflare...');
const uploadFormData = new FormData();
uploadFormData.append('file', file);
const xhr = new XMLHttpRequest();
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = 30 + Math.round((e.loaded / e.total) * 60);
showProgress(percentComplete);
}
});
// Handle upload completion
const uploadPromise = new Promise((resolve, reject) => {
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error(`Upload failed: ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'));
});
});
xhr.open('POST', uploadURL);
xhr.send(uploadFormData);
await uploadPromise;
showProgress(100);
console.log('Upload successful!');
// Step 3: Display uploaded image
const imageUrl = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/public`;
uploadedImage.src = imageUrl;
imageDetails.innerHTML = `
<div><strong>Image ID:</strong> ${imageId}</div>
<div><strong>Filename:</strong> ${file.name}</div>
<div><strong>Size:</strong> ${(file.size / 1024).toFixed(1)} KB</div>
<div><strong>Type:</strong> ${file.type}</div>
<div><strong>URL:</strong> <a href="${imageUrl}" target="_blank">${imageUrl}</a></div>
`;
uploadedImageContainer.style.display = 'block';
showSuccess('Image uploaded successfully!');
// Reset form
form.reset();
} catch (error) {
console.error('Upload error:', error);
showError(error.message);
} finally {
// Re-enable form
uploadButton.disabled = false;
fileInput.disabled = false;
hideProgress();
}
}
// UI helper functions
function showProgress(percent) {
progressContainer.style.display = 'block';
progressFill.style.width = `${percent}%`;
progressText.textContent = `Uploading... ${percent}%`;
}
function hideProgress() {
setTimeout(() => {
progressContainer.style.display = 'none';
progressFill.style.width = '0%';
}, 500);
}
function showError(message) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
function hideError() {
errorDiv.style.display = 'none';
}
function showSuccess(message) {
successDiv.textContent = message;
successDiv.style.display = 'block';
}
function hideSuccess() {
successDiv.style.display = 'none';
}
</script>
</body>
</html>
Basic Upload Example
Minimal but complete example of uploading images to Cloudflare Images using Direct Creator Upload pattern.
Features
- ✅ Direct Creator Upload (frontend → Cloudflare, no backend bottleneck)
- ✅ Cloudflare Worker backend for generating upload URLs
- ✅ Simple HTML/JavaScript frontend
- ✅ File validation (size, type)
- ✅ Upload progress tracking
- ✅ Error handling
- ✅ Success state with image display
Project Structure
basic-upload/
├── README.md # This file
├── package.json # Dependencies
├── wrangler.jsonc # Cloudflare Worker config
├── .env.example # Example environment variables
├── src/
│ └── index.ts # Worker: Upload URL generation
└── public/
└── index.html # Frontend: Upload formPrerequisites
- Node.js 18+ installed
- Cloudflare account with Images enabled
- Wrangler CLI:
npm install -g wrangler
Setup
1. Install Dependencies
npm install2. Configure Environment Variables
cp .env.example .envEdit .env and add your Cloudflare credentials:
CF_ACCOUNT_ID=your_account_id_here
CF_API_TOKEN=your_api_token_here
CF_ACCOUNT_HASH=your_account_hash_hereGet your credentials:
- Account ID: Cloudflare Dashboard → Workers & Pages → Account ID
- API Token: Dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Images"
- Account Hash: Dashboard → Images → Serving Images → Account Hash
3. Update wrangler.jsonc
Edit wrangler.jsonc and replace YOUR_ACCOUNT_ID with your actual account ID:
{
"account_id": "your_account_id_here"
}4. Run Locally
wrangler devThis starts the Worker at http://localhost:8787
5. Open Frontend
Open public/index.html in your browser (or serve it locally):
# Option 1: Open directly
open public/index.html
# Option 2: Use a local server
npx serve public6. Test Upload
1. Click "Choose File" and select an image 2. Click "Upload Image" 3. Watch progress bar (0-100%) 4. See uploaded image displayed on success
How It Works
Architecture
Browser Worker Cloudflare Images
| | |
|---(1) Request URL------>| |
| |---(2) Generate URL-------->|
| |<---(3) {uploadURL, id}-----|
|<---(4) Return URL-------| |
| |
|--------------(5) Upload File to uploadURL----------->|
|<-------------(6) 200 OK-------------------------------|
| |
|---(7) Display image from imagedelivery.net---------->|Step-by-Step
1. User selects file: Frontend validates size (<10MB) and type (JPEG/PNG/WebP/GIF) 2. Frontend requests upload URL: POST http://localhost:8787/api/upload-url 3. Worker generates one-time URL: Calls Cloudflare Images API /direct_upload 4. Worker returns URL to frontend: {uploadURL, imageId} 5. Frontend uploads directly to Cloudflare: POST uploadURL with multipart/form-data 6. Cloudflare processes image: Stores, generates variants, caches 7. Frontend displays image: Fetches from imagedelivery.net
Key Code
Worker (src/index.ts):
// Generate one-time upload URL
app.post('/api/upload-url', async (c) => {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${c.env.CF_API_TOKEN}` }
}
);
const result = await response.json();
return c.json({
uploadURL: result.result.uploadURL,
imageId: result.result.id
});
});Frontend (public/index.html):
// Get upload URL
const { uploadURL, imageId } = await fetch('http://localhost:8787/api/upload-url', {
method: 'POST'
}).then(r => r.json());
// Upload to Cloudflare
const formData = new FormData();
formData.append('file', file);
await fetch(uploadURL, {
method: 'POST',
body: formData
});
// Display uploaded image
img.src = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/public`;Deploy to Production
1. Deploy Worker
wrangler deployThis deploys your Worker to https://basic-upload.YOUR_SUBDOMAIN.workers.dev
2. Update Frontend
Edit public/index.html and replace http://localhost:8787 with your Worker URL:
const API_URL = 'https://basic-upload.YOUR_SUBDOMAIN.workers.dev';3. Deploy Frontend
Deploy public/index.html to:
- Cloudflare Pages
- Vercel
- Netlify
- Any static hosting
Troubleshooting
Error: CORS Policy
Symptom: "CORS policy: No 'Access-Control-Allow-Origin' header"
Solution: CORS headers are already configured in Worker. Ensure:
- Frontend is served from same origin as Worker, OR
- Update CORS origins in
src/index.ts
Error: Upload URL Expired
Symptom: Upload fails after waiting
Solution: Upload URLs expire after 30 minutes. Generate new URL for each upload.
Error: File Too Large
Symptom: "File too large" error
Solution: Cloudflare Images max file size is 10MB. Compress image before upload.
Error: Invalid File Type
Symptom: Upload rejected
Solution: Only JPEG, PNG, WebP, GIF supported. Convert other formats.
Next Steps
Add Features
- Webhook: Handle upload notifications (
templates/webhook-handler.ts) - Database: Store image metadata (Drizzle ORM + D1)
- Variants: Create named variants (
/generate-variant) - Signed URLs: Private images (
references/signed-urls-guide.md) - Watermarks: Add branding (
templates/overlay-watermark.ts)
Production Checklist
- [ ] Environment variables in Wrangler secrets (not
.env) - [ ] CORS origins restricted to your domain
- [ ] Rate limiting on upload URL generation
- [ ] File validation server-side (not just client-side)
- [ ] Error logging and monitoring
- [ ] CDN caching configured
- [ ] Variants created for common sizes
Related Examples
- Responsive Gallery: Complete gallery with responsive images
- Private Images: Signed URLs for access control
Related References
- Direct Upload Guide:
references/direct-upload-complete-workflow.md - API Reference:
references/api-reference.md - Worker Template:
templates/worker-upload.ts
/**
* Basic Upload Example - Cloudflare Worker
*
* Generates one-time upload URLs for Direct Creator Upload pattern.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
interface Env {
CF_ACCOUNT_ID: string;
CF_API_TOKEN: string;
CF_ACCOUNT_HASH: string;
}
const app = new Hono<{ Bindings: Env }>();
// CORS configuration - Allow frontend to call API
app.use('/*', cors({
origin: ['http://localhost:8787', 'http://localhost:3000', 'http://localhost:5173'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type'],
credentials: true
}));
/**
* Health check endpoint
*/
app.get('/', (c) => {
return c.json({
status: 'ok',
service: 'Cloudflare Images Basic Upload Example',
endpoints: {
uploadUrl: 'POST /api/upload-url',
health: 'GET /'
}
});
});
/**
* Generate one-time upload URL
*
* POST /api/upload-url
*
* Returns:
* {
* "uploadURL": "https://upload.imagedelivery.net/...",
* "imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901"
* }
*/
app.post('/api/upload-url', async (c) => {
try {
// Verify environment variables are set
if (!c.env.CF_ACCOUNT_ID || !c.env.CF_API_TOKEN) {
return c.json({
error: 'Missing configuration',
message: 'CF_ACCOUNT_ID and CF_API_TOKEN must be set'
}, 500);
}
console.log('Generating upload URL for account:', c.env.CF_ACCOUNT_ID.substring(0, 8) + '...');
// Request one-time upload URL from Cloudflare Images API
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.CF_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
requireSignedURLs: false,
metadata: {
source: 'basic-upload-example',
timestamp: new Date().toISOString()
}
})
}
);
const result = await response.json<any>();
if (!result.success) {
console.error('Cloudflare API error:', result.errors);
return c.json({
error: 'Failed to generate upload URL',
details: result.errors
}, 500);
}
console.log('Upload URL generated successfully. Image ID:', result.result.id);
// Return upload URL and image ID to frontend
return c.json({
uploadURL: result.result.uploadURL,
imageId: result.result.id
});
} catch (error) {
console.error('Error generating upload URL:', error);
return c.json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error'
}, 500);
}
});
export default app;
{
"name": "cloudflare-images-basic-upload",
"main": "src/index.ts",
"compatibility_date": "2025-01-15",
"account_id": "YOUR_ACCOUNT_ID",
// Environment variables (for local development)
"vars": {
"CF_ACCOUNT_HASH": "your_account_hash"
}
// For production, use secrets instead:
// wrangler secret put CF_ACCOUNT_ID
// wrangler secret put CF_API_TOKEN
}
# Cloudflare Images Configuration
# Copy this file to .env and fill in your credentials
CF_ACCOUNT_ID=your_account_id_here
CF_API_TOKEN=your_api_token_here
CF_ACCOUNT_HASH=your_account_hash_here
CF_IMAGES_SIGNING_KEY=your_signing_key_here
# Get your credentials:
# - Account ID: Dashboard → Workers & Pages → Account ID (right sidebar)
# - API Token: Dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Images"
# - Account Hash: Dashboard → Images → Serving Images → Account Hash
# - Signing Key: Dashboard → Images → Signing Keys → Create Key
# Or generate with: openssl rand -hex 32
{
"name": "cloudflare-images-private-images",
"version": "1.0.0",
"description": "Complete implementation of signed URLs for private image access control using Cloudflare Images",
"main": "src/index.ts",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"keywords": [
"cloudflare",
"images",
"workers",
"signed-urls",
"private-images",
"access-control",
"hmac",
"security"
],
"author": "",
"license": "MIT",
"dependencies": {
"hono": "^4.12.12",
"@tsndr/cloudflare-worker-jwt": "^2.5.4"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250110.0",
"wrangler": "^4.81.0",
"typescript": "^5.9.3"
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare Images - Private Images with Signed URLs</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 700px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.security-badge {
display: inline-block;
background: #3c3;
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-left: 10px;
}
.upload-form {
margin-bottom: 30px;
}
.file-input-wrapper {
position: relative;
margin-bottom: 20px;
}
input[type="file"] {
width: 100%;
padding: 15px;
border: 2px dashed #ddd;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: border-color 0.3s;
}
input[type="file"]:hover {
border-color: #667eea;
}
.expiry-selector {
margin-bottom: 20px;
}
.expiry-selector label {
display: block;
color: #333;
font-weight: 600;
margin-bottom: 8px;
font-size: 14px;
}
.expiry-selector select {
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: border-color 0.3s;
}
.expiry-selector select:focus {
outline: none;
border-color: #667eea;
}
.upload-button {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, opacity 0.3s;
}
.upload-button:hover:not(:disabled) {
transform: translateY(-2px);
}
.upload-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.progress-container {
display: none;
margin-bottom: 20px;
}
.progress-bar {
width: 100%;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
width: 0%;
transition: width 0.3s;
}
.progress-text {
margin-top: 8px;
color: #666;
font-size: 14px;
text-align: center;
}
.error {
background: #fee;
color: #c33;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #c33;
display: none;
}
.success {
background: #efe;
color: #3c3;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #3c3;
display: none;
}
.uploaded-image-container {
display: none;
}
.uploaded-image-container h2 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.expiry-info {
background: #fff3cd;
border: 1px solid #ffc107;
color: #856404;
padding: 12px;
border-radius: 8px;
margin-bottom: 15px;
font-size: 14px;
}
.expiry-info strong {
color: #333;
}
.countdown {
font-weight: 600;
color: #c33;
}
.uploaded-image {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
margin-bottom: 15px;
}
.image-details {
margin-bottom: 15px;
padding: 15px;
background: #f8f8f8;
border-radius: 8px;
font-size: 13px;
color: #666;
}
.image-details div {
margin: 5px 0;
}
.image-details strong {
color: #333;
}
.refresh-button {
width: 100%;
padding: 12px;
background: #ffc107;
color: #333;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.refresh-button:hover {
transform: translateY(-2px);
}
.info-section {
margin-top: 30px;
padding: 20px;
background: #f0f7ff;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.info-section h3 {
color: #333;
margin-bottom: 10px;
font-size: 16px;
}
.info-section p {
color: #666;
font-size: 14px;
line-height: 1.6;
}
.info-section ul {
margin-top: 10px;
padding-left: 20px;
}
.info-section li {
color: #666;
font-size: 14px;
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🔒 Private Images <span class="security-badge">Secure</span></h1>
<p class="subtitle">Signed URLs with Time-Based Expiry</p>
<div class="error" id="error"></div>
<div class="success" id="success"></div>
<form class="upload-form" id="upload-form">
<div class="file-input-wrapper">
<input
type="file"
id="file-input"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/>
</div>
<div class="expiry-selector">
<label for="expiry-select">URL Expiry Time:</label>
<select id="expiry-select">
<option value="300">5 minutes (Highly Sensitive)</option>
<option value="900">15 minutes (Sensitive)</option>
<option value="3600" selected>1 hour (Default)</option>
<option value="14400">4 hours (Extended)</option>
<option value="86400">24 hours (Public Sharing)</option>
</select>
</div>
<div class="progress-container" id="progress-container">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-text" id="progress-text">Uploading... 0%</div>
</div>
<button type="submit" class="upload-button" id="upload-button">
Upload Private Image
</button>
</form>
<div class="uploaded-image-container" id="uploaded-image-container">
<h2>✅ Private Image Uploaded!</h2>
<div class="expiry-info" id="expiry-info">
<strong>URL Expires:</strong> <span id="expiry-time"></span><br>
<strong>Time Remaining:</strong> <span class="countdown" id="countdown"></span>
</div>
<img class="uploaded-image" id="uploaded-image" alt="Private image">
<div class="image-details" id="image-details"></div>
<button class="refresh-button" id="refresh-button">
🔄 Refresh Signed URL (Extend Access)
</button>
</div>
<div class="info-section">
<h3>🛡️ How Signed URLs Work</h3>
<p>
This example demonstrates <strong>signed URLs</strong> for private image access control.
The image can only be accessed with a cryptographically signed URL that expires after a set time.
</p>
<ul>
<li><strong>HMAC-SHA256 Signatures:</strong> URLs are signed using your secret key</li>
<li><strong>Time-Based Expiry:</strong> URLs automatically expire after the selected duration</li>
<li><strong>Access Control:</strong> No access without a valid signature</li>
<li><strong>Perfect For:</strong> User uploads, premium content, temporary sharing, HIPAA/GDPR compliance</li>
</ul>
</div>
</div>
<script>
// Configuration
const API_URL = 'http://localhost:8787'; // Change to your Worker URL in production
// DOM elements
const form = document.getElementById('upload-form');
const fileInput = document.getElementById('file-input');
const expirySelect = document.getElementById('expiry-select');
const uploadButton = document.getElementById('upload-button');
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const errorDiv = document.getElementById('error');
const successDiv = document.getElementById('success');
const uploadedImageContainer = document.getElementById('uploaded-image-container');
const uploadedImage = document.getElementById('uploaded-image');
const imageDetails = document.getElementById('image-details');
const expiryInfo = document.getElementById('expiry-info');
const expiryTime = document.getElementById('expiry-time');
const countdown = document.getElementById('countdown');
const refreshButton = document.getElementById('refresh-button');
let currentImageId = null;
let countdownInterval = null;
// Form submit handler
form.addEventListener('submit', async (e) => {
e.preventDefault();
await uploadPrivateImage();
});
// Refresh button handler
refreshButton.addEventListener('click', async () => {
if (currentImageId) {
await generateSignedUrl(currentImageId);
}
});
async function uploadPrivateImage() {
// Get selected file
const file = fileInput.files[0];
if (!file) {
showError('Please select a file');
return;
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
showError(`File too large (${(file.size / 1024 / 1024).toFixed(1)}MB). Maximum size is 10MB.`);
return;
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
showError('Invalid file type. Please upload JPEG, PNG, WebP, or GIF.');
return;
}
try {
// Reset UI
hideError();
hideSuccess();
uploadedImageContainer.style.display = 'none';
// Disable form
uploadButton.disabled = true;
fileInput.disabled = true;
expirySelect.disabled = true;
// Show progress
showProgress(10);
// Step 1: Upload private image
console.log('Uploading private image...');
const uploadFormData = new FormData();
uploadFormData.append('file', file);
const uploadResponse = await fetch(`${API_URL}/api/upload-private`, {
method: 'POST',
body: uploadFormData
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
}
const uploadResult = await uploadResponse.json();
console.log('Private image uploaded. Image ID:', uploadResult.imageId);
currentImageId = uploadResult.imageId;
showProgress(50);
// Step 2: Generate signed URL
await generateSignedUrl(uploadResult.imageId);
showProgress(100);
showSuccess('Private image uploaded successfully!');
// Reset form
form.reset();
} catch (error) {
console.error('Upload error:', error);
showError(error.message);
} finally {
// Re-enable form
uploadButton.disabled = false;
fileInput.disabled = false;
expirySelect.disabled = false;
hideProgress();
}
}
async function generateSignedUrl(imageId) {
try {
const expirySeconds = parseInt(expirySelect.value);
console.log('Generating signed URL...', { imageId, expirySeconds });
const signResponse = await fetch(`${API_URL}/api/sign-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
imageId,
variant: 'public',
expirySeconds
})
});
if (!signResponse.ok) {
throw new Error(`Signing failed: ${signResponse.statusText}`);
}
const signResult = await signResponse.json();
console.log('Signed URL generated:', signResult);
// Display image
uploadedImage.src = signResult.signedUrl;
// Show expiry info
const expiresAt = new Date(signResult.expiresAt);
expiryTime.textContent = expiresAt.toLocaleString();
// Start countdown
startCountdown(expiresAt);
// Show image details
imageDetails.innerHTML = `
<div><strong>Image ID:</strong> ${imageId}</div>
<div><strong>Signed URL:</strong> <code style="font-size: 11px; word-break: break-all;">${signResult.signedUrl}</code></div>
<div><strong>Expiry:</strong> ${expirySeconds} seconds (${formatDuration(expirySeconds)})</div>
<div><strong>Security:</strong> HMAC-SHA256 signed</div>
`;
uploadedImageContainer.style.display = 'block';
} catch (error) {
console.error('Signing error:', error);
showError(error.message);
}
}
function startCountdown(expiresAt) {
// Clear existing interval
if (countdownInterval) {
clearInterval(countdownInterval);
}
// Update countdown every second
countdownInterval = setInterval(() => {
const now = new Date();
const remaining = Math.max(0, expiresAt - now);
if (remaining === 0) {
clearInterval(countdownInterval);
countdown.textContent = 'EXPIRED';
expiryInfo.style.background = '#fee';
expiryInfo.style.borderColor = '#c33';
expiryInfo.style.color = '#c33';
} else {
countdown.textContent = formatDuration(Math.floor(remaining / 1000));
}
}, 1000);
}
function formatDuration(seconds) {
if (seconds >= 86400) {
return `${Math.floor(seconds / 86400)} days`;
} else if (seconds >= 3600) {
return `${Math.floor(seconds / 3600)} hours`;
} else if (seconds >= 60) {
return `${Math.floor(seconds / 60)} minutes`;
} else {
return `${seconds} seconds`;
}
}
// UI helper functions
function showProgress(percent) {
progressContainer.style.display = 'block';
progressFill.style.width = `${percent}%`;
progressText.textContent = `Uploading... ${percent}%`;
}
function hideProgress() {
setTimeout(() => {
progressContainer.style.display = 'none';
progressFill.style.width = '0%';
}, 500);
}
function showError(message) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
function hideError() {
errorDiv.style.display = 'none';
}
function showSuccess(message) {
successDiv.textContent = message;
successDiv.style.display = 'block';
}
function hideSuccess() {
successDiv.style.display = 'none';
}
</script>
</body>
</html>
Private Images Example
Complete implementation of signed URLs for private image access control using Cloudflare Images.
Features
- ✅ HMAC-SHA256 signed URL generation
- ✅ Time-based expiry (customizable)
- ✅ Access control patterns
- ✅ Secure image delivery
- ✅ Frontend authentication flow
- ✅ Token validation
- ✅ Automatic expiry handling
Live Demo Structure
private-images/
├── README.md # This file
├── package.json # Dependencies
├── wrangler.jsonc # Worker configuration
├── .env.example # Environment variables template
├── src/
│ └── index.ts # Worker with signed URL generation
└── public/
└── index.html # Gallery UI with authenticationWhat are Signed URLs?
Signed URLs are cryptographically signed URLs that grant temporary access to private images. They prevent unauthorized access by requiring a valid signature that expires after a set time.
Use Cases:
- Private user content (profile photos, documents)
- Paid content (premium images, stock photos)
- Temporary sharing (time-limited access links)
- HIPAA/GDPR compliance (controlled access to sensitive images)
Architecture
┌─────────────────┐
│ Browser │
│ (Gallery) │
└────────┬────────┘
│ 1. Request signed URL
▼
┌─────────────────┐
│ Worker API │
│ /api/sign-url │
└────────┬────────┘
│ 2. Generate signature
│ HMAC-SHA256(imageId + expiry)
▼
┌─────────────────┐
│ Browser │
│ Displays image │
└────────┬────────┘
│ 3. Request image with signature
▼
┌─────────────────┐
│ Cloudflare CDN │
│ Validates sig │
└─────────────────┘Implementation
1. Upload Private Image
Images uploaded with requireSignedURLs: true can only be accessed with signed URLs:
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`
},
body: formData.append('requireSignedURLs', 'true') // ← KEY
}
);2. Generate Signed URL (Server-Side)
import { sign } from '@tsndr/cloudflare-worker-jwt';
// Generate expiry timestamp (1 hour from now)
const expiry = Math.floor(Date.now() / 1000) + 3600;
// Create signature using HMAC-SHA256
const signature = await sign(
{ imageId, expiry },
CF_IMAGES_SIGNING_KEY
);
// Construct signed URL
const signedUrl = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/public?exp=${expiry}&sig=${signature}`;3. Access Control Patterns
Time-Based Access:
// 5 minutes
const expiry = Math.floor(Date.now() / 1000) + 300;
// 1 hour
const expiry = Math.floor(Date.now() / 1000) + 3600;
// 24 hours
const expiry = Math.floor(Date.now() / 1000) + 86400;User-Based Access:
// Check user authentication first
if (!req.user || req.user.id !== imageOwnerId) {
return c.json({ error: 'Unauthorized' }, 403);
}
// Generate signed URL only for authorized user
const signedUrl = await generateSignedUrl(imageId, expiry);Content Type Restrictions:
// Only allow specific variants
const allowedVariants = ['thumbnail', 'medium'];
if (!allowedVariants.includes(variant)) {
return c.json({ error: 'Forbidden variant' }, 403);
}Setup
1. Install Dependencies
npm install2. Configure Environment
Copy .env.example to .env:
cp .env.example .envFill in your credentials:
CF_ACCOUNT_ID=your_account_id_here
CF_API_TOKEN=your_api_token_here
CF_ACCOUNT_HASH=your_account_hash_here
CF_IMAGES_SIGNING_KEY=your_signing_key_here # Generate: openssl rand -hex 32Get your credentials:
- Account ID: Dashboard → Workers & Pages → Account ID (right sidebar)
- API Token: Dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Images"
- Account Hash: Dashboard → Images → Serving Images → Account Hash
- Signing Key: Dashboard → Images → Signing Keys → Create Key (or generate with
openssl rand -hex 32)
3. Deploy Worker
# Development
npm run dev
# Production
npm run deploy4. Configure Wrangler
Update wrangler.jsonc with your Account ID:
{
"account_id": "YOUR_ACCOUNT_ID" // ← Replace
}5. Set Secrets
# Set secrets in production (more secure than .env)
npx wrangler secret put CF_ACCOUNT_ID
npx wrangler secret put CF_API_TOKEN
npx wrangler secret put CF_IMAGES_SIGNING_KEY6. Open Gallery
# Serve frontend locally
npx serve public
# Or open directly
open public/index.htmlAPI Endpoints
POST /api/upload-private
Upload a private image (requireSignedURLs: true).
Request:
curl -X POST http://localhost:8787/api/upload-private \
-F "file=@image.jpg"Response:
{
"imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"uploaded": true,
"requireSignedURLs": true
}POST /api/sign-url
Generate a signed URL for a private image.
Request:
curl -X POST http://localhost:8787/api/sign-url \
-H "Content-Type: application/json" \
-d '{
"imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"variant": "public",
"expirySeconds": 3600
}'Response:
{
"signedUrl": "https://imagedelivery.net/{hash}/{id}/public?exp=1234567890&sig=abc123...",
"expiresAt": "2024-01-15T12:00:00Z",
"expirySeconds": 3600
}GET /health
Health check endpoint.
Response:
{
"status": "ok",
"service": "Cloudflare Images Private Images Example"
}Security Best Practices
1. Use Strong Signing Keys
# Generate cryptographically secure key
openssl rand -hex 32
# Store as Wrangler secret (not in code)
npx wrangler secret put CF_IMAGES_SIGNING_KEY2. Short Expiry Times
// Prefer short expiry for sensitive content
const expiry = Math.floor(Date.now() / 1000) + 300; // 5 minutes3. Validate User Access
// Check user owns the image before signing
const image = await db.query('SELECT owner_id FROM images WHERE id = ?', [imageId]);
if (image.owner_id !== req.user.id) {
return c.json({ error: 'Forbidden' }, 403);
}4. Rate Limiting
// Limit signed URL generation
import { Ratelimit } from '@upstash/ratelimit';
const ratelimit = new Ratelimit({
redis: /* your redis */,
limiter: Ratelimit.slidingWindow(10, '1m') // 10 requests per minute
});
const { success } = await ratelimit.limit(userId);
if (!success) {
return c.json({ error: 'Rate limit exceeded' }, 429);
}5. Audit Logging
// Log all signed URL generations
await db.insert('audit_log').values({
user_id: req.user.id,
image_id: imageId,
action: 'generate_signed_url',
expiry: expiry,
timestamp: new Date()
});Testing
Test Signed URL Generation
# 1. Upload private image
IMAGE_ID=$(curl -X POST http://localhost:8787/api/upload-private \
-F "file=@test.jpg" | jq -r '.imageId')
# 2. Generate signed URL
SIGNED_URL=$(curl -X POST http://localhost:8787/api/sign-url \
-H "Content-Type: application/json" \
-d "{\"imageId\": \"$IMAGE_ID\", \"expirySeconds\": 300}" | jq -r '.signedUrl')
# 3. Access image
curl "$SIGNED_URL" -o output.jpg
# 4. Verify output.jpg displays correctly
open output.jpgTest Expiry
# Generate URL with 5-second expiry
SIGNED_URL=$(curl -X POST http://localhost:8787/api/sign-url \
-H "Content-Type: application/json" \
-d "{\"imageId\": \"$IMAGE_ID\", \"expirySeconds\": 5}" | jq -r '.signedUrl')
# Access immediately (should work)
curl "$SIGNED_URL" -o output1.jpg
# Wait 10 seconds
sleep 10
# Try again (should fail with 403)
curl "$SIGNED_URL" -o output2.jpg # ← Expect errorTest Invalid Signature
# Try accessing without signature (should fail)
curl "https://imagedelivery.net/${ACCOUNT_HASH}/${IMAGE_ID}/public" -o output.jpg
# ← Expect: 403 Forbidden (requireSignedURLs: true)Common Use Cases
1. User Profile Photos
Scenario: Users can upload profile photos visible only to authenticated users.
// Upload private profile photo
const uploadResponse = await uploadPrivateImage(file);
// Generate signed URL for logged-in user
const signedUrl = await generateSignedUrl(uploadResponse.imageId, 3600);
// Display in profile
<img src={signedUrl} alt="Profile" />2. Premium Content
Scenario: Paid users get temporary access to premium images.
// Check subscription
if (!user.isPremium) {
return c.json({ error: 'Subscription required' }, 402);
}
// Generate signed URL with 24-hour expiry
const signedUrl = await generateSignedUrl(imageId, 86400);3. Temporary Sharing
Scenario: Generate a shareable link that expires after 1 hour.
// Generate short-lived share link
const shareUrl = await generateSignedUrl(imageId, 3600);
// Send via email or copy to clipboard
await sendEmail(recipient, `View image: ${shareUrl}`);4. Medical/Legal Images (HIPAA/GDPR)
Scenario: Highly sensitive images with strict access control.
// Check authorization
if (!user.hasPermission('view_medical_records')) {
return c.json({ error: 'Unauthorized' }, 403);
}
// Generate very short expiry (5 minutes)
const signedUrl = await generateSignedUrl(imageId, 300);
// Log access for audit trail
await logAccess(user.id, imageId, 'medical_image_view');Performance Optimization
1. Cache Signed URLs
// Cache signed URL in KV for 50% of expiry time
const cacheKey = `signed:${imageId}:${variant}`;
const cachedUrl = await env.KV.get(cacheKey);
if (cachedUrl) {
return c.json({ signedUrl: cachedUrl });
}
const signedUrl = await generateSignedUrl(imageId, expiry);
await env.KV.put(cacheKey, signedUrl, { expirationTtl: expiry / 2 });
return c.json({ signedUrl });2. Batch Signing
// Sign multiple images at once
const imageIds = ['id1', 'id2', 'id3'];
const signedUrls = await Promise.all(
imageIds.map(id => generateSignedUrl(id, 3600))
);3. CDN Caching
Signed URLs are cached by Cloudflare CDN until expiry:
Cache-Control: public, max-age=<expiry-seconds>Ensure expiry is set correctly to leverage CDN caching.
Troubleshooting
Issue: "Invalid signature" (403)
Cause: Signature verification failed.
Solutions:
- Verify signing key matches between upload and URL generation
- Check expiry timestamp is in the future
- Ensure URL encoding is correct (no spaces, special chars)
Issue: Signed URL works initially, then fails
Cause: URL has expired.
Solutions:
- Increase
expirySecondswhen generating URL - Regenerate URL before displaying to user
- Implement automatic refresh in frontend
Issue: Cannot access image even with signature
Cause: Image not uploaded with requireSignedURLs: true.
Solutions:
- Re-upload image with
requireSignedURLs: true - Or remove signing requirement (not recommended for private content)
Issue: Signature works in browser but not in cURL
Cause: URL encoding differences.
Solutions:
- Ensure proper URL encoding:
encodeURIComponent(signedUrl) - Use raw URL in cURL:
curl "$SIGNED_URL"
Related Examples
- Basic Upload: Minimal upload implementation
- Responsive Gallery: Public image gallery with srcset
Related References
- Signed URLs Guide:
references/signed-urls-guide.md - API Reference:
references/api-reference.md - Top Errors:
references/top-errors.md - Security Best Practices:
references/api-reference.md(Security section)
Production Checklist
Before deploying to production:
- [ ] Store signing key in Wrangler secrets (not .env)
- [ ] Implement rate limiting on
/api/sign-url - [ ] Add user authentication/authorization
- [ ] Enable audit logging for signed URL generation
- [ ] Set appropriate expiry times (shorter for sensitive content)
- [ ] Test expiry behavior thoroughly
- [ ] Monitor signed URL generation rate
- [ ] Implement signed URL refresh mechanism in frontend
- [ ] Add CORS headers if accessing from different domain
- [ ] Set up error monitoring (Sentry, etc.)
License
MIT
/**
* Private Images Example - Cloudflare Worker
*
* Generates signed URLs for private images with time-based expiry.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { sign } from '@tsndr/cloudflare-worker-jwt';
interface Env {
CF_ACCOUNT_ID: string;
CF_API_TOKEN: string;
CF_ACCOUNT_HASH: string;
CF_IMAGES_SIGNING_KEY: string;
}
const app = new Hono<{ Bindings: Env }>();
// CORS configuration
app.use('/*', cors({
origin: ['http://localhost:8787', 'http://localhost:3000', 'http://localhost:5173'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type'],
credentials: true
}));
/**
* Health check endpoint
*/
app.get('/', (c) => {
return c.json({
status: 'ok',
service: 'Cloudflare Images Private Images Example',
endpoints: {
uploadPrivate: 'POST /api/upload-private',
signUrl: 'POST /api/sign-url',
health: 'GET /'
}
});
});
/**
* Upload private image
*
* POST /api/upload-private
*
* Uploads an image with requireSignedURLs: true
*
* Request (multipart/form-data):
* - file: Image file
*
* Returns:
* {
* "imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901",
* "uploaded": true,
* "requireSignedURLs": true
* }
*/
app.post('/api/upload-private', async (c) => {
try {
// Verify environment variables
if (!c.env.CF_ACCOUNT_ID || !c.env.CF_API_TOKEN) {
return c.json({
error: 'Missing configuration',
message: 'CF_ACCOUNT_ID and CF_API_TOKEN must be set'
}, 500);
}
// Get file from form data
const formData = await c.req.formData();
const file = formData.get('file');
if (!file || !(file instanceof File)) {
return c.json({
error: 'Missing file',
message: 'Please provide a file in the form data'
}, 400);
}
console.log('Uploading private image:', file.name);
// Create upload form data
const uploadFormData = new FormData();
uploadFormData.append('file', file);
uploadFormData.append('requireSignedURLs', 'true'); // ← KEY: Makes image private
// Upload to Cloudflare Images
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.CF_API_TOKEN}`
},
body: uploadFormData
}
);
const result = await response.json<any>();
if (!result.success) {
console.error('Cloudflare API error:', result.errors);
return c.json({
error: 'Upload failed',
details: result.errors
}, 500);
}
console.log('Private image uploaded successfully. Image ID:', result.result.id);
return c.json({
imageId: result.result.id,
uploaded: true,
requireSignedURLs: true
});
} catch (error) {
console.error('Error uploading private image:', error);
return c.json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error'
}, 500);
}
});
/**
* Generate signed URL for private image
*
* POST /api/sign-url
*
* Body:
* {
* "imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901",
* "variant": "public", // Optional, defaults to "public"
* "expirySeconds": 3600 // Optional, defaults to 1 hour
* }
*
* Returns:
* {
* "signedUrl": "https://imagedelivery.net/{hash}/{id}/public?exp=1234567890&sig=abc123...",
* "expiresAt": "2024-01-15T12:00:00Z",
* "expirySeconds": 3600
* }
*/
app.post('/api/sign-url', async (c) => {
try {
// Verify environment variables
if (!c.env.CF_ACCOUNT_HASH || !c.env.CF_IMAGES_SIGNING_KEY) {
return c.json({
error: 'Missing configuration',
message: 'CF_ACCOUNT_HASH and CF_IMAGES_SIGNING_KEY must be set'
}, 500);
}
// Parse request body
const body = await c.req.json<{
imageId: string;
variant?: string;
expirySeconds?: number;
}>();
const { imageId, variant = 'public', expirySeconds = 3600 } = body;
if (!imageId) {
return c.json({
error: 'Missing imageId',
message: 'Please provide imageId in request body'
}, 400);
}
console.log('Generating signed URL for:', imageId, 'variant:', variant, 'expiry:', expirySeconds);
// Generate expiry timestamp (Unix epoch)
const expiry = Math.floor(Date.now() / 1000) + expirySeconds;
// Generate signature using HMAC-SHA256
// Format: imageId + "/" + variant + expiry
const dataToSign = `${imageId}/${variant}${expiry}`;
const signature = await sign(
{ data: dataToSign },
c.env.CF_IMAGES_SIGNING_KEY,
{ algorithm: 'HS256' }
);
// Construct signed URL
const signedUrl = `https://imagedelivery.net/${c.env.CF_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${signature}`;
console.log('Signed URL generated successfully');
return c.json({
signedUrl,
expiresAt: new Date(expiry * 1000).toISOString(),
expirySeconds
});
} catch (error) {
console.error('Error generating signed URL:', error);
return c.json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error'
}, 500);
}
});
export default app;
{
"name": "cloudflare-images-private-images",
"main": "src/index.ts",
"compatibility_date": "2025-01-15",
"account_id": "YOUR_ACCOUNT_ID",
// Environment variables (for local development)
"vars": {
"CF_ACCOUNT_HASH": "your_account_hash"
}
// For production, use secrets instead:
// wrangler secret put CF_ACCOUNT_ID
// wrangler secret put CF_API_TOKEN
// wrangler secret put CF_IMAGES_SIGNING_KEY
}
Responsive Gallery Example
Complete responsive image gallery using Cloudflare Images with srcset, lazy loading, and variant optimization.
Features
- ✅ Responsive images with
srcsetandsizes - ✅ Lazy loading for performance
- ✅ Named variants for common sizes
- ✅ Masonry grid layout
- ✅ Lightbox for full-size viewing
- ✅ WebP/AVIF automatic format negotiation
- ✅ CDN caching
Live Demo Structure
responsive-gallery/
├── README.md # This file
├── index.html # Gallery UI
└── images.json # Image metadata (IDs, alt text)Implementation
HTML Structure
<div class="gallery">
<div class="gallery-item" data-image-id="abc123">
<img
src="https://imagedelivery.net/{hash}/abc123/thumbnail"
srcset="
https://imagedelivery.net/{hash}/abc123/thumbnail 300w,
https://imagedelivery.net/{hash}/abc123/medium 600w,
https://imagedelivery.net/{hash}/abc123/large 1200w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
alt="Product photo"
loading="lazy"
decoding="async"
/>
</div>
</div>Variants Configuration
Create these variants using /generate-variant command:
{
"thumbnail": { "width": 300, "height": 300, "fit": "cover", "quality": 80 },
"medium": { "width": 600, "height": 600, "fit": "scale-down", "quality": 85 },
"large": { "width": 1200, "height": 1200, "fit": "scale-down", "quality": 90 }
}Responsive Behavior
- Mobile (<640px): Loads
thumbnailvariant (300px) - Tablet (640-1024px): Loads
mediumvariant (600px) - Desktop (>1024px): Loads
largevariant (1200px) - Retina displays: Automatically serves higher resolution
Performance Optimizations
1. Lazy Loading: Images load as user scrolls 2. Decoding Async: Non-blocking image decode 3. Format Auto: WebP/AVIF served automatically (25-50% smaller) 4. CDN Caching: Cached globally at edge locations 5. Named Variants: Pre-defined sizes for consistency
Lighthouse Scores
Expected scores with optimizations:
- Performance: 95-100
- Largest Contentful Paint (LCP): <2.5s
- Cumulative Layout Shift (CLS): <0.1
- Total Blocking Time (TBT): <200ms
Setup
1. Create Variants
# Use the generate-variant command for each size
/generate-variant
# Or via API:
curl -X POST \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"id": "thumbnail", "options": {"width": 300, "height": 300, "fit": "cover"}}'2. Configure Image Data
Edit images.json with your image IDs:
[
{
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"alt": "Product 1",
"title": "Modern Chair"
},
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"alt": "Product 2",
"title": "Wooden Table"
}
]3. Open Gallery
# Serve locally
npx serve .
# Or open directly
open index.htmlAdvanced Features
Lightbox Implementation
// Click image to view full size
item.addEventListener('click', () => {
const lightbox = document.createElement('div');
lightbox.className = 'lightbox';
lightbox.innerHTML = `
<img src="https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/large?format=auto" />
`;
document.body.appendChild(lightbox);
});Infinite Scroll
// Load more images on scroll
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreImages();
}
});
observer.observe(document.querySelector('.load-more-trigger'));Search and Filter
// Filter gallery by search term
function filterGallery(searchTerm) {
const items = document.querySelectorAll('.gallery-item');
items.forEach(item => {
const alt = item.querySelector('img').alt.toLowerCase();
item.style.display = alt.includes(searchTerm.toLowerCase()) ? 'block' : 'none';
});
}Related Examples
- Basic Upload: Minimal upload implementation
- Private Images: Signed URLs for access control
Related References
- Responsive Images:
references/responsive-images-patterns.md - Variants Guide:
references/variants-guide.md - Format Optimization:
references/format-optimization.md
Cloudflare Images API Reference
Complete API endpoints for Cloudflare Images.
Base URL: https://api.cloudflare.com/client/v4/accounts/{account_id} Batch API: https://batch.imagedelivery.net
---
Authentication
All requests require an API token with Cloudflare Images: Edit permission.
Authorization: Bearer <API_TOKEN>Get API token: Dashboard → My Profile → API Tokens → Create Token
---
Upload Endpoints
Upload Image (File)
POST /accounts/{account_id}/images/v1
Upload an image file.
Headers:
Authorization: Bearer <API_TOKEN>Content-Type: multipart/form-data
Form Fields:
file(required): Image fileid(optional): Custom ID (auto-generated if not provided)requireSignedURLs(optional):truefor private imagesmetadata(optional): JSON object (max 1024 bytes)
Example:
curl --request POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
--header "Authorization: Bearer <API_TOKEN>" \
--form 'file=@./image.jpg' \
--form 'requireSignedURLs=false' \
--form 'metadata={"key":"value"}'Response:
{
"success": true,
"result": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"filename": "image.jpg",
"uploaded": "2022-01-31T16:39:28.458Z",
"requireSignedURLs": false,
"variants": [
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0.../public"
]
}
}---
Upload via URL
POST /accounts/{account_id}/images/v1
Ingest image from external URL.
Form Fields:
url(required): Image URL to ingestid(optional): Custom IDrequireSignedURLs(optional):truefor private imagesmetadata(optional): JSON object
Example:
curl --request POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
--header "Authorization: Bearer <API_TOKEN>" \
--form 'url=https://example.com/image.jpg' \
--form 'metadata={"source":"external"}'Note: Cannot use both file and url in same request.
---
Direct Creator Upload
POST /accounts/{account_id}/images/v2/direct_upload
Generate one-time upload URL for user uploads.
Headers:
Authorization: Bearer <API_TOKEN>Content-Type: application/json
Body:
{
"requireSignedURLs": false,
"metadata": {"userId": "12345"},
"expiry": "2025-10-26T18:00:00Z",
"id": "custom-id"
}Fields:
requireSignedURLs(optional):truefor private imagesmetadata(optional): JSON objectexpiry(optional): ISO 8601 timestamp (default: 30min, max: 6hr)id(optional): Custom ID (cannot use withrequireSignedURLs=true)
Response:
{
"success": true,
"result": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"uploadURL": "https://upload.imagedelivery.net/..."
}
}Frontend Upload:
const formData = new FormData();
formData.append('file', fileInput.files[0]); // MUST be named 'file'
await fetch(uploadURL, {
method: 'POST',
body: formData // NO Content-Type header
});---
Image Management
List Images
GET /accounts/{account_id}/images/v2
List all images (paginated).
Query Params:
page(optional): Page number (default: 1)per_page(optional): Results per page (default: 100, max: 100)
Example:
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2?page=1&per_page=50" \
--header "Authorization: Bearer <API_TOKEN>"Response:
{
"success": true,
"result": {
"images": [
{
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"filename": "image.jpg",
"uploaded": "2022-01-31T16:39:28.458Z",
"requireSignedURLs": false,
"variants": ["https://imagedelivery.net/.../public"]
}
]
}
}---
Get Image Details
GET /accounts/{account_id}/images/v1/{image_id}
Get details of specific image.
Example:
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \
--header "Authorization: Bearer <API_TOKEN>"Response:
{
"success": true,
"result": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"filename": "image.jpg",
"uploaded": "2022-01-31T16:39:28.458Z",
"requireSignedURLs": false,
"draft": false,
"variants": ["https://imagedelivery.net/.../public"]
}
}Note: draft: true means Direct Creator Upload not completed yet.
---
Delete Image
DELETE /accounts/{account_id}/images/v1/{image_id}
Delete an image.
Example:
curl --request DELETE \
"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \
--header "Authorization: Bearer <API_TOKEN>"Response:
{
"success": true
}---
Variants Management
Create Variant
POST /accounts/{account_id}/images/v1/variants
Create a new variant.
Body:
{
"id": "thumbnail",
"options": {
"fit": "cover",
"width": 300,
"height": 300,
"metadata": "none"
},
"neverRequireSignedURLs": false
}Options:
fit:scale-down,contain,cover,crop,padwidth: Max width in pixelsheight: Max height in pixelsmetadata:none,copyright,keep
Example:
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{"id":"thumbnail","options":{"fit":"cover","width":300,"height":300}}'---
List Variants
GET /accounts/{account_id}/images/v1/variants
List all variants.
Example:
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
--header "Authorization: Bearer <API_TOKEN>"---
Get Variant
GET /accounts/{account_id}/images/v1/variants/{variant_id}
Get specific variant details.
---
Update Variant
PATCH /accounts/{account_id}/images/v1/variants/{variant_id}
Update existing variant.
Body:
{
"options": {
"width": 350,
"height": 350
}
}---
Delete Variant
DELETE /accounts/{account_id}/images/v1/variants/{variant_id}
Delete a variant.
---
Enable Flexible Variants
PATCH /accounts/{account_id}/images/v1/config
Enable or disable flexible variants (dynamic transformations).
Body:
{
"flexible_variants": true
}---
Batch API
Same endpoints as regular API, but different host and authentication.
Host: https://batch.imagedelivery.net Auth: Batch token (create in Dashboard → Images → Batch API)
Endpoints:
POST /images/v1- Upload imageGET /images/v2- List imagesDELETE /images/v1/{image_id}- Delete image
Example:
curl "https://batch.imagedelivery.net/images/v1" \
--header "Authorization: Bearer <BATCH_TOKEN>" \
--form 'file=@./image.jpg'---
Error Codes
HTTP Status Codes
200 OK- Request successful400 Bad Request- Invalid request (check error message)401 Unauthorized- Invalid or missing API token403 Forbidden- Insufficient permissions404 Not Found- Resource not found413 Payload Too Large- File too large429 Too Many Requests- Rate limit exceeded500 Internal Server Error- Cloudflare error502 Bad Gateway- Transformation error
Cloudflare Errors
Check errors array in response:
{
"success": false,
"errors": [
{
"code": 5400,
"message": "Error description"
}
]
}Common error codes:
5400- Invalid request5408- Upload timeout5454- Unsupported protocol
---
Rate Limits
- Standard uploads: No published rate limits
- Direct Creator Upload: Limited by one-time URL expiry (default 30min, max 6hr)
- Batch API: Contact Cloudflare for high-volume needs
---
Official Documentation
- Images API: https://developers.cloudflare.com/api/resources/images/
- Upload Images: https://developers.cloudflare.com/images/upload-images/
- Direct Creator Upload: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
- Variants: https://developers.cloudflare.com/images/manage-images/create-variants/
Content Credentials for Cloudflare Images
Guide to image authenticity, provenance tracking, and metadata preservation with Cloudflare Images.
---
What Are Content Credentials?
Content Credentials are metadata standards that verify:
- Image provenance (where image came from)
- Edit history (modifications made to image)
- Creator attribution (who created/modified image)
- Authenticity (whether image is original or AI-generated)
Key Standards:
- C2PA (Coalition for Content Provenance and Authenticity)
- IPTC Photo Metadata
- EXIF (Exchangeable Image File Format)
---
Metadata Preservation in Cloudflare Images
Default Behavior
By default, Cloudflare Images:
- ✅ Preserves basic EXIF orientation
- ❌ Strips most EXIF metadata (GPS, camera info, copyright)
- ❌ Removes IPTC metadata
- ❌ Removes XMP metadata
Reason: Privacy and file size optimization
Preserving Metadata
During transformation, use metadata=keep:
<!-- Preserve all metadata -->
<img src="/cdn-cgi/image/metadata=keep,width=800/uploads/photo.jpg" />Via Workers:
return fetch(imageUrl, {
cf: {
image: {
width: 800,
metadata: 'keep' // Preserve metadata
}
}
});Trade-off:
- ✅ Preserves creator info, copyright, GPS, camera data
- ⚠️ Larger file size (+5-15%)
- ⚠️ May expose sensitive data (GPS location)
---
EXIF Metadata
Common EXIF Fields
interface EXIFMetadata {
// Camera Information
Make: string; // "Canon"
Model: string; // "EOS R5"
LensModel: string; // "RF 24-70mm F2.8 L IS USM"
// Capture Settings
FNumber: number; // f/2.8
ExposureTime: string; // "1/250"
ISO: number; // 400
FocalLength: string; // "50mm"
// Date/Time
DateTimeOriginal: string; // "2025:01:15 14:23:45"
DateTime: string; // "2025:01:15 14:23:45"
// Location (GPS)
GPSLatitude: string; // "37.7749° N"
GPSLongitude: string; // "122.4194° W"
GPSAltitude: string; // "10m"
// Copyright
Copyright: string; // "© 2025 John Doe"
Artist: string; // "John Doe"
// Image Properties
Orientation: number; // 1 (normal), 3 (180°), 6 (90° CW), 8 (90° CCW)
XResolution: number; // 72 DPI
YResolution: number; // 72 DPI
}Reading EXIF Data
Using exif-js (Browser):
import EXIF from 'exif-js';
async function readEXIF(imageFile: File): Promise<any> {
return new Promise((resolve) => {
EXIF.getData(imageFile as any, function(this: any) {
const exifData = EXIF.getAllTags(this);
resolve(exifData);
});
});
}
// Usage
const file = fileInput.files[0];
const exif = await readEXIF(file);
console.log('Camera:', exif.Make, exif.Model);
console.log('Copyright:', exif.Copyright);Using exifreader (Node.js):
import ExifReader from 'exifreader';
import { readFile } from 'fs/promises';
const buffer = await readFile('photo.jpg');
const tags = ExifReader.load(buffer);
console.log('Copyright:', tags.Copyright?.description);
console.log('GPS:', tags.GPSLatitude?.description, tags.GPSLongitude?.description);---
IPTC Metadata
IPTC Photo Metadata Standard
interface IPTCMetadata {
// Creator Information
Creator: string[]; // Photographer name(s)
CreatorJobTitle: string; // "Photographer"
CreatorAddress: string;
CreatorCity: string;
CreatorCountry: string;
// Copyright
CopyrightNotice: string; // "© 2025 John Doe"
RightsUsageTerms: string; // "All rights reserved"
WebStatement: string; // URL to copyright info
// Image Description
Caption: string; // Image description
Headline: string; // Brief title
Keywords: string[]; // ["landscape", "sunset", "beach"]
// Usage Rights
CreditLine: string; // "Photo by John Doe"
Source: string; // "Example Photography"
// Administrative
DateCreated: string; // "2025-01-15"
IntellectualGenre: string; // "Documentary Photography"
}---
C2PA Content Credentials
What is C2PA?
The Coalition for Content Provenance and Authenticity provides standards for:
- Verifying image authenticity
- Tracking edits and modifications
- Attributing creators
- Detecting AI-generated content
Supported by:
- Adobe, Microsoft, Google, BBC, Sony, Nikon, Canon
How C2PA Works
1. Content Binding:
- Digital signature embedded in image
- Links to external manifest (JSON)
2. Manifest Contains:
- Creator information
- Edit history
- Assertions (original vs AI-generated)
- Ingredients (source images)
3. Verification:
- Check signature validity
- Verify no tampering occurred
- Display provenance to users
Implementing C2PA
Note: Cloudflare Images doesn't natively support C2PA manifest creation. Implement before upload:
// Pseudo-code (requires C2PA library)
import { createC2PAManifest } from 'c2pa';
async function addContentCredentials(
imageBuffer: ArrayBuffer,
metadata: {
creator: string;
title: string;
createdDate: string;
assertions: string[];
}
): Promise<ArrayBuffer> {
const manifest = createC2PAManifest({
claim: {
creator: metadata.creator,
title: metadata.title,
dateCreated: metadata.createdDate,
assertions: metadata.assertions
}
});
const signedImage = await manifest.embed(imageBuffer);
return signedImage;
}
// Upload to Cloudflare Images
const credentialedImage = await addContentCredentials(imageBuffer, {
creator: 'John Doe',
title: 'Sunset at Beach',
createdDate: '2025-01-15',
assertions: ['human-created', 'no-ai-generation']
});
// Upload signedImage to Cloudflare...---
Preserving Copyright Information
Add Copyright to Image
import ExifWriter from 'exif-js';
async function addCopyright(
imageBuffer: ArrayBuffer,
copyrightText: string
): Promise<ArrayBuffer> {
const exif = {
Copyright: copyrightText,
Artist: 'Your Name',
ImageDescription: 'Image description'
};
// Write EXIF data
const modifiedBuffer = await ExifWriter.insert(exif, imageBuffer);
return modifiedBuffer;
}
// Usage
const copyrighted = await addCopyright(imageBuffer, '© 2025 Your Company. All Rights Reserved.');
// Upload to Cloudflare Images with metadata=keepStore in Database (Alternative):
// Store copyright info separately
await db.images.create({
data: {
cloudflareId: imageId,
copyright: '© 2025 Your Company',
creator: 'John Doe',
license: 'All Rights Reserved',
createdAt: new Date()
}
});
// Display copyright from database (not embedded in image)---
AI-Generated Content Attribution
Marking AI-Generated Images
Metadata Approach:
await db.images.create({
data: {
cloudflareId: imageId,
isAIGenerated: true,
aiModel: 'DALL-E 3',
prompt: 'A sunset over mountains',
generatedAt: new Date()
}
});Visible Watermark:
<div class="relative">
<img src="https://images.yourdomain.com/ai-generated-id/public" alt="AI Generated" />
<div class="absolute top-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
🤖 AI Generated
</div>
</div>EXIF Custom Field:
const exif = {
ImageDescription: 'AI Generated by DALL-E 3',
Copyright: '© 2025 Your Company (AI Generated)',
UserComment: 'Created with artificial intelligence'
};---
Privacy Considerations
GPS Data Removal
Why remove GPS data:
- Privacy protection (home address, location tracking)
- Security concerns (sensitive locations)
Cloudflare Images removes GPS by default ✅
Manual removal (if needed before upload):
import piexif from 'piexifjs';
function removeGPS(imageDataURL: string): string {
const exif = piexif.load(imageDataURL);
// Remove GPS data
delete exif['GPS'];
const exifBytes = piexif.dump(exif);
const newDataURL = piexif.insert(exifBytes, imageDataURL);
return newDataURL;
}Sensitive Metadata
Metadata that may expose privacy:
- GPS coordinates (exact location)
- Camera serial number (device tracking)
- Timestamps (when photo taken)
- Wi-Fi network names (in some camera models)
Best practice: Strip metadata for user-uploaded images unless specifically needed.
---
Displaying Provenance to Users
Photo Credit Display
interface ImageWithCreditProps {
imageId: string;
creator: string;
copyright: string;
license: string;
}
export function ImageWithCredit({
imageId,
creator,
copyright,
license
}: ImageWithCreditProps) {
return (
<figure>
<img
src={`https://images.yourdomain.com/${imageId}/public`}
alt={`Photo by ${creator}`}
/>
<figcaption className="text-sm text-gray-600 mt-2">
<div>Photo by {creator}</div>
<div>{copyright}</div>
<div>License: {license}</div>
</figcaption>
</figure>
);
}---
Legal Compliance
DMCA Compliance
If hosting user-uploaded images:
1. Copyright Notice:
© [Year] [Owner]. All rights reserved.
Unauthorized use prohibited.2. DMCA Agent:
- Designate agent for copyright complaints
- Provide contact information
- Register with US Copyright Office
3. Takedown Process:
async function processDMCATakedown(imageId: string) {
// Remove from Cloudflare Images
await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${imageId}`,
{
method: 'DELETE',
headers: { 'Authorization': `Bearer ${apiToken}` }
}
);
// Mark in database
await db.images.update({
where: { cloudflareId: imageId },
data: { status: 'dmca_removed', removedAt: new Date() }
});
}---
Best Practices
1. Strip Metadata for Privacy
// For user uploads, remove GPS and sensitive data
await uploadImage(file, {
stripMetadata: true // Default behavior in Cloudflare Images
});2. Preserve Copyright for Attribution
// For professional photography, keep copyright
await uploadImage(file, {
preserveMetadata: true,
metadata: {
copyright: '© 2025 Photographer Name',
creator: 'Photographer Name'
}
});3. Store Provenance Separately
// Store in database for flexibility
await db.images.create({
data: {
cloudflareId,
source: 'user_upload',
originalFilename: file.name,
uploadedBy: userId,
copyright,
license,
provenance: {
creator,
dateCreated,
editHistory: []
}
}
});---
Tools and Libraries
EXIF Reading/Writing:
- exif-js: https://github.com/exif-js/exif-js (Browser)
- exifreader: https://github.com/mattiasw/ExifReader (Node.js)
- piexif: https://github.com/hMatoba/piexifjs (Browser)
C2PA Libraries:
- Adobe C2PA: https://github.com/contentauth/c2pa-js (JavaScript)
- C2PA Rust: https://github.com/contentauth/c2pa-rs (Rust/WASM)
Image Metadata Tools:
- ExifTool: https://exiftool.org/ (CLI)
- ImageMagick: https://imagemagick.org/ (CLI)
---
Related References
- Upload API: See
references/api-reference.md - Transformations: See
references/transformation-options.md - Overlays/Watermarks: See
references/overlays-watermarks.md
---
Official Documentation
- Cloudflare Images: https://developers.cloudflare.com/images/
- C2PA: https://c2pa.org/
- IPTC: https://www.iptc.org/standards/photo-metadata/
Responsive Images Patterns
Complete guide to serving optimal images for different devices and screen sizes.
---
srcset with Named Variants
Best for consistent, predefined sizes.
<img
srcset="
https://imagedelivery.net/HASH/ID/mobile 480w,
https://imagedelivery.net/HASH/ID/tablet 768w,
https://imagedelivery.net/HASH/ID/desktop 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/HASH/ID/desktop"
alt="Responsive image"
loading="lazy"
/>Variants to create:
mobile: width=480, fit=scale-downtablet: width=768, fit=scale-downdesktop: width=1920, fit=scale-down
---
srcset with Flexible Variants
Best for dynamic sizing (public images only).
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
https://imagedelivery.net/HASH/ID/w=1920,f=auto 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
alt="Responsive image"
loading="lazy"
/>---
Art Direction (Different Crops)
Serve different image crops for mobile vs desktop.
<picture>
<!-- Mobile: Square crop -->
<source
media="(max-width: 767px)"
srcset="https://imagedelivery.net/HASH/ID/mobile-square"
/>
<!-- Desktop: Wide crop -->
<source
media="(min-width: 768px)"
srcset="https://imagedelivery.net/HASH/ID/desktop-wide"
/>
<!-- Fallback -->
<img
src="https://imagedelivery.net/HASH/ID/desktop-wide"
alt="Art directed image"
loading="lazy"
/>
</picture>Variants to create:
mobile-square: width=480, height=480, fit=coverdesktop-wide: width=1920, height=1080, fit=cover
---
High-DPI (Retina) Displays
Serve 2x images for high-resolution screens.
<img
srcset="
https://imagedelivery.net/HASH/ID/w=400,dpr=1,f=auto 1x,
https://imagedelivery.net/HASH/ID/w=400,dpr=2,f=auto 2x
"
src="https://imagedelivery.net/HASH/ID/w=400,f=auto"
alt="Retina-ready image"
/>---
Blur Placeholder (LQIP)
Load tiny blurred placeholder first, then swap to full image.
<img
id="lqip-image"
src="https://imagedelivery.net/HASH/ID/w=50,q=10,blur=20,f=webp"
data-src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
alt="Image with LQIP"
style="filter: blur(10px); transition: filter 0.3s;"
/>
<script>
const img = document.getElementById('lqip-image');
const fullSrc = img.getAttribute('data-src');
const fullImg = new Image();
fullImg.src = fullSrc;
fullImg.onload = () => {
img.src = fullSrc;
img.style.filter = 'blur(0)';
};
</script>---
Lazy Loading
Defer loading below-the-fold images.
<!-- Native lazy loading (modern browsers) -->
<img src="..." loading="lazy" alt="..." />
<!-- With Intersection Observer (better control) -->
<img
class="lazy"
data-src="https://imagedelivery.net/HASH/ID/w=800,f=auto"
alt="Lazy loaded image"
/>
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});
document.querySelectorAll('img.lazy').forEach(img => observer.observe(img));
</script>---
URL Transformations (/cdn-cgi/image/)
Transform ANY publicly accessible image (not just Cloudflare Images storage).
<img
srcset="
/cdn-cgi/image/width=480,format=auto/uploads/photo.jpg 480w,
/cdn-cgi/image/width=768,format=auto/uploads/photo.jpg 768w,
/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg"
alt="Transformed origin image"
loading="lazy"
/>---
Recommended Breakpoints
const breakpoints = {
mobile: 480, // Small phones
tablet: 768, // Tablets
desktop: 1024, // Laptops
wide: 1920, // Desktops
ultrawide: 2560 // Large displays
};sizes attribute:
sizes="
(max-width: 480px) 480px,
(max-width: 768px) 768px,
(max-width: 1024px) 1024px,
(max-width: 1920px) 1920px,
2560px
"---
Complete Example
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
img { max-width: 100%; height: auto; display: block; }
</style>
</head>
<body>
<!-- Hero image with art direction -->
<picture>
<source
media="(max-width: 767px)"
srcset="https://imagedelivery.net/HASH/ID/w=480,h=480,fit=cover,f=auto"
/>
<source
media="(min-width: 768px)"
srcset="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
/>
<img
src="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
alt="Hero image"
/>
</picture>
<!-- Responsive gallery images -->
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
https://imagedelivery.net/HASH/ID/w=1024,f=auto 1024w
"
sizes="
(max-width: 480px) 100vw,
(max-width: 768px) 50vw,
33vw
"
src="https://imagedelivery.net/HASH/ID/w=1024,f=auto"
alt="Gallery image"
loading="lazy"
/>
</body>
</html>---
Best Practices
1. Always use format=auto: Optimal WebP/AVIF delivery 2. Add loading="lazy": Below-the-fold images 3. Match sizes to CSS layout: Use sizes attribute correctly 4. Provide descriptive alt text: Accessibility 5. Use LQIP for perceived performance: Better UX 6. Named variants for private: Signed URLs compatible 7. Flexible variants for public: Dynamic sizing 8. Limit srcset to 3-5 sizes: Balance performance vs flexibility
---
Official Documentation
- Responsive Images (MDN): https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images
- Cloudflare Images: https://developers.cloudflare.com/images/