Skip to content

Signed URLs explained: HMAC signatures, expiry and replay protection

How signed URLs work: HMAC signatures, canonical forms, expiry, method binding, nonces and replay protection, with working signing and verification code.

The ProxifyEdge team 6 min read

A signed URL is a link that carries its own proof of permission. Instead of giving a browser a reusable credential, your server hands it one URL that is valid for one purpose and a short time. Signed URLs are how storage services share private files, how CDNs protect paid media, and how you can let a frontend call an API without giving it a key worth stealing.

This article explains how signed URLs work with HMAC signatures, the mistakes that make them forgeable, and how to add expiry, method binding and replay protection.

What a signed URL is

A normal API key is a credential: whoever holds it can make any request the key allows, for as long as the key lives. A signed URL is a capability: it grants exactly one thing, and it expires.

https://api.example.com/files/report.pdf?exp=1791230400&sig=Qm9yZWQ...
                         └── what ──┘    └ until when ┘  └── proof ──┘

The server that receives the URL recomputes the signature from the parts it can see. If the result matches and the expiry is in the future, the request is allowed. Nothing about the signing secret is in the URL, and changing any covered part breaks the signature.

How HMAC signatures work

Most signed-URL schemes use an HMAC: a hash-based message authentication code, defined in RFC 2104. HMAC-SHA256 takes a secret key and a message and produces a 32-byte tag. Without the key, nobody can produce a valid tag for a different message, and the tag reveals nothing useful about the key.

Signing and verification use the same secret, which makes HMAC a good fit when the signer and verifier are the same service, or two services that share a secret. When many independent parties must verify a signature, public-key signatures (where only the signer holds the private key) are the usual choice instead.

A minimal signer and verifier

Here is a small, correct implementation in Node.js. It signs a method, a path and an expiry:

import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.URL_SIGNING_SECRET;

// Length-prefix each field so no two different inputs share a canonical form.
function canonical(fields) {
  return fields.map((field) => `${Buffer.byteLength(field)}:${field}`).join('');
}

export function signPath(path, { method = 'GET', ttlSeconds = 300 } = {}) {
  const exp = String(Math.floor(Date.now() / 1000) + ttlSeconds);
  const sig = createHmac('sha256', SECRET)
    .update(canonical([method, path, exp]))
    .digest('base64url');
  return `${path}?exp=${exp}&sig=${sig}`;
}

