You want to embed a live screenshot in an email, a public dashboard, or an <img> tag on your website. The problem: every screenshot request requires an API key, and you can’t put your API key in a URL that end users can see.
The traditional solution is to proxy every request through your own server — the client requests from your backend, your backend calls the screenshot API with the key, and you relay the image. This works but adds latency, complexity, and server load.
Signed URLs solve this cleanly. Instead of authenticating with an API key, the request is verified using a cryptographic signature. The URL is safe to expose publicly because the signature can only be generated by someone who knows your signing secret — and the signing secret never appears in the URL.
How Signed URLs Work
A signed URL replaces the API key with two parameters:
key_id— identifies which signing key to use (not a secret)signature— an HMAC-SHA256 hash that proves the URL was created by someone with the signing secret
The URL looks like this:
https://api.len.sh/v1/screenshot?key_id=abc123&url=https://example.com&width=1280&signature=a1b2c3d4e5f6...
No API key appears anywhere. The server verifies the signature by recomputing the HMAC using the signing secret associated with the key_id. If it matches, the request is authenticated.
The Signing Algorithm
The algorithm is straightforward:
- Collect all query parameters except
signature - Sort parameter names alphabetically
- Build a canonical string:
key1=value1&key2=value2&...(using raw/decoded values) - Compute
HMAC-SHA256(signing_secret, canonical_string) - Output the result as a hex string
- Append
&signature={hex}to the URL
This is a standard HMAC signing pattern — the same approach used by AWS, Stripe, and other APIs for request authentication.
Security Properties
- The signing secret is separate from your API key and can be rotated independently
- Signed URLs do not expire — they’re designed for static
<img>tags where you don’t want images to break - Rate limits and monthly quotas still apply to signed URL requests
- An attacker who sees the URL can replay it (get the same screenshot) but cannot modify it (changing any parameter invalidates the signature)
Getting Your Signing Secret
When you generate an API key in the len.sh dashboard, you receive both:
- Your API key (for server-side requests):
sk_live_a1b2c3d4... - Your signing secret (for generating signed URLs):
sig_secret_x1y2z3... - Your key ID (public identifier):
key_abc123
The signing secret is shown only once when the key is created. Store it securely alongside your API key.
Generating Signed URLs
JavaScript
Using the official @lensh/sdk package:
import { signUrl } from "@lensh/sdk";
const url = signUrl(
"https://api.len.sh/v1/screenshot?url=https://example.com&width=1280&format=webp",
{
keyId: "key_abc123",
signingSecret: "sig_secret_x1y2z3...",
}
);
console.log(url);
// https://api.len.sh/v1/screenshot?url=https://example.com&width=1280&format=webp&key_id=key_abc123&signature=a1b2c3d4...
Python
Using the official lensh package:
from lensh import sign_url
url = sign_url(
"https://api.len.sh/v1/screenshot?url=https://example.com&width=1280&format=webp",
key_id="key_abc123",
signing_secret="sig_secret_x1y2z3...",
)
print(url)
# https://api.len.sh/v1/screenshot?url=https://example.com&width=1280&format=webp&key_id=key_abc123&signature=a1b2c3d4...
Manual Implementation (Any Language)
If you’re not using an official SDK, here’s how to implement signing from scratch:
Node.js:
import { createHmac } from "crypto";
function signUrl(url, keyId, signingSecret) {
const parsed = new URL(url);
parsed.searchParams.set("key_id", keyId);
// Collect all params except signature
const params = [];
for (const [key, value] of parsed.searchParams.entries()) {
if (key !== "signature") {
params.push([key, value]);
}
}
// Sort alphabetically by key
params.sort((a, b) => a[0].localeCompare(b[0]));
// Build canonical string
const canonical = params.map(([k, v]) => `${k}=${v}`).join("&");
// Compute HMAC-SHA256
const signature = createHmac("sha256", signingSecret)
.update(canonical)
.digest("hex");
parsed.searchParams.set("signature", signature);
return parsed.toString();
}
Python:
import hmac
import hashlib
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
def sign_url(url: str, key_id: str, signing_secret: str) -> str:
parsed = urlparse(url)
params = parse_qs(parsed.query, keep_blank_values=True)
flat = {k: v[0] for k, v in params.items()}
flat["key_id"] = key_id
flat.pop("signature", None)
canonical = "&".join(
f"{k}={flat[k]}" for k in sorted(flat.keys())
)
signature = hmac.new(
signing_secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256,
).hexdigest()
flat["signature"] = signature
return urlunparse(parsed._replace(query=urlencode(flat)))
Ruby:
require "openssl"
require "uri"
def sign_url(url, key_id:, signing_secret:)
uri = URI.parse(url)
params = URI.decode_www_form(uri.query || "").to_h
params["key_id"] = key_id
params.delete("signature")
canonical = params.sort.map { |k, v| "#{k}=#{v}" }.join("&")
signature = OpenSSL::HMAC.hexdigest(
"SHA256", signing_secret, canonical
)
params["signature"] = signature
uri.query = URI.encode_www_form(params)
uri.to_s
end
PHP:
function signUrl(string $url, string $keyId, string $signingSecret): string {
$parsed = parse_url($url);
parse_str($parsed['query'] ?? '', $params);
$params['key_id'] = $keyId;
unset($params['signature']);
ksort($params);
$canonical = implode('&', array_map(
fn($k, $v) => "$k=$v",
array_keys($params),
array_values($params)
));
$signature = hash_hmac('sha256', $canonical, $signingSecret);
$params['signature'] = $signature;
return $parsed['scheme'] . '://' . $parsed['host']
. $parsed['path'] . '?' . http_build_query($params);
}
Go:
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/url"
"sort"
"strings"
)
func SignURL(rawURL, keyID, signingSecret string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
q := u.Query()
q.Set("key_id", keyID)
q.Del("signature")
keys := make([]string, 0, len(q))
for k := range q {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, len(keys))
for i, k := range keys {
parts[i] = k + "=" + q.Get(k)
}
canonical := strings.Join(parts, "&")
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(canonical))
sig := hex.EncodeToString(mac.Sum(nil))
q.Set("signature", sig)
u.RawQuery = q.Encode()
return u.String(), nil
}
Use Case 1: Screenshots in Email
Embedding live website thumbnails in emails is a common use case — weekly reports, monitoring alerts, link preview digests. With signed URLs, the email client fetches the screenshot directly from the len.sh CDN:
import { signUrl } from "@lensh/sdk";
function generateEmailHtml(sites) {
const rows = sites.map((site) => {
const screenshotUrl = signUrl(
`https://api.len.sh/v1/screenshot?url=${encodeURIComponent(site.url)}&width=600&height=400&format=jpeg&quality=80`,
{
keyId: process.env.LENSH_KEY_ID,
signingSecret: process.env.LENSH_SIGNING_SECRET,
}
);
return `
<tr>
<td style="padding: 16px;">
<a href="${site.url}">
<img src="${screenshotUrl}" width="600" alt="${site.name}" style="border-radius: 8px;" />
</a>
<p style="margin: 8px 0 0; font-size: 14px; color: #666;">${site.name}</p>
</td>
</tr>
`;
});
return `
<table cellpadding="0" cellspacing="0" width="100%">
${rows.join("")}
</table>
`;
}
The image URLs are signed and safe to include directly in the email. No proxy server needed.
Tips for email screenshots:
- Use JPEG format (
format=jpeg) for smaller file sizes - Set
quality=80for good compression without visible artifacts - Keep the width reasonable (600px is standard for email)
- Consider
cache_ttl— a longer TTL reduces API calls from email opens
Use Case 2: Public Dashboard
If you’re building a website monitoring dashboard that’s publicly accessible (no auth required), signed URLs let you show live thumbnails without exposing your API key:
// React component
function SitePreview({ siteUrl, width = 400, height = 300 }) {
// Generate this on the server and pass as a prop
const signedUrl = signUrl(
`https://api.len.sh/v1/screenshot?url=${encodeURIComponent(siteUrl)}&width=${width}&height=${height}&format=webp`,
{
keyId: process.env.LENSH_KEY_ID,
signingSecret: process.env.LENSH_SIGNING_SECRET,
}
);
return (
<div className="site-preview">
<img
src={signedUrl}
width={width}
height={height}
alt={`Preview of ${siteUrl}`}
loading="lazy"
/>
</div>
);
}
The signed URL is generated server-side (during SSR or at build time), so the signing secret never reaches the client.
Use Case 3: Social Media Cards
Use signed URLs to generate dynamic social cards that show live website previews:
import { signUrl } from "@lensh/sdk";
function getOgScreenshot(pageUrl) {
return signUrl(
`https://api.len.sh/v1/screenshot?url=${encodeURIComponent(pageUrl)}&width=1200&height=630&format=png`,
{
keyId: process.env.LENSH_KEY_ID,
signingSecret: process.env.LENSH_SIGNING_SECRET,
}
);
}
<meta property="og:image" content="<%= getOgScreenshot(currentPageUrl) %>" />
Use Case 4: Link Previews in Chat
If you’re building a chat app or forum that shows link previews, signed URLs let the client fetch thumbnails directly:
// Server-side: generate signed URL for link preview
app.get("/api/link-preview", (req, res) => {
const { url } = req.query;
const thumbnailUrl = signUrl(
`https://api.len.sh/v1/screenshot?url=${encodeURIComponent(url)}&width=800&height=600&format=webp`,
{
keyId: process.env.LENSH_KEY_ID,
signingSecret: process.env.LENSH_SIGNING_SECRET,
}
);
res.json({ thumbnailUrl });
});
// Client-side: use the signed URL directly in an <img>
const { thumbnailUrl } = await fetch(`/api/link-preview?url=${encodeURIComponent(sharedUrl)}`).then(r => r.json());
document.querySelector("#preview-img").src = thumbnailUrl;
The client gets a signed URL it can use directly — no image proxying through your server.
Signed URLs for OG Images and PDFs
Signed URLs work with all len.sh endpoints, not just screenshots:
Signed OG Image URL
const ogUrl = signUrl(
"https://api.len.sh/v1/og?title=My+Blog+Post&badge=Tutorial&brand_name=Acme&theme=dark",
{
keyId: process.env.LENSH_KEY_ID,
signingSecret: process.env.LENSH_SIGNING_SECRET,
}
);
Signed PDF URL
const pdfUrl = signUrl(
"https://api.len.sh/v1/pdf?url=https://example.com/invoice&paper_size=Letter",
{
keyId: process.env.LENSH_KEY_ID,
signingSecret: process.env.LENSH_SIGNING_SECRET,
}
);
Security Best Practices
Keep the Signing Secret Server-Side
The signing secret must never appear in client-side code, browser JavaScript, or mobile apps. Generate signed URLs on your server and pass the complete URL to the client.
// WRONG: signing in the browser
const url = signUrl("...", { signingSecret: "sig_secret_..." });
// RIGHT: signing on the server, passing the URL to the client
app.get("/api/screenshot-url", (req, res) => {
const url = signUrl("...", {
keyId: process.env.LENSH_KEY_ID,
signingSecret: process.env.LENSH_SIGNING_SECRET,
});
res.json({ url });
});
Rotate Secrets Periodically
The signing secret can be rotated independently of your API key. If you suspect a secret has been compromised, generate a new one in the dashboard. Old signed URLs will stop working, so update your application accordingly.
Be Intentional About What You Sign
A signed URL authenticates a specific set of parameters. If you sign a URL with width=1280, someone who captures the URL can only request that exact screenshot. They can’t modify the width, change the URL, or add parameters — any change invalidates the signature.
This means each signed URL is scoped to exactly what you intended. Design your signed URLs to match your use case:
// Sign the exact URL and dimensions you want
const exact = signUrl(
"https://api.len.sh/v1/screenshot?url=https://example.com&width=800&height=600&format=webp",
{ keyId, signingSecret }
);
Signed URLs vs. Proxying
| Approach | Latency | Server load | Complexity | Key security |
|---|---|---|---|---|
| Signed URLs | Low (direct CDN) | None | SDK call | Key never exposed |
| Server proxy | Higher (extra hop) | High (relay traffic) | Build proxy endpoint | Key on your server |
Signed URLs are the better choice in almost every scenario where you need public access to screenshots, OG images, or PDFs. The only exception is when you need to transform or process the image before serving it to the client.
Getting Started
- Sign up for a free len.sh account
- Generate an API key — note your key ID and signing secret
- Install the SDK:
npm install @lensh/sdkorpip install lensh - Generate your first signed URL:
import { signUrl } from "@lensh/sdk";
const url = signUrl(
"https://api.len.sh/v1/screenshot?url=https://example.com",
{ keyId: "your-key-id", signingSecret: "your-signing-secret" }
);
console.log(url);
// Use directly in <img src="..."> tags
Signed URLs work on all plans, including the free tier. Every feature that’s available with API key authentication is also available through signed URLs.