Lesson 07 / 14
Access Control and Signed URLs
Measuring time-limited, narrow access: generating a presigned URL with a fictional key and actually verifying its lifetime, method, and scope checks; scanning link lifetime to compare regeneration load against the leak window, counting the surface scope width opens, and showing a presigned URL's unrecoverability through its cancellation blast radius.
Published Updated
In this lesson
- Generates a presigned URL and actually runs four checks: signature, lifetime, method, and scope
- Scans link lifetime and counts regeneration load together with the open leak window
- Works out the object count and egress cost that scope width opens on a single leaked link
- Measures a presigned URL's unrevocability through the blast radius of rotating the key
Contents
Six lessons measured where data goes. None of them asked a single question: who can read this data. In the fictional measurement network, field teams open photos from their phones, auditors request invoice documents from outside, and neither group has bucket access. Granting an identity opens a permanent door; what is usually wanted, though, is a time-limited and narrow door — this object, for this long, read only.
A presigned URL offers this. A link signed with a key proves that the fields it carries have not been changed; the store verifies the link and hands over the object. Whoever holds the link has no account, no identity, no permission — the link itself is the permission. This puts its power and its danger in the same sentence: wherever the link goes, the permission goes with it.
- ST37 — The signing key is fictional, not a real secret; the clock is fixed so the output stays the same run to run.
- ST38 — The canonical string consists of the method, scope, resource, and expiry fields; the signature is this string’s keyed digest.
- ST39 — Verification runs four checks: signature, clock, method, and scope.
// signature/sign.mjs — a presigned URL is generated and lifetime and scope are ACTUALLY verified. // The key is fictional, not a real secret; the clock is fixed so the output stays the same run to run. import { createHmac, timingSafeEqual } from "node:crypto"; const KEY = Buffer.from("fictional-signing-key-example-invalid"); // no real secret is ever written const NOW = 1_774_000_000; // fixed clock (seconds); Date.now() is not used const canonical = (method, scope, resource, expiry) => [method, scope, resource, expiry].join("\n"); const sign = (method, scope, resource, lifetimeSec) => { const expiry = NOW + lifetimeSec; const signature = createHmac("sha256", KEY).update(canonical(method, scope, resource, expiry)).digest("hex"); return { method, scope, resource, expiry, signature }; }; // Verification: does the signature hold, has time expired, is the requested key in scope, is the method the same. const verify = (link, requestedMethod, requestedKey, time) => { const expected = createHmac("sha256", KEY).update(canonical(link.method, link.scope, link.resource, link.expiry)).digest("hex"); const expectedBuf = Buffer.from(expected, "hex"), actualBuf = Buffer.from(link.signature, "hex"); if (expectedBuf.length !== actualBuf.length || !timingSafeEqual(expectedBuf, actualBuf)) return "signature mismatch"; if (time > link.expiry) return `expired ${time - link.expiry} s ago`; if (link.method !== requestedMethod) return "wrong method"; if (!requestedKey.startsWith(link.scope)) return "out of scope"; return "valid"; }; const PREFIX = "field/2026/03/14/"; const OBJECT = PREFIX + "photo-00412.jpg"; const link = sign("read", OBJECT, OBJECT, 900); // single object, 15-minute lifetime const prefixLink = sign("read", PREFIX, OBJECT, 900); // one day's worth of photos const TRIAL = [ ["correct key, within lifetime", link, "read", OBJECT, NOW + 300], ["after expiry", link, "read", OBJECT, NOW + 1200], ["different object requested", link, "read", PREFIX + "photo-00413.jpg", NOW + 300], ["write with a read link", link, "write", OBJECT, NOW + 300], ["last character of signature changed", { ...link, signature: link.signature.slice(0, -1) + (link.signature.endsWith("a") ? "b" : "a") }, "read", OBJECT, NOW + 300], ["prefix scope, within scope", prefixLink, "read", PREFIX + "photo-09999.jpg", NOW + 300], ["prefix scope, outside scope", prefixLink, "read", "field/2026/03/15/photo-00001.jpg", NOW + 300], ]; console.log(`signed with a fictional key; fixed clock ${NOW}. Signature length ${link.signature.length} hex digits.`); console.log(`single-object link: scope ${link.scope}, expiry ${link.expiry} (${link.expiry - NOW} s lifetime)`); console.log(`prefix link : scope ${prefixLink.scope}`); console.log("\ntrial | requested key | result"); console.log("--------------------------------------|-----------------------------------|---------------------"); for (const [name, l, method, key, time] of TRIAL) console.log(`${name.padEnd(37)} | ${key.padEnd(33)} | ${verify(l, method, key, time)}`); console.log("\nrejection reasons are distinct: one from the signature, one from the clock, two from the fields written into the canonical string."); console.log("The signature only says the link was not modified; how long it lives and what it opens is said by"); console.log("the scope and expiry fields — both are inside the signature, and cannot be changed from outside.");
signed with a fictional key; fixed clock 1774000000. Signature length 64 hex digits. single-object link: scope field/2026/03/14/photo-00412.jpg, expiry 1774000900 (900 s lifetime) prefix link : scope field/2026/03/14/ trial | requested key | result --------------------------------------|-----------------------------------|--------------------- correct key, within lifetime | field/2026/03/14/photo-00412.jpg | valid after expiry | field/2026/03/14/photo-00412.jpg | expired 300 s ago different object requested | field/2026/03/14/photo-00413.jpg | out of scope write with a read link | field/2026/03/14/photo-00412.jpg | wrong method last character of signature changed | field/2026/03/14/photo-00412.jpg | signature mismatch prefix scope, within scope | field/2026/03/14/photo-09999.jpg | valid prefix scope, outside scope | field/2026/03/15/photo-00001.jpg | out of scope rejection reasons are distinct: one from the signature, one from the clock, two from the fields written into the canonical string. The signature only says the link was not modified; how long it lives and what it opens is said by the scope and expiry fields — both are inside the signature, and cannot be changed from outside.
Seven trials show the four checks one by one. The fifteen-minute link is valid at the three-hundredth second, and at the twelve-hundredth second it says “expired 300 s ago” — expiry is not a suggestion, it is part of verification. Requesting a neighboring object with the same link trips the scope check; attempting a write with a read link trips the method check. Changing the signature’s last digit stops all four at the signature check.
The distinction matters. The signature only says one thing: the link has not been modified since it was generated. What says how long the link lives and what it opens is not the signature but the scope and expiry fields written into the canonical string. Because these fields are inside the signature, they cannot be changed from outside; but whoever writes their content at generation time is the one who draws the boundary of access. Generating a link is an act of delegating permission, and it comes down to two numbers: lifetime and scope.
Lifetime and Scope
- ST40 — The field application’s workload is a model: 12,000 sessions a day, a session is 1,500 seconds, the screen refreshes eighteen times, forty photos per screen.
- ST41 — The leak rate is fictional: one in 200,000 generated links leaks into a log, an address bar, or a share. Generating one signature costs 0.012 ms of CPU time in the model.
// signature/lifetime.mjs — a link's lifetime and scope are scanned: regeneration load, leak window, // cancellation blast radius. Workload and rates are a MODEL, no randomness. const SESSIONS = 12_000, SESSION_SEC = 1500, REFRESHES = 18, SCREEN_PHOTOS = 40; const LEAK_RATE = 1 / 200_000; // the leaking share of generated links (fictional) const SIGN_MS = 0.012; // a signature generation's CPU time in the model const SCOPE = { // the surface a single leaked link opens "single object": { objects: 1, gib: 0.0035 }, "one day's prefix": { objects: 3_500, gib: 12.3 }, "whole bucket": { objects: 1_678_000, gib: 184.8 }, }; const EGRESS_INTERNET = 0.085; // unit/GiB, fictional const LIFETIME = [60, 300, 1800, 7200, 86_400, 604_800]; const g = (x, n, d = 2) => x.toFixed(d).padStart(n); const s = (x, n) => String(x).padStart(n); const produced = (ttl) => SESSIONS * SCREEN_PHOTOS * Math.min(REFRESHES, Math.ceil(SESSION_SEC / ttl)); console.log(`${SESSIONS} sessions/day, session ${SESSION_SEC} s, screen refreshes ${REFRESHES} times, ` + `${SCREEN_PHOTOS} photos on screen. Leak rate 1/${(1 / LEAK_RATE).toLocaleString("en-US")} (fictional).`); console.log("\nlifetime | links produced/day | CPU s/day | leaked/day | open object-hr | valid at once"); console.log("----------|-----------------------|----------------|------------|-----------------|------------------"); const PRODUCED = {}; for (const ttl of LIFETIME) { const p = PRODUCED[ttl] = produced(ttl), leaked = p * LEAK_RATE; console.log(`${s(ttl + " s", 9)} | ${s(p.toLocaleString("en-US"), 21)} | ${g(p * SIGN_MS / 1000, 14, 1)} | ` + `${g(leaked, 10, 1)} | ${g(leaked * ttl / 3600, 15, 2)} | ${s(Math.round(p / 86400 * ttl).toLocaleString("en-US"), 17)}`); } const elbow = LIFETIME.find((t) => PRODUCED[t] === PRODUCED[LIFETIME[LIFETIME.length - 1]]); console.log(`elbow at ${elbow} s: past this point production levels off at ${PRODUCED[elbow].toLocaleString("en-US")}/day, ` + `but the open window keeps growing —`); console.log(` at ${elbow} s: ${g(PRODUCED[elbow] * LEAK_RATE * elbow / 3600, 0, 2)} object-hours, ` + `at 604800 s: ${g(PRODUCED[604_800] * LEAK_RATE * 604_800 / 3600, 0, 2)} object-hours: ` + `${(PRODUCED[604_800] * 604_800 / (PRODUCED[elbow] * elbow)).toFixed(0)}x. A longer lifetime no longer gains anything.`); console.log("\nscope width — if a single link leaks:"); console.log("scope | objects opened | GiB opened | egress if all downloaded"); console.log("------------------|----------------|------------|--------------------------"); for (const [name, sc] of Object.entries(SCOPE)) console.log(`${name.padEnd(17)} | ${s(sc.objects.toLocaleString("en-US"), 14)} | ${g(sc.gib, 10, 3)} | ` + `${g(sc.gib * EGRESS_INTERNET, 22)} units`); console.log(`widening scope from a single object to the whole bucket multiplies the surface a single leak ` + `opens by ${(SCOPE["whole bucket"].objects / SCOPE["single object"].objects).toLocaleString("en-US")}x.`); console.log("\ncancelability: a presigned link cannot be revoked, it stays valid until it expires."); console.log("The only lever is rotating the signing key; that breaks every link valid at that moment, at once:"); for (const ttl of [1800, 86_400, 604_800]) console.log(` lifetime ${s(ttl + " s", 9)} -> rotating the key breaks ${s(Math.round(PRODUCED[ttl] / 86400 * ttl).toLocaleString("en-US"), 9)} ` + `links; that is the price paid for a single leaked link.`); // Both axes together: the surface a leaked link opens is the product of lifetime and scope. console.log("\nlifetime x scope — daily open object-hours (leaked link count x lifetime x objects in scope):"); console.log("lifetime |" + Object.keys(SCOPE).map((a) => a.padStart(17)).join(" |")); console.log("----------|" + Object.keys(SCOPE).map(() => "-".repeat(18)).join("|")); for (const ttl of [300, 1800, 86_400, 604_800]) { const leaked = PRODUCED[ttl] * LEAK_RATE; console.log(`${s(ttl + " s", 9)} |` + Object.values(SCOPE) .map((sc) => g(leaked * ttl / 3600 * sc.objects, 17, 1)).join(" |")); } console.log("A short lifetime and a narrow scope work in the same direction: since each shrinks one factor of the"); console.log("product, their effects do not add, they multiply — that is the gap between 300 s, single object and 604800 s, whole bucket.");
12000 sessions/day, session 1500 s, screen refreshes 18 times, 40 photos on screen. Leak rate 1/200,000 (fictional).
lifetime | links produced/day | CPU s/day | leaked/day | open object-hr | valid at once
----------|-----------------------|----------------|------------|-----------------|------------------
60 s | 8,640,000 | 103.7 | 43.2 | 0.72 | 6,000
300 s | 2,400,000 | 28.8 | 12.0 | 1.00 | 8,333
1800 s | 480,000 | 5.8 | 2.4 | 1.20 | 10,000
7200 s | 480,000 | 5.8 | 2.4 | 4.80 | 40,000
86400 s | 480,000 | 5.8 | 2.4 | 57.60 | 480,000
604800 s | 480,000 | 5.8 | 2.4 | 403.20 | 3,360,000
elbow at 1800 s: past this point production levels off at 480,000/day, but the open window keeps growing —
at 1800 s: 1.20 object-hours, at 604800 s: 403.20 object-hours: 336x. A longer lifetime no longer gains anything.
scope width — if a single link leaks:
scope | objects opened | GiB opened | egress if all downloaded
------------------|----------------|------------|--------------------------
single object | 1 | 0.004 | 0.00 units
one day's prefix | 3,500 | 12.300 | 1.05 units
whole bucket | 1,678,000 | 184.800 | 15.71 units
widening scope from a single object to the whole bucket multiplies the surface a single leak opens by 1,678,000x.
cancelability: a presigned link cannot be revoked, it stays valid until it expires.
The only lever is rotating the signing key; that breaks every link valid at that moment, at once:
lifetime 1800 s -> rotating the key breaks 10,000 links; that is the price paid for a single leaked link.
lifetime 86400 s -> rotating the key breaks 480,000 links; that is the price paid for a single leaked link.
lifetime 604800 s -> rotating the key breaks 3,360,000 links; that is the price paid for a single leaked link.
lifetime x scope — daily open object-hours (leaked link count x lifetime x objects in scope):
lifetime | single object | one day's prefix | whole bucket
----------|------------------|------------------|------------------
300 s | 1.0 | 3500.0 | 1678000.0
1800 s | 1.2 | 4200.0 | 2013600.0
86400 s | 57.6 | 201600.0 | 96652800.0
604800 s | 403.2 | 1411200.0 | 676569600.0
A short lifetime and a narrow scope work in the same direction: since each shrinks one factor of the
product, their effects do not add, they multiply — that is the gap between 300 s, single object and 604800 s, whole bucket.
The lifetime scan balances two columns. At a sixty-second lifetime, 8,640,000 signatures are generated a day; the CPU cost is 103.7 seconds a day and the open window is 0.72 object-hours. As lifetime lengthens, production drops, but only to a floor: past 1,800 seconds, production levels off at 480,000, because a link already covers an entire session. This is the elbow. Past this point, a longer lifetime reduces no load; the open window keeps growing — 1.20 object-hours at 1,800 seconds, 403.20 object-hours at 604,800 seconds, 336 times over. A seven-day link carries three hundred thirty-six times the risk for something a thirty-minute link does not gain at all.
Scope is the second axis, and its scale differs. A single leaked link opens one object at single-object scope; 3,500 objects and 12.3 GiB at one day’s prefix; 1,678,000 objects and 184.8 GiB at the whole bucket — this course’s entire byte count, in a single string. The gap is 1,678,000 times, and generating the link is equally easy in all three. Prefix scope is often chosen for convenience — a single request fills the whole screen — but that convenience costs a leak’s surface thousands of times over.
The last table combines both axes into one number: daily open object-hours, the product of the leaked link count, the lifetime, and the object count in scope. At a five-minute lifetime and single-object scope this is 1.0; at a seven-day lifetime and bucket scope, 676,569,600. Loosening both axes does not add the risk, it multiplies it; tightening both reduces it the same way. So “we kept the link’s lifetime short” is not, by itself, a safeguard — unless scope is also narrowed, only one factor has shrunk.
Unrevocability
- ST42 — In this model a presigned URL cannot be revoked; the only way is rotating the signing key, and that breaks every link valid at that moment, at once. Some providers offer selective revocation by binding the link to a named access policy; this lesson does not use that mechanism.
The last block writes this type’s harshest item. Once a link leaks, nothing can be done: the link stays valid until it expires. There is no identity to suspend, no session to close. The only lever is rotating the signing key, and that lever is not selective. Rotating the key at a thirty-minute lifetime breaks 10,000 links; at a twenty-four-hour lifetime, 480,000; at a seven-day lifetime, 3,360,000. The price of a single leak is breaking every link currently in circulation — and on the user’s side, that means screens come back empty.
This explains why choosing a lifetime is not a security setting: lifetime is also the cancellation cost. A short lifetime shrinks both the leak window and the cancellation blast radius, pulling both columns down at once — which is what the elbow point means: 1,800 seconds is where the regeneration load bottoms out and both risks stay smallest.
The delegated-decision side is narrow here. The provider only performs verification: it recomputes the signature, checks the clock, compares the scope. Who accesses what, for how long, stays entirely with you, and by this course’s measure all three are unrecoverable — lifetime cannot be shortened, scope cannot be narrowed, the link itself cannot be revoked. Egress is plain too: a leaked bucket link allows all 184.8 GiB to be downloaded, incurring a 15.71-unit egress fee, and the bill arrives before the leak is found.
Summary
- A presigned URL passes through four checks: signature, expiry, method, and scope. The signature only says the link has not been modified; the fields in the canonical string say how long it lives and what it opens.
- The lifetime scan has an elbow: past 1,800 seconds, production levels off at 480,000 a day, and lengthening the lifetime reduces no load.
- The open window grows in direct proportion to lifetime: from 1.20 object-hours to 403.20 object-hours, 336 times over. A seven-day link carries this difference for something it does not gain.
- Scope sets the scale of a leak: single object opens 1, one day’s prefix opens 3,500, the whole bucket opens 1,678,000 objects and 184.8 GiB; downloading it all incurs a 15.71-unit egress fee.
- The unrecoverable item is the link itself: it cannot be revoked, it stays valid until it expires. The only lever is rotating the key, and that breaks 10,000 links at a thirty-minute lifetime, 3,360,000 at a seven-day lifetime, all at once.
Next Step
This topic closed two questions: where data goes and who accesses it. The fictional measurement network’s meter readings, invoice documents, field photos, and work-order records are now placed by type, their class chosen, their lifecycle written, and their access granted through time-limited links. One item still stands outside, and has been passing by the edge for six lessons: the queried part of this data. Rate tables, work-order statuses, and invoice line items sit not in object storage but in a database set up by hand on an instance — a scheduled job takes its backup, someone upgrades its version by hand, a single copy runs. The next topic measures renting this layer: when a database is taken as a service, which decision is delegated, which stays, and which of the delegated ones cannot be undone.
To keep your progress and take notes, Log in
My notes
Log in to take notes.