351 lines
7.5 KiB
Markdown
351 lines
7.5 KiB
Markdown
# Signed URLs Guide
|
|
|
|
Complete guide to generating signed URLs for private images using HMAC-SHA256.
|
|
|
|
---
|
|
|
|
## What Are Signed URLs?
|
|
|
|
Time-limited URLs for serving private images securely.
|
|
|
|
**Format**:
|
|
```
|
|
https://imagedelivery.net/<HASH>/<ID>/<VARIANT>?exp=<EXPIRY>&sig=<SIGNATURE>
|
|
```
|
|
|
|
**Use cases**:
|
|
- User profile photos (private until shared)
|
|
- Paid content (time-limited access)
|
|
- Temporary downloads
|
|
- Secure image delivery
|
|
|
|
---
|
|
|
|
## Requirements
|
|
|
|
1. **Upload with signed URLs enabled**:
|
|
```javascript
|
|
await uploadImage(file, {
|
|
requireSignedURLs: true // Image requires signed URL
|
|
});
|
|
```
|
|
|
|
2. **Get signing key**:
|
|
Dashboard → Images → Keys → Generate key
|
|
|
|
3. **Use named variants only**:
|
|
Flexible variants NOT compatible with signed URLs.
|
|
|
|
---
|
|
|
|
## Signature Algorithm (HMAC-SHA256)
|
|
|
|
### String to Sign
|
|
|
|
```
|
|
{imageId}{variant}{expiry}
|
|
```
|
|
|
|
**Example**:
|
|
```
|
|
Image ID: abc123
|
|
Variant: public
|
|
Expiry: 1735228800
|
|
|
|
String to sign: abc123public1735228800
|
|
```
|
|
|
|
### Generate Signature
|
|
|
|
**Workers** (recommended):
|
|
```typescript
|
|
async function generateSignature(
|
|
imageId: string,
|
|
variant: string,
|
|
expiry: number,
|
|
signingKey: string
|
|
): Promise<string> {
|
|
const stringToSign = `${imageId}${variant}${expiry}`;
|
|
|
|
const encoder = new TextEncoder();
|
|
const keyData = encoder.encode(signingKey);
|
|
const messageData = encoder.encode(stringToSign);
|
|
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
keyData,
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
|
|
|
// Convert to hex string
|
|
return Array.from(new Uint8Array(signature))
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
```
|
|
|
|
**Node.js**:
|
|
```javascript
|
|
const crypto = require('crypto');
|
|
|
|
function generateSignature(imageId, variant, expiry, signingKey) {
|
|
const stringToSign = `${imageId}${variant}${expiry}`;
|
|
|
|
return crypto
|
|
.createHmac('sha256', signingKey)
|
|
.update(stringToSign)
|
|
.digest('hex');
|
|
}
|
|
```
|
|
|
|
### Build Signed URL
|
|
|
|
```typescript
|
|
async function generateSignedURL(
|
|
imageId: string,
|
|
variant: string,
|
|
expirySeconds: number,
|
|
accountHash: string,
|
|
signingKey: string
|
|
): Promise<string> {
|
|
const expiry = Math.floor(Date.now() / 1000) + expirySeconds;
|
|
const sig = await generateSignature(imageId, variant, expiry, signingKey);
|
|
|
|
return `https://imagedelivery.net/${accountHash}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Expiry Timestamp
|
|
|
|
**Unix timestamp** (seconds since epoch):
|
|
```typescript
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const oneHour = 60 * 60;
|
|
const expiry = now + oneHour; // 1 hour from now
|
|
```
|
|
|
|
**From specific date**:
|
|
```typescript
|
|
const expiryDate = new Date('2025-10-27T18:00:00Z');
|
|
const expiry = Math.floor(expiryDate.getTime() / 1000);
|
|
```
|
|
|
|
**Common presets**:
|
|
```typescript
|
|
const expiryPresets = {
|
|
fiveMinutes: 5 * 60,
|
|
fifteenMinutes: 15 * 60,
|
|
oneHour: 60 * 60,
|
|
oneDay: 24 * 60 * 60,
|
|
oneWeek: 7 * 24 * 60 * 60
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Complete Example (Workers)
|
|
|
|
```typescript
|
|
interface Env {
|
|
IMAGES_ACCOUNT_HASH: string;
|
|
IMAGES_SIGNING_KEY: string;
|
|
}
|
|
|
|
async function generateSignedURL(
|
|
imageId: string,
|
|
variant: string,
|
|
expirySeconds: number,
|
|
env: Env
|
|
): Promise<string> {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const expiry = now + expirySeconds;
|
|
const stringToSign = `${imageId}${variant}${expiry}`;
|
|
|
|
const encoder = new TextEncoder();
|
|
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
|
|
const messageData = encoder.encode(stringToSign);
|
|
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
keyData,
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
|
const sig = Array.from(new Uint8Array(signature))
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
|
|
return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
|
|
}
|
|
|
|
export default {
|
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
// Generate signed URL valid for 1 hour
|
|
const signedURL = await generateSignedURL(
|
|
'image-id',
|
|
'public',
|
|
3600,
|
|
env
|
|
);
|
|
|
|
return Response.json({ signedURL });
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Multiple Variants
|
|
|
|
Generate signed URLs for multiple variants at once:
|
|
|
|
```typescript
|
|
async function generateSignedURLsForVariants(
|
|
imageId: string,
|
|
variants: string[],
|
|
expirySeconds: number,
|
|
env: Env
|
|
): Promise<Record<string, string>> {
|
|
const urls: Record<string, string> = {};
|
|
|
|
for (const variant of variants) {
|
|
urls[variant] = await generateSignedURL(imageId, variant, expirySeconds, env);
|
|
}
|
|
|
|
return urls;
|
|
}
|
|
|
|
// Usage
|
|
const urls = await generateSignedURLsForVariants(
|
|
'image-id',
|
|
['thumbnail', 'medium', 'large'],
|
|
3600,
|
|
env
|
|
);
|
|
|
|
// {
|
|
// thumbnail: 'https://imagedelivery.net/.../thumbnail?exp=...&sig=...',
|
|
// medium: 'https://imagedelivery.net/.../medium?exp=...&sig=...',
|
|
// large: 'https://imagedelivery.net/.../large?exp=...&sig=...'
|
|
// }
|
|
```
|
|
|
|
---
|
|
|
|
## Verification (Cloudflare handles this)
|
|
|
|
For reference, here's how verification works:
|
|
|
|
```typescript
|
|
async function verifySignature(
|
|
imageId: string,
|
|
variant: string,
|
|
expiry: number,
|
|
providedSig: string,
|
|
signingKey: string
|
|
): Promise<boolean> {
|
|
// Check if expired
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (expiry < now) {
|
|
return false; // Expired
|
|
}
|
|
|
|
// Generate expected signature
|
|
const expectedSig = await generateSignature(imageId, variant, expiry, signingKey);
|
|
|
|
return expectedSig === providedSig;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Common Issues
|
|
|
|
### 1. Signed URL returns 403
|
|
|
|
**Causes**:
|
|
- Image not uploaded with `requireSignedURLs=true`
|
|
- Signature incorrect (wrong signing key)
|
|
- URL expired
|
|
- Using flexible variants (not supported)
|
|
|
|
**Solutions**:
|
|
- Verify image requires signed URLs
|
|
- Check signing key matches dashboard
|
|
- Ensure expiry in future
|
|
- Use named variants only
|
|
|
|
### 2. Signature doesn't match
|
|
|
|
**Causes**:
|
|
- Wrong signing key
|
|
- Incorrect string-to-sign format
|
|
- Timestamp precision (must be seconds, not milliseconds)
|
|
|
|
**Solutions**:
|
|
```typescript
|
|
// ✅ CORRECT - Seconds
|
|
const expiry = Math.floor(Date.now() / 1000);
|
|
|
|
// ❌ WRONG - Milliseconds
|
|
const expiry = Date.now();
|
|
```
|
|
|
|
### 3. Cannot use with flexible variants
|
|
|
|
**Error**: 403 Forbidden when using flexible variants with signed URLs
|
|
|
|
**Solution**: Use named variants for private images
|
|
```typescript
|
|
// ✅ CORRECT
|
|
const url = await generateSignedURL('id', 'thumbnail', 3600, env);
|
|
|
|
// ❌ WRONG
|
|
const url = `https://imagedelivery.net/${hash}/${id}/w=300?exp=${exp}&sig=${sig}`;
|
|
```
|
|
|
|
---
|
|
|
|
## Security Best Practices
|
|
|
|
1. **Keep signing key secret**: Never expose in client-side code
|
|
2. **Generate on backend**: Frontend requests signed URL from backend
|
|
3. **Short expiry for sensitive content**: 5-15 minutes for temporary access
|
|
4. **Longer expiry for user content**: 1-24 hours for profile photos
|
|
5. **Rotate keys periodically**: Dashboard → Images → Keys → Regenerate
|
|
6. **Log suspicious activity**: Monitor for signature mismatches
|
|
|
|
---
|
|
|
|
## Example Use Cases
|
|
|
|
### Profile Photos (24-hour expiry)
|
|
```typescript
|
|
const profileURL = await generateSignedURL('user-123', 'avatar', 24 * 60 * 60, env);
|
|
```
|
|
|
|
### Temporary Download (5 minutes)
|
|
```typescript
|
|
const downloadURL = await generateSignedURL('doc-456', 'large', 5 * 60, env);
|
|
```
|
|
|
|
### Paid Content (1-week subscription)
|
|
```typescript
|
|
const contentURL = await generateSignedURL('premium-789', 'medium', 7 * 24 * 60 * 60, env);
|
|
```
|
|
|
|
---
|
|
|
|
## Official Documentation
|
|
|
|
- **Serve Private Images**: https://developers.cloudflare.com/images/manage-images/serve-images/serve-private-images/
|