Home › Security › Access control & request signing
algorithm low

Signed URLs

Problem A service must let a client with no account or session fetch or upload exactly one object for a few minutes, without the bytes passing through the app server and without storing a grant per link.

A URL carrying its expiry and a MAC or signature over method, path and query, so a verifier holding the key needs no lookup.

sign and verify O(url length), one MAC each, no network call or state; key lookup O(1) by key id
capabilityhmacexpirypresigned-urlstateless-authorizationkey-rotation
Overview

Authorization without a session: the issuer, which knows who may do what, writes the grant into the URL (method, path, expiry, constraints) and appends a MAC over it. Whoever holds the URL holds the capability; the verifier needs only the key. Media downloads, shared-file links, phone uploads to storage, firmware fetched by devices and game asset CDNs use it because the payload skips the app server.

Key facts

Use when

  • A client without credentials (browser, mobile app, device, CDN edge) needs one object for minutes to days.
  • Bulk bytes should flow client to storage or CDN, not through your servers.

Avoid when

  • You need instant per-link revocation or one-time use: keep a server-side grant or use a short-lived session.
  • The data is highly sensitive and the URL will be logged, shared or cached beyond your control.

Deep dive

How it works

In: method, path, query, expiry, key id + secret key · Out: URL with exp, kid, sig; verifier answers accept or reject · State: keyring (kid → secret), no per-URL state · Cost: one HMAC-SHA256 over the canonical string per sign or verify

  1. Issuer builds params: caller constraints plus exp = now + ttl and kid = current.
  2. Canonicalise: percent-encode names and values (RFC 3986 unreserved kept, uppercase hex), then sort the encoded pairs. Path segments are decoded and re-encoded, so %2F stays distinct from /.
  3. sig = HMAC-SHA256(key[kid], domain-tag || canonical), hex; append as &sig=.
  4. Verifier splits off sig, rejects duplicate reserved names, looks up kid (unknown means reject), recomputes the canonical form from what it received.
  5. Compare with verify_slice (constant time). Only then trust exp: reject if now > exp + skew.
  6. Check what the MAC cannot (real client IP against signed ip).
  7. Rotate: add the new key, sign with it, retire the old one after the longest TTL.
issuer --(sign: path, method, exp, kid)--> URL --> client
client --GET url--> verifier: kid -> key; MAC ok? (constant time) -> exp ok? -> ip/size ok? -> object

Real systems differ in the MAC: S3 SigV4 chains HMAC-SHA256 over date, region, service and aws4_request into a scoped signing key; CloudFront and Google Cloud Storage use asymmetric signatures (verifiers hold public keys); Azure SAS uses HMAC-SHA256 under an account or user delegation key.

Code

Rust, hmac = "0.13", sha2 = "0.11", direct API. Compiled and run with the asserts below.

use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::collections::HashMap;

type Params = Vec<(String, String)>;
#[derive(Debug, PartialEq)]
pub enum Reject { Malformed, UnknownKey, Expired, BadSignature }

/// RFC 3986 unreserved bytes stay, everything else is %XX (uppercase hex).
fn enc(s: &str) -> String {
    s.bytes().map(|b| match b {
        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(),
        _ => format!("%{b:02X}"),
    }).collect()
}
fn dec(s: &str) -> Option<String> {
    let (b, mut out, mut i) = (s.as_bytes(), Vec::new(), 0);
    while i < b.len() {
        if b[i] == b'%' {
            out.push(u8::from_str_radix(s.get(i + 1..i + 3)?, 16).ok()?);
            i += 3;
        } else { out.push(b[i]); i += 1; }
    }
    String::from_utf8(out).ok()
}
fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() }
fn unhex(s: &str) -> Option<Vec<u8>> {
    if s.len() % 2 != 0 { return None; }
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}

