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:
- Dashboard — You create an API key and receive a
publicKey+secretKeypair (one-time setup) - Your Server — For each image, your server computes a signature using the
secretKeyand embeds it in the URL - 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 Case | Recommended Expiration |
|---|---|
| Static page images | 24 hours (86400 seconds) |
| Dynamic content | 1 hour (3600 seconds) |
| Temporary share links | 15 minutes (900 seconds) |
| Testing | 5 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
3600seconds) - Round the raw expiration (
rawExp = now + ttl) down to its bucket boundary usingMath.floor(rawExp / bucket) * bucketso 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
| Property | Benefit |
|---|---|
| One-way function | A captured signature cannot reveal the secretKey |
| Constant-time comparison | Prevents timing attacks via timingSafeEqual |
| 32-character output | Brute force is computationally infeasible |
| Encrypted storage | The 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.
Related Documentation
- Next.js Integration — Full integration walkthrough
- API Endpoint — URL format and parameter reference
- Error Codes — Troubleshooting failed requests
Last updated on