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
- Issuer builds params: caller constraints plus
exp = now + ttlandkid = current. - 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
%2Fstays distinct from/. sig = HMAC-SHA256(key[kid], domain-tag || canonical), hex; append as&sig=.- Verifier splits off
sig, rejects duplicate reserved names, looks upkid(unknown means reject), recomputes the canonical form from what it received. - Compare with
verify_slice(constant time). Only then trustexp: reject ifnow > exp + skew. - Check what the MAC cannot (real client IP against signed
ip). - 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,
kidrotation, 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,
kidin 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-referreron 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-originsends 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
CloudFront-Policy, -Signature, -Key-Pair-Id); no URL rewriting.In production
- Media and file sharing: CloudFront (canned
Expires/Signature/Key-Pair-Id; customPolicywith start, IP range, wildcards; RSA 2048 or ECDSA 256 keys in trusted key groups), S3 SigV4 (X-Amz-Expires1-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_linkchecks an MD5 over a chosen string plus expiry.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| object_store | Rust | apache/arrow-rs-object-store | cargo add object_store --features aws,gcp,azure | — | — | — | — | Signer::signed_url(method, path, expires_in); S3, GCS, Azure |
| aws-sigv4 | Rust | smithy-lang/smithy-rs | cargo add aws-sigv4 --features sign-http | — | — | — | — | SignatureLocation::QueryParams gives presigned URLs |
| google-cloud-storage | Rust | googleapis/google-cloud-rust | cargo add google-cloud-storage | — | — | — | — | signed_url module, SignedUrlBuilder, V4 |
| nginx secure_link | C | nginx/nginx | build --with-http_secure_link_module | — | — | — | — | MD5 link check with expiry; optional module |
| itsdangerous | Python | pallets/itsdangerous | pip 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