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"
/>
Link Preview Cards
<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:
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.