What Is a Signed URL? Definition and How It Works

A signed URL is a link that carries its own permission: an expiry and a cryptographic signature. How signing and checking work, with tested Node code.

By Simon Chiu · Updated

A signed URL is a link that contains its own permission: the request parameters, an expiry time and a signature computed with a secret key, so a server can trust the link without a login or an API key.

The person who holds the secret creates the link. Anyone who receives it can use it until it expires, but cannot change it. This entry is part of the screenshot glossary.

What it means in plain terms

A normal API call proves who you are with a header such as Authorization: Bearer .... That works between servers, but it breaks in places where you cannot add headers: an <img src>, an og:image tag, a link in an email. If you put the API key itself in such a URL, everyone who views the page can copy the key and spend your account.

A signed URL fixes this. Your server writes down exactly what the link is allowed to do and until when, then seals that with a signature only your secret could produce. The receiving server recomputes the seal. If one character changed, the seals do not match.

Cloud storage calls the same thing a presigned URL. CDNs use it for paid downloads. Screenshot APIs use it so an image URL can sit in public HTML.

The life of a signed URL

sequenceDiagram
  participant S as Your server (holds the secret)
  participant B as Browser or social crawler
  participant A as API or storage server
  S->>S: canonical string = sorted params + expires
  S->>S: signature = HMAC-SHA256(secret, string)
  S->>B: HTML with the signed link
  B->>A: GET link (no secret, no API key)
  A->>A: expired? recompute HMAC, compare in constant time
  A-->>B: 200 with the resource, or 401

How it works technically

Three rules make a signature scheme work:

  1. A canonical string. Signer and checker must build byte-identical input. Usually that means sorting parameters and encoding them one exact way. Most "invalid signature" bugs are encoding differences, not wrong keys.
  2. A keyed hash, usually an HMAC with SHA-256. Without the secret, nobody can compute a valid value for a changed string.
  3. An expiry inside the signed data. Because expires is part of the string, nobody can extend it.

The checker should compare signatures in constant time, so the response time does not leak how many leading characters matched. Here is a complete, generic sign-and-verify pair in Node.js:

// A generic HMAC signed URL: sign on one side, verify on the other.
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.URL_SIGNING_SECRET ?? 'dev-only-secret';

export function sign(path, params, ttlSeconds) {
  const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
  const query = new URLSearchParams({ ...params, expires: String(expires) });
  query.sort(); // both sides must build the same canonical string
  const signature = createHmac('sha256', SECRET).update(`${path}?${query}`).digest('hex');
  query.append('signature', signature);
  return `${path}?${query}`;
}

export function verify(signedPathAndQuery) {
  const url = new URL(signedPathAndQuery, 'http://placeholder');
  const given = url.searchParams.get('signature') ?? '';
  url.searchParams.delete('signature');
  url.searchParams.sort();

  if (Number(url.searchParams.get('expires')) < Date.now() / 1000) return 'expired';
  const expected = createHmac('sha256', SECRET).update(`${url.pathname}?${url.searchParams}`).digest('hex');
  const a = Buffer.from(given, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && timingSafeEqual(a, b) ? 'valid' : 'invalid';
}

const link = sign('/files/report.pdf', { user: '42' }, 600);
console.log(verify(link));                                   // valid
console.log(verify(link.replace('user=42', 'user=43')));     // invalid
console.log(verify(sign('/files/report.pdf', { user: '42' }, -1))); // expired

Tested on 2026-10-15 with Node 22.18.0. It printed valid, invalid, expired. This is a teaching example for your own endpoints. It is not the RenderScreenshot encoding, which differs in detail (see below).

Where Puppeteer and Playwright fit

Browser libraries do not sign anything. A signed URL is a delivery concern, not a rendering one. They meet in two places:

  • When you run your own screenshot service on Puppeteer or Playwright, you need a scheme like the one above in front of it before you can put its URLs in public pages.
  • When a page you capture loads assets from signed links, those links must still be valid at capture time. An expired asset link renders as a broken image in the screenshot.

Signed URLs in RenderScreenshot

RenderScreenshot accepts signed GET /v1/screenshot links. They carry a public key ID (rs_pub_...), an expires timestamp in Unix seconds and a signature, which is HMAC-SHA256 over the sorted, form-encoded parameters, made with your secret key (rs_secret_...). The secret never appears in the link. The maximum expiry is 30 days, and links without an expiry are not accepted.

A signed link looks like this (signature shortened):

https://api.renderscreenshot.com/v1/screenshot?expires=1792644628&key_id=rs_pub_...&preset=og_card&url=https%3A%2F%2Fexample.com%2Fblog%2Fhello-world&signature=5c0f...

The CLI builds one for you, reading the key pair from RS_PUBLIC_KEY_ID and RS_SECRET_KEY:

rs signed-url https://example.com/blog/hello-world --preset og_card --expires 7d

Tested signing code in six languages, the exact encoding rules and every error case are in the signed URLs reference.

Common mistakes

  • Editing the link after signing. A template that appends &format=webp breaks the signature. Sign every parameter you send.
  • Encoding twice, or with a different encoder than the checker (%20 versus +).
  • Milliseconds instead of seconds in expires. The timestamp lands thousands of years in the future, so the link seems to never expire. Divide by 1000.
  • Signing in the browser. The secret must stay on a server. Anything shipped to the client is public.
  • Using a signed URL where a stored copy is better. A link that must work for years should point to your own file, not to a signed render.

To sign your first screenshot link, create a free account and request a signing key pair.

Frequently asked questions

Is a signed URL secure if anyone can see it?
Anyone who has the link can use it until it expires, so treat it like a short-lived password. What the signature prevents is changing the link: a different parameter or a later expiry makes the signature invalid.
What is the difference between a signed URL and a presigned URL?
They are the same idea. Presigned URL is the name Amazon S3 and S3-compatible storage use for a signed link to an object. Signed URL is the general term, also used by CDNs and APIs.
Can a signed URL be revoked before it expires?
Only by rotating or revoking the key that signed it. That invalidates every link signed with that key, which is why expiries should stay short.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.