/// Canonical form: METHOD \n encoded path \n sorted encoded query. Sort happens AFTER encoding.
fn canonical(method: &str, path: &str, params: &Params) -> Option<String> {
    let segs: Option<Vec<String>> = path.split('/').map(|s| dec(s).map(|d| enc(&d))).collect();
    let mut q: Vec<String> = params.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect();
    q.sort();
    Some(format!("{method}\n{}\n{}", segs?.join("/"), q.join("&")))
}
fn mac(key: &[u8], msg: &str) -> Hmac<Sha256> {
    let mut m = Hmac::<Sha256>::new_from_slice(key).expect("any key length");
    m.update(b"url-v1\n"); // domain separation: this MAC cannot be replayed as another message type
    m.update(msg.as_bytes());
    m
}

pub struct Keyring { keys: HashMap<String, Vec<u8>>, current: String }
impl Keyring {
    pub fn new(kid: &str, secret: &[u8]) -> Self {
        Keyring { keys: HashMap::from([(kid.into(), secret.to_vec())]), current: kid.into() }
    }
    /// Rotation: add the new key and sign with it; the old one still verifies until `retire`.
    pub fn rotate(&mut self, kid: &str, secret: &[u8]) { self.keys.insert(kid.into(), secret.to_vec()); self.current = kid.into(); }
    pub fn retire(&mut self, kid: &str) { self.keys.remove(kid); }

    /// `path` is the already-encoded URL path. `extra` = caller constraints to sign (e.g. response-content-type, ip, object version).
    pub fn sign(&self, method: &str, path: &str, extra: &[(&str, &str)], now: u64, ttl: u64) -> String {
        let mut p: Params = extra.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
        p.push(("exp".into(), (now + ttl).to_string()));
        p.push(("kid".into(), self.current.clone()));
        let m = canonical(method, path, &p).expect("valid path");
        let sig = hex(&mac(&self.keys[&self.current], &m).finalize().into_bytes());
        let q: Vec<String> = p.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect();
        format!("{path}?{}&sig={sig}", q.join("&"))
    }

    /// Returns the verified params so the caller can enforce signed constraints (ip, ...).
    pub fn verify(&self, method: &str, url: &str, now: u64, skew: u64) -> Result<Params, Reject> {
        let (path, query) = url.split_once('?').ok_or(Reject::Malformed)?;
        let mut p: Params = Vec::new();
        for kv in query.split('&') {
            let (k, v) = kv.split_once('=').ok_or(Reject::Malformed)?;
            p.push((dec(k).ok_or(Reject::Malformed)?, dec(v).ok_or(Reject::Malformed)?));
        }
        let get = |p: &Params, n: &str| -> Result<String, Reject> {
            let mut it = p.iter().filter(|(k, _)| k == n); // duplicates of a reserved name are ambiguous
            match (it.next(), it.next()) { (Some((_, v)), None) => Ok(v.clone()), _ => Err(Reject::Malformed) }
        };
        let sig = unhex(&get(&p, "sig")?).ok_or(Reject::Malformed)?;
        p.retain(|(k, _)| k != "sig");
        let exp: u64 = get(&p, "exp")?.parse().map_err(|_| Reject::Malformed)?;
        let key = self.keys.get(&get(&p, "kid")?).ok_or(Reject::UnknownKey)?;
        // MAC first, constant-time (verify_slice); only then trust exp.
        let m = canonical(method, path, &p).ok_or(Reject::Malformed)?;
        mac(key, &m).verify_slice(&sig).map_err(|_| Reject::BadSignature)?;
        if now > exp + skew { return Err(Reject::Expired); }
        Ok(p)
    }
}

