OptStuff

URL Signing

How to generate HMAC-SHA256 signed URLs for OptStuff image requests — signature formula, code examples, expiration, and security properties.

Every OptStuff image request requires a cryptographic signature to prove the URL was authorized. This guide covers how signing works and how to implement it.

For a higher-level overview, see Core Concepts. For a complete integration walkthrough, see the Next.js Integration guide.

How It Works

There are three actors in the signing system:

  1. Dashboard — You create an API key and receive a publicKey + secretKey pair (one-time setup)
  2. Your Server — For each image, your server computes a signature using the secretKey and embeds it in the URL
  3. OptStuff Server — On each request, it recomputes the expected signature and compares it to the one in the URL

The server doesn't maintain a list of valid URLs. A matching signature is the authorization proof. In production, requests go to your OptStuff deployment origin (for example, https://images.example.com), then the /api/v1/... path.

Signature Formula

signature = HMAC-SHA256(secretKey, payload)
             .digest("base64url")
             .substring(0, 32)

Where payload is:

  • With expiration: {operations}/{imageUrl}?exp={expiresAt}
  • Without expiration: {operations}/{imageUrl}

Code Example

import { createHmac } from "crypto";

function createSignedUrl(options: {
  readonly baseUrl: string;
  readonly projectSlug: string;
  readonly publicKey: string;
  readonly secretKey: string;
  readonly operations: string;
  readonly imageUrl: string;
  readonly expiresAt: number | undefined;
}): string {
  const path = `${options.operations}/${options.imageUrl}`;
  const payload = options.expiresAt
    ? `${path}?exp=${options.expiresAt}`
    : path;

  const signature = createHmac("sha256", options.secretKey)
    .update(payload)
    .digest("base64url")
    .substring(0, 32);

  const params = new URLSearchParams({
    key: options.publicKey,
    sig: signature,
    ...(options.expiresAt && { exp: String(options.expiresAt) }),
  });

  const baseUrl = options.baseUrl.replace(/\/$/, "");
  return `${baseUrl}/api/v1/${options.projectSlug}/${path}?${params.toString()}`;
}

Usage

const now = Math.floor(Date.now() / 1000);
const ttlSeconds = 3600;
const bucketSeconds = 3600;
const rawExp = now + ttlSeconds;
const effectiveBucket = bucketSeconds > 0
  ? Math.min(bucketSeconds, ttlSeconds)
  : 0;
const expiresAt = effectiveBucket > 0
  ? Math.floor(rawExp / effectiveBucket) * effectiveBucket
  : rawExp;
const finalExpiresAt = Math.max(now + 1, expiresAt);

const url = createSignedUrl({
  baseUrl: "https://images.example.com",
  projectSlug: "my-blog",
  publicKey: "pk_abc123",
  secretKey: "sk_your_secret_key",
  operations: "w_800,f_webp",
  imageUrl: "cdn.example.com/photo.jpg",
  expiresAt: finalExpiresAt, // bucketed expiration for better cache reuse
});

Signature Expiration

The optional exp parameter limits how long a signed URL remains valid, preventing replay attacks.

Use CaseRecommended Expiration
Static page images24 hours (86400 seconds)
Dynamic content1 hour (3600 seconds)
Temporary share links15 minutes (900 seconds)
Testing5 minutes (300 seconds)

When exp is set, it must be included in both the signed payload and the URL query string.

Cache-Friendly Expiration Strategy

If you generate exp as now + ttl on every render, each request can produce a different URL and reduce CDN/browser cache hit rate. For static or semi-static images, prefer time-bucketed expiration:

  • Choose a TTL (for example 3600 seconds)
  • Round the raw expiration (rawExp = now + ttl) down to its bucket boundary using Math.floor(rawExp / bucket) * bucket so multiple requests whose expirations fall in the same bucket reuse the same signed URL
  • Reuse the same signed URL inside that bucket window

This keeps signatures valid while avoiding unnecessary cache fragmentation.

Security Properties

PropertyBenefit
One-way functionA captured signature cannot reveal the secretKey
Constant-time comparisonPrevents timing attacks via timingSafeEqual
32-character outputBrute force is computationally infeasible
Encrypted storageThe secretKey is AES-256-GCM encrypted at rest; shown only once at creation

Common Mistakes

If you get 403 Invalid or expired signature, see Troubleshooting Signature Errors in Error Codes.

Last updated on

On this page