Use Signed URLs to Embed Screenshots Directly in HTML
Learn how to use signed URLs to embed live website screenshots directly in HTML img tags without exposing your API key or building a backend.
By Christian Mesa·Updated Feb 27, 2026
## 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 `` tags, emails, or anywhere that loads images.
```html
```
## Why Use Signed URLs?
### The Problem with API Keys in Frontend
You can't put API keys in client-side code:
```html
```
### The Traditional Workaround
Build a backend proxy that forwards requests:
```javascript
// 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:
```javascript
// 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
//
```
The screenshot is taken only when the browser loads the image. Your server does zero proxying.
## Generating Signed URLs
### Node.js
```javascript
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
```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
```html
```
### Link Preview Cards
```html
{{ pageTitle }}
{{ pageDescription }}
```
### Email Embeds
Signed URLs work in email since they're just standard image URLs:
```html
```
## Security Best Practices
### 1. Always Set Expiration
Never create signed URLs without an expiration:
```javascript
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:
```javascript
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:
```javascript
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:
```javascript
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:
```javascript
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
```javascript
// 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:
```html
```
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:
```javascript
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.