arrow_back Back to blog
Tutorials March 6, 2026 8 min read

Signed URLs: Embed Screenshots in Emails Without Exposing API Keys

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:

  1. Collect all query parameters except signature
  2. Sort parameter names alphabetically
  3. Build a canonical string: key1=value1&key2=value2&... (using raw/decoded values)
  4. Compute HMAC-SHA256(signing_secret, canonical_string)
  5. Output the result as a hex string
  6. 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:

  1. Your API key (for server-side requests): sk_live_a1b2c3d4...
  2. Your signing secret (for generating signed URLs): sig_secret_x1y2z3...
  3. 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=80 for 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) %>" />

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

ApproachLatencyServer loadComplexityKey security
Signed URLsLow (direct CDN)NoneSDK callKey never exposed
Server proxyHigher (extra hop)High (relay traffic)Build proxy endpointKey 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

  1. Sign up for a free len.sh account
  2. Generate an API key — note your key ID and signing secret
  3. Install the SDK: npm install @lensh/sdk or pip install lensh
  4. 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.

Try len.sh for free

Start capturing screenshots with a simple API call. No credit card required.