fn main() {
    let mut ring = Keyring::new("k1", b"secret-one-secret-one-secret-one!");
    let (now, ttl) = (1_800_000_000u64, 300u64);
    let url = ring.sign("GET", "/files/report%202026.pdf", &[("ip", "203.0.113.7"), ("dl", "1")], now, ttl);
    println!("{url}");
    let ok = |r: &Keyring, u: &str, t: u64| r.verify("GET", u, t, 5);
    let p = ok(&ring, &url, now + 10).unwrap();
    assert!(p.contains(&("ip".into(), "203.0.113.7".into()))); // caller compares to the real client IP

    assert_eq!(ok(&ring, &url.replace("report", "secret"), now), Err(Reject::BadSignature)); // path
    assert_eq!(ok(&ring, &url.replace("dl=1", "dl=2"), now), Err(Reject::BadSignature)); // query
    assert_eq!(ok(&ring, &url.replace(&format!("{}", now + ttl), &format!("{}", now + 9999)), now), Err(Reject::BadSignature)); // expiry
    assert_eq!(ring.verify("PUT", &url, now, 5), Err(Reject::BadSignature)); // method
    assert_eq!(ok(&ring, &url, now + ttl + 6), Err(Reject::Expired));
    assert!(ok(&ring, &url, now + ttl + 5).is_ok()); // inside the skew allowance
    assert!(ok(&ring, &url.replace("report", "r%65port"), now).is_ok()); // equivalent encoding

    ring.rotate("k2", b"secret-two-secret-two-secret-two!"); // overlap window
    assert!(ok(&ring, &url, now).is_ok()); // old kid still verifies
    let url2 = ring.sign("GET", "/files/a", &[], now, ttl);
    assert!(url2.contains("kid=k2") && ok(&ring, &url2, now).is_ok());
    ring.retire("k1"); // after the longest TTL has passed
    assert_eq!(ok(&ring, &url, now), Err(Reject::UnknownKey));
    println!("all asserts passed");
}

Complexity and constants

One HMAC-SHA256 over order 100-300 bytes, no I/O or shared state, so verification scales with cores. Cost drivers: string building (allocation, encoding, sorting) and URL bytes per request (a CloudFront custom-policy URL carries a base64 policy). Measured on the reference: 5.1 µs per verify, 6.0 µs per sign (Apple-silicon Mac, release build, 135-byte URL), dominated by String work, not the MAC.

Efficiency

Cost: one MAC plus parsing; no round trip or storage read for authorization. The saving is bytes: the object goes client to storage or edge, never through the app server.

Levers:

  • Verify in storage or at the edge — app sees no payload (S3, GCS, Azure verify in-service) — always.
  • One signature for many objects — CloudFront custom policy with wildcard Resource, or signed cookies (CloudFront docs) — video segments, asset bundles.
  • Drop signature and expiry from the CDN cache key once the edge verified them, else each link caches separately (design reasoning; check your CDN) — cached content.

Naive → standard → state of the art:

  • Proxy through the app after a session check · app CPU and egress for every byte, a DB read per request
  • Per-object HMAC URL verified by storage or edge · one MAC per request, payload bypasses the app
  • Prefix policy or cookie at the edge, kid rotation, signature-free cache key · one signature per session, edge hits

Implementation notes

  • Sign everything but the signature: method, host or tenant if keys are shared, path, all query parameters.
  • Percent-encode, then sort. Mac::verify_slice, never == on hex (constant-time-code); MAC before expiry.
  • Keys: 32 random bytes from a secrets store, kid in the URL. Overlap rotation as CloudFront documents it: add new key, sign with it, wait out old expiries, remove old (key-rotation).
  • Scope: one object by default. Prefix scope needs a signed pattern and a strict matcher (CloudFront: *, ?); a lax one is path traversal.
  • Skew: Azure warns of up to 15 minutes either way on SAS times; allow seconds for short links (clock-synchronization).
  • Referrer-Policy: no-referrer on pages with links; redact query strings in logs.

Pitfalls

  • Leakage. URLs land in logs, proxies, history (CWE-598); the default referrer policy strict-origin-when-cross-origin sends the full URL on same-origin requests (MDN), exposing it to your own third-party scripts.
  • No revocation. GCS: valid until expiry or key rotation. AWS: dies with a revoked signing credential, and temporary credentials cap lifetime. Azure: stored access policies (five per container) revoke service SAS without regenerating keys.
  • Unconstrained uploads. Holders write anything at your cost (Azure bills any SAS use); sign content type and size where supported.
  • Method or host unsigned. A GET URL then authorises PUT or DELETE.
  • Expiry in flight. CloudFront checks at request time: a started download finishes, a retry or Range request after expiry fails.
  • Decoding differences between proxy and storage (%2F) cause mismatches or confused-deputy bugs.

Variants and alternatives

Signed cookiesmany files, one grant (CloudFront-Policy, -Signature, -Key-Pair-Id); no URL rewriting.
Signed header (hmac-request-signing)same canonical form, nothing in the URL.JWT bearerflexible claims, not path-bound unless you add it.
Server-side granttoken to row: instant revocation, one lookup per request (capability-tokens).

