What Are Signed URLs?

A signed URL is a regular URL with a cryptographic signature appended as a query parameter. It proves the request was authorized without exposing your API key. The signature is generated server-side using your secret key, but the URL can be used client-side in <img> tags, emails, or anywhere that loads images.

<!-- This just works — no backend proxy needed -->
<img src="https://api.toolcenter.dev/v1/screenshot?url=https://example.com&width=1280&height=800&sig=a1b2c3d4e5f6" />

Why Use Signed URLs?

The Problem with API Keys in Frontend

You can’t put API keys in client-side code:

<!-- ❌ NEVER do this — anyone can steal your key -->
<img src="https://api.toolcenter.dev/v1/screenshot?url=https://example.com&apiKey=sk_live_123" />

The Traditional Workaround

Build a backend proxy that forwards requests:

// Server-side proxy
app.get('/api/screenshot', async (req, res) => {
  const response = await axios.get('https://api.toolcenter.dev/v1/screenshot', {
    params: { url: req.query.url, width: 1280, height: 800 },
    headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
    responseType: 'arraybuffer',
  });
  res.set('Content-Type', 'image/png');
  res.send(response.data);
});

This works but adds latency, server load, and complexity.

The Signed URL Solution

Generate the signed URL server-side, use it anywhere client-side:

// Server: generate signed URL (fast, no screenshot taken yet)
const signedUrl = generateSignedUrl('https://example.com', { width: 1280, height: 800 });

// Client: use it directly in HTML
// <img src="${signedUrl}" />

The screenshot is taken only when the browser loads the image. Your server does zero proxying.

Generating Signed URLs

Node.js

const crypto = require('crypto');

function generateSignedUrl(targetUrl, options = {}) {
  const params = new URLSearchParams({
    url: targetUrl,
    width: options.width || 1280,
    height: options.height || 800,
    format: options.format || 'png',
    // Optional: expiration timestamp
    expires: options.expires || Math.floor(Date.now() / 1000) + 3600, // 1 hour
  });

  // Create signature from all parameters
  const signature = crypto
    .createHmac('sha256', process.env.DEVTOOLBOX_SIGNING_SECRET)
    .update(params.toString())
    .digest('hex');

  params.set('sig', signature);

  return `https://api.toolcenter.dev/v1/screenshot?${params.toString()}`;
}

// Generate a signed URL
const signedUrl = generateSignedUrl('https://example.com', {
  width: 1280,
  height: 800,
  expires: Math.floor(Date.now() / 1000) + 86400, // 24 hours
});

console.log(signedUrl);

Python

import hmac
import hashlib
import time
from urllib.parse import urlencode
import os

def generate_signed_url(target_url, width=1280, height=800, ttl=3600):
    params = {
        'url': target_url,
        'width': width,
        'height': height,
        'format': 'png',
        'expires': int(time.time()) + ttl,
    }

    # Create signature
    query_string = urlencode(sorted(params.items()))
    signature = hmac.new(
        os.environ['DEVTOOLBOX_SIGNING_SECRET'].encode(),
        query_string.encode(),
        hashlib.sha256
    ).hexdigest()

    params['sig'] = signature
    return f"https://api.toolcenter.dev/v1/screenshot?{urlencode(params)}"

# Generate URL valid for 24 hours
url = generate_signed_url('https://example.com', ttl=86400)
print(url)

Using Signed URLs in HTML

Simple Image Embed

<img
  src="https://api.toolcenter.dev/v1/screenshot?url=https%3A%2F%2Fexample.com&width=1280&height=800&expires=1740000000&sig=abc123"
  alt="Screenshot of example.com"
  loading="lazy"
  width="640"
  height="400"
/>
<div class="link-preview" style="border:1px solid #ddd;border-radius:8px;overflow:hidden;max-width:400px;">
  <img
    src="{{ signedScreenshotUrl }}"
    style="width:100%;height:200px;object-fit:cover;"
    loading="lazy"
  />
  <div style="padding:12px;">
    <h3 style="margin:0 0 4px;">{{ pageTitle }}</h3>
    <p style="margin:0;color:#666;font-size:14px;">{{ pageDescription }}</p>
  </div>
</div>

Email Embeds

Signed URLs work in email since they’re just standard image URLs:

<!-- Email template -->
<table>
  <tr>
    <td>
      <img
        src="{{ signedUrl }}"
        alt="Website preview"
        width="600"
        style="max-width:100%;border-radius:4px;"
      />
    </td>
  </tr>
</table>

Security Best Practices

1. Always Set Expiration

Never create signed URLs without an expiration:

const expires = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now

Use short TTLs for sensitive content and longer TTLs for public content.

2. Restrict URL Patterns

Limit which URLs can be screenshotted to prevent abuse:

function generateSignedUrl(targetUrl, options = {}) {
  // Only allow screenshots of approved domains
  const allowedDomains = ['example.com', 'docs.example.com', 'blog.example.com'];
  const urlObj = new URL(targetUrl);

  if (!allowedDomains.includes(urlObj.hostname)) {
    throw new Error(`Domain not allowed: ${urlObj.hostname}`);
  }

  // ... generate signature
}

3. Rate Limit by Signature

Track how many times each signed URL is used:

const usageTracker = new Map();
const MAX_USES = 10;

function checkUsage(signature) {
  const count = usageTracker.get(signature) || 0;
  if (count >= MAX_USES) {
    throw new Error('Signed URL usage limit exceeded');
  }
  usageTracker.set(signature, count + 1);
}

4. Rotate Signing Secrets

Periodically rotate your signing secret. During rotation, accept both old and new secrets:

function verifySignature(params, signature) {
  const secrets = [
    process.env.DEVTOOLBOX_SIGNING_SECRET,
    process.env.DEVTOOLBOX_SIGNING_SECRET_PREVIOUS,
  ].filter(Boolean);

  return secrets.some(secret => {
    const expected = crypto.createHmac('sha256', secret).update(params).digest('hex');
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  });
}

Server-Side Integration

Express.js Endpoint

Create an endpoint that generates signed URLs for your frontend:

app.get('/api/preview-url', (req, res) => {
  const { url } = req.query;

  if (!url) {
    return res.status(400).json({ error: 'URL required' });
  }

  const signedUrl = generateSignedUrl(url, {
    width: 1200,
    height: 630,
    expires: Math.floor(Date.now() / 1000) + 86400,
  });

  res.json({ previewUrl: signedUrl });
});

Next.js API Route

// pages/api/preview.js
export default function handler(req, res) {
  const { url } = req.query;
  const signedUrl = generateSignedUrl(url, { width: 1200, height: 630 });
  res.json({ previewUrl: signedUrl });
}

Caching Considerations

Signed URLs with the same parameters generate the same signature, making them cache-friendly:

<img
  src="{{ signedUrl }}"
  loading="lazy"
  decoding="async"
  style="max-width:100%;"
/>

Add cache headers on your CDN to avoid regenerating the same screenshot:

Cache-Control:public,max-age=86400

For dynamic content that changes frequently, use shorter expiration times and cache-busting parameters:

const signedUrl = generateSignedUrl(url, {
  expires: Math.floor(Date.now() / 1000) + 300, // 5 minutes
  cacheBust: Date.now(), // Unique per request
});

Conclusion

Signed URLs are the cleanest way to embed live screenshots in HTML. They keep your API key secure, eliminate the need for backend proxying, and work everywhere that images work — web pages, emails, markdown, and more. Generate them server-side with a short expiration, and use them freely on the client.