export function verifyPath(method, path, exp, sig) {
  if (!/^\d+$/.test(exp ?? '') || Number(exp) < Date.now() / 1000) return false;
  const expected = createHmac('sha256', SECRET)
    .update(canonical([method, path, exp]))
    .digest();
  const given = Buffer.from(sig ?? '', 'base64url');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Three details in that short example are doing most of the security work. The next sections explain each one.

Canonicalization: sign exactly what you mean

The signature covers a canonical form: a single string built from the parts of the request that matter. If two different requests can produce the same canonical string, an attacker can reuse a signature for a request you never approved.

The classic mistake is joining fields with a separator. Suppose you sign query parameters as name=value pairs joined with &. Then { a: "1", b: "2" } and { a: "1&b=2" } both become a=1&b=2. If an attacker controls one value, they can smuggle in another parameter under your signature. Length-prefixing each field, as above, makes the encoding unambiguous.

Other canonicalization rules worth deciding explicitly:

  • Which parameters are covered. Cover everything that changes what the server does, including options like response formats, not just the target.
  • Order. Proxies and frameworks reorder query strings. Sort parameter names before signing so order does not matter.
  • Encoding. Decide whether you sign raw or percent-encoded values, and do the same thing on both sides.

Compare signatures in constant time

A naive a === b comparison stops at the first mismatched character, so it takes slightly longer the more of the prefix is correct. Over many requests, that timing difference can leak the expected signature byte by byte. Use a constant-time comparison such as crypto.timingSafeEqual in Node.js or hmac.Equal in Go, and check lengths first.

Expiry and clock skew

Every signed URL needs an expiry. It limits how long a leaked URL is useful, so short is better: seconds to minutes for an API call, longer only for links that people open later.

Two practical rules:

  • Tolerate a little clock skew. The signer’s and verifier’s clocks are never exactly in sync. Allowing around half a minute avoids spurious failures.
  • Cap the maximum lifetime. Reject expiries far in the future, so a bug or a compromised signer cannot mint URLs that last for years.

Bind the method

If the HTTP method is not part of the signature, a URL signed for a harmless GET may be replayed as DELETE or POST against the same path. Include the method in the canonical form, as the example does, and refuse any other method. A reasonable default is that a signature without an explicit method allows only GET and HEAD.

Replay protection with nonces

Within its lifetime, a signed URL can be used any number of times. For most reads that is fine. For actions that must happen once, such as redeeming a coupon or confirming a payment, add a nonce: a random value included in the signature.

The verifier then records each nonce it accepts and rejects repeats until the URL would have expired anyway:

// With Redis: SET with NX succeeds only the first time a nonce is seen.
const firstUse = await redis.set(`nonce:${nonce}`, '1', { NX: true, EX: secondsUntilExpiry });
if (!firstUse) throw new Error('This link has already been used');

The nonce must be inside the signature. Otherwise an attacker simply swaps in a fresh one.

Rotating signing secrets

Secrets need rotation like any other credential. Add a key identifier to the URL (often called kid) so the verifier knows which secret to use, keep the old secret valid until URLs signed with it have expired, then retire it.

When signed URLs are the right tool

Signed URLs fit best when a trusted server decides what a client may do, and the client only carries out that decision:

  • Private downloads and uploads. The server checks that the user may access a file, then hands the browser a short-lived link straight to storage, so large files never pass through your application servers.
  • Paid or protected media. A player receives links that stop working minutes later, which makes sharing them pointless.
  • Browser calls to keyed APIs. The server signs each request URL, so the page never holds a credential that works on its own.

They are a poor fit when the client must decide the request itself, such as free-form search, because the server would have to sign whatever the client asks for.

Signed URLs with ProxifyEdge

Origin locking keeps a public ProxifyEdge key from working on other websites, but curl can send any Origin. For keys that must not be usable outside your app, ProxifyEdge supports signed URLs per key (signed URLs reference). Your server signs each proxy URL with the key’s signing secret, and once you turn on Require signature, unsigned requests for that key are refused.

// On your server. SIGNING_SECRET is the key's signing secret from the dashboard.
import { createHmac } from 'node:crypto';

export function signProxyUrl({ target, key, ttlSeconds = 60, method, nonce }) {
  const params = new URLSearchParams();
  params.set('url', target);
  params.set('exp', String(Math.floor(Date.now() / 1000) + ttlSeconds));
  if (nonce) params.set('sn', nonce);
  if (method) params.set('m', method.toUpperCase());

  // Every parameter except sig and key, names sorted, length-prefixed.
  const names = [...new Set(params.keys())].filter((n) => n !== 'sig' && n !== 'key').sort();
  let canonical = '';
  for (const name of names) {
    for (const value of params.getAll(name).sort()) {
      canonical += `${Buffer.byteLength(name)}:${name}${Buffer.byteLength(value)}:${value}`;
    }
  }
  params.set('sig', createHmac('sha256', process.env.SIGNING_SECRET).update(canonical).digest('base64url'));
  params.set('key', key);
  return `https://api.proxifyedge.com/proxy?${params}`;
}

The rules ProxifyEdge applies:

ParameterMeaning
expExpiry as a Unix timestamp in seconds. At most 24 hours ahead; 30 seconds of clock skew is tolerated.
sigHMAC-SHA256 of the canonical form, base64url without padding.
mOptional. Binds the signature to one method. Without it, only GET and HEAD are allowed.
snOptional nonce, covered by the signature. ProxifyEdge does not track it; use it for single-use schemes of your own.
keyIdentifies the key and its signing secret. Not part of the signature.

The signature covers every query parameter except sig and key, including transform and timeout parameters, so a URL signed to fetch JSON cannot be turned into one that scrapes a page.

Key takeaways

  • A signed URL turns a reusable credential into a short-lived capability for one request.
  • HMAC-SHA256 with a server-held secret is simple and strong when the signer and verifier share the secret.
  • Most signed-URL bugs are canonicalization bugs: length-prefix fields, sort parameters and cover everything that changes behavior.
  • Compare signatures in constant time, keep expiries short with a little skew tolerance, and cap the maximum lifetime.
  • Bind the HTTP method, and add a recorded nonce when an action must happen only once.
  • To keep a public API key from being used outside your app, have your server sign each request URL. Origin locking alone cannot stop non-browser clients.

Are you sure?