Files
gh-jezweb-claude-skills-ski…/references/signed-urls-guide.md
2025-11-30 08:24:18 +08:00

7.5 KiB

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:
await uploadImage(file, {
  requireSignedURLs: true // Image requires signed URL
});
  1. Get signing key: Dashboard → Images → Keys → Generate key

  2. 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):

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:

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

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):

const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
const expiry = now + oneHour; // 1 hour from now

From specific date:

const expiryDate = new Date('2025-10-27T18:00:00Z');
const expiry = Math.floor(expiryDate.getTime() / 1000);

Common presets:

const expiryPresets = {
  fiveMinutes: 5 * 60,
  fifteenMinutes: 15 * 60,
  oneHour: 60 * 60,
  oneDay: 24 * 60 * 60,
  oneWeek: 7 * 24 * 60 * 60
};

Complete Example (Workers)

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:

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:

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:

// ✅ 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

// ✅ 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)

const profileURL = await generateSignedURL('user-123', 'avatar', 24 * 60 * 60, env);

Temporary Download (5 minutes)

const downloadURL = await generateSignedURL('doc-456', 'large', 5 * 60, env);

Paid Content (1-week subscription)

const contentURL = await generateSignedURL('premium-789', 'medium', 7 * 24 * 60 * 60, env);

Official Documentation