In production

  • Media and file sharing: CloudFront (canned Expires/Signature/Key-Pair-Id; custom Policy with start, IP range, wildcards; RSA 2048 or ECDSA 256 keys in trusted key groups), S3 SigV4 (X-Amz-Expires 1-604800), GCS V4 (GOOG4-RSA-SHA256), Azure SAS (sp st se sr si sip sig).
  • Mobile uploads: app asks the backend for a URL, then PUTs to object storage.
  • IoT firmware and game assets: devices and clients fetch builds and patches from a CDN via links the update or entitlement service mints; add signed update metadata, as the URL gives access, not integrity.
  • Web servers: nginx secure_link checks an MD5 over a chosen string plus expiry.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
object_storeRustapache/arrow-rs-object-storecargo add object_store --features aws,gcp,azure————Signer::signed_url(method, path, expires_in); S3, GCS, Azure
aws-sigv4Rustsmithy-lang/smithy-rscargo add aws-sigv4 --features sign-http————SignatureLocation::QueryParams gives presigned URLs
google-cloud-storageRustgoogleapis/google-cloud-rustcargo add google-cloud-storage————signed_url module, SignedUrlBuilder, V4
nginx secure_linkCnginx/nginxbuild --with-http_secure_link_module————MD5 link check with expiry; optional module
itsdangerousPythonpallets/itsdangerouspip install itsdangerous————TimestampSigner with max age; non-storage links

Sources

  • AWS, Authentication methods and Create a signed request, IAM User Guide. methods · signing
  • AWS, Download and upload objects with presigned URLs, S3 User Guide. docs
  • Google Cloud, Signed URLs, Cloud Storage docs. docs
  • AWS, Use signed URLs and Specify signers, CloudFront Developer Guide. urls · signers
  • Microsoft, Shared access signatures overview, Create a service SAS. overview · service SAS
  • Berners-Lee, Fielding, Masinter, URI: Generic Syntax, RFC 3986, 2005. rfc
  • MDN, Referrer-Policy. docs
  • nginx, ngx_http_secure_link_module. docs

Used by

Systems (11):

Alternatives and related

hmac request signing · plannedHMAC canonical-request signatures and presigned URLs (SigV4 style) with expiry
Message authentication codes (HMAC, CMAC, Poly1305, KMAC)A receiver gets bytes over a channel an attacker can modify (webhook, cookie, radio frame, download link) and must tell whether a holder of a shared secret produced them unchanged.
constant time code · plannedWriting secret-dependent code without branches or memory lookups on secrets; constant-time compare and select
key rotation · plannedRotating keys without downtime: key ids, overlap windows, re-wrapping, JWKS publication, revocation
capability tokens · plannedUnforgeable, attenuable tokens granting specific rights (macaroons, biscuits, object capabilities)
Capability-based security (object capabilities)A host that runs code it does not fully trust (a plugin, a parser, a driver, a helper service) worries that the code can reach anything its user account can. The step is deciding what a piece of code may touch.JSON Web Token (JWT)A server must accept requests from clients or other services it holds no stored state for, and decide who sent them, for whom they are meant and until when, without a database lookup per request.CDN and edgeUsers far from your servers see slow responses, and traffic spikes or attacks hit the origin directly. This guide answers whether and how to put a CDN or edge compute in front of a service.Object storageBackups, media, logs, data lakes and model weights need cheap, durable storage for large files. This guide answers when object storage is the right home and when a database, block storage or cache is needed instead.HTTP caching semanticsA client, proxy or edge server holds a copy of a fetched resource and must decide, for each new request, whether it may reuse the copy, must ask the origin whether it changed, or must refetch it.Clock synchronizationMachines each keep their own drifting clock, yet timestamps, leases and event orders compared across machines must agree to a known error. Each node must estimate and correct its error against a reference over an unreliable network.Digital signatures (Ed25519)Services, release pipelines and protocols need recipients to verify who produced a message and that it was not altered, without sharing a secret with every verifier (tokens, releases, webhooks).