Specifications
Signed URL Format
Status: Phase 0 v2 — normative. Implementations of internal/auth
must conform exactly. Drift between this spec and the implementation
is a bug in the implementation.
v2 note. The original revocation scheme (string-prefix match against the canonical signed-URL string) was incorrect: the canonical string starts with the URL path, so prefixes like
cid:{cid}oraud:{aud}could never match. v2 replaces it with structured (kind, value) revocation entries against parsed fields. See § "Revocation".
Purpose
Signed URLs let a Nova operator hand a viewer a time-limited, audience-bound link to a blob without requiring the viewer to hold credentials. They are used for:
- Private collections that are not exposed via anonymous read.
- Embeds that should expire after a session (chat attachments, etc.).
- Any rate-limited "share this for an hour" workflow.
Signed URLs are an alternative to bearer-token authentication. A
request with a valid sig is accepted regardless of whether a
bearer token is present.
Wire format
A signed read URL is the canonical content URL plus four query parameters appended in any order:
{path}?sig={signature}&exp={unix_seconds}&aud={origin}&kid={key_id}
| Param | Type | Description |
|---|---|---|
sig |
base64url-encoded bytes | HMAC-SHA256 of the canonical string. No padding. |
exp |
integer | Unix timestamp in seconds (UTC). After this moment the URL is rejected. |
aud |
string | Origin of the embedding context (e.g., https://example.com). |
kid |
string | Identifier of the signing key in the signing_keys table. |
All four are required. Missing any parameter is a 403 invalid_signature error.
Example
GET /i/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/p/thumb.webp?
exp=1730000000&
aud=https%3A%2F%2Fexample.com&
kid=k_2026_05&
sig=Wf8E3Ui1lP_ZhO3pJxV5T6m9DkB0v6oMZQyqr5QyXtA
Canonical string
The string fed to HMAC-SHA256 is built deterministically as:
canonical = path + "\n" + exp + "\n" + aud + "\n" + kid
pathis the URL path including a leading slash and excluding any query string. It must be percent-decoded back to the canonical bytes the server will see (the server compares against its ownr.URL.Pathvalue, which is already decoded).expis the integer rendered in base 10 with no leading zeros.audis the literal string from the query parameter (not URL-encoded; the value the server reads after URL decoding).kidis the literal key identifier.- The separator is a single ASCII LF byte (
0x0A).
The string is hashed with HMAC-SHA256 using the bytes stored in the
signing_keys.wrapped_key row referenced by kid, after unwrapping
with the operator master key.
The resulting 32 raw bytes are base64url-encoded without padding
to produce the sig parameter.
Worked example
Given:
path = "/i/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/p/thumb.webp"exp = 1730000000aud = "https://example.com"kid = "k_2026_05"
The canonical string is (with \n rendered explicitly):
/i/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/p/thumb.webp
1730000000
https://example.com
k_2026_05
That is exactly four lines, terminated by \n between fields and
no trailing newline.
Verification
Implementation status (M7). The
signing_keysandsigned_url_revocationstables are present from M1; the verifier (internal/auth/signedurl) is implemented in M7 perdocs/superpowers/specs/phase1/2026-06-01-phase1-m7-signed-urls-design.md. Operators mint signed URLs server-side viaPOST /api/v1/admin/signed-urls/sign(the unwrapped HMAC key never leaves the coordinator). The format below is normative for that implementation.
The server performs verification in this order. Any failure short-
circuits with 403 invalid_signature and an appropriate code
field in the JSON error body. No timing differences between failure
modes are observable to the client (constant-time comparison
throughout, and uniform error response time).
- Schema check. All four query parameters present, well-formed.
Failure code:
signature_missing_param. - Key lookup.
signing_keysrow wherekid = ?exists and is eitherstate='active'orstate='retired'withretire_after > now(). Failure code:signature_unknown_kid. - Revocation check. Parse the canonical string into its fields
(cid, aud, kid, path) and check each against
signed_url_revocations: - any row withkind='cid'andvalue={cid}→ revoked - any row withkind='aud'andvalue={aud}→ revoked - any row withkind='kid'andvalue={kid}→ revoked - any row withkind='path_prefix'andvalueis a prefix of{path}→ revoked Failure code:signature_revoked. - Expiry check.
exp > now_unix_seconds(). Servers must use a monotonic, NTP-synced clock; clock skew tolerance is 0 seconds. Failure code:signature_expired. - Signature recomputation. Unwrap the
wrapped_keyfor the referenced kid using the master key version recorded on thesigning_keysrow. Computeexpected = HMAC-SHA256(unwrapped_key, canonical)and compare to the decodedsigbytes with a constant-time comparator (crypto/subtle.ConstantTimeCompare). Failure code:signature_invalid. - Audience check. The request's
Originheader (orRefererifOriginis absent) is parsed for its scheme + host + port, and the resulting origin string must equalaudbyte-for-byte. Failure code:signature_aud_mismatch.
If all six pass, the request is authorized and proceeds to the content handler. The content handler must not re-check authorization; the signed URL is sufficient.
Key rotation
The operator rotates the signing key by calling
POST /api/v1/admin/keys/rotate-signing. The coordinator:
- Generates a new 256-bit secret.
- Inserts a new
signing_keysrow with a freshkid,state = 'active',active_from = now(),master_key_version_id = <current active master version>. - Marks the previous active signing key
state = 'retired'and setsretire_after = now() + grace_window(default 24 h). URLs minted with the previous key remain verifiable untilretire_after. - Returns the new
kidso the caller can update any long-lived signed URLs they cache.
URLs minted between rotation and grace expiry are signed with the
previous key but verified against any non-retired-past-grace key
in the signing_keys table — the verifier looks up the key by
kid, not by "currently default."
After retire_after passes, a scheduled job transitions the
retired row's state = 'shredded' and zeroes the wrapped key.
Revocation
v2 revocation is structured, not prefix-based. The original
prefix-against-canonical-string scheme could not represent the
documented cid: / aud: / kid: revocation forms because the
canonical string starts with the URL path.
POST /api/v1/admin/signed-urls/revoke writes a row into
signed_url_revocations with a (kind, value) tuple. The verifier
parses the canonical signed-URL string into its component fields
and checks each against the revocation table:
kind |
value |
Effect |
|---|---|---|
cid |
a CID | Every signed URL for that CID fails verification |
aud |
an origin (e.g., https://example.com) |
Every URL bound to that origin fails |
kid |
a signing key id | Every URL signed with that key fails (equivalent to instant key shred) |
path_prefix |
a URL path prefix (e.g., /i/bafy.../) |
Every URL whose path starts with this prefix fails |
The schema is enforced by the CHECK (kind IN (...)) constraint in
DATA_MODEL.sql.
Common operations:
- After a takedown: insert
('cid', {cid}). Thecrypto_shred(blob)procedure does this automatically. - After an embedding site compromise: insert
('aud', {origin}). - After a suspected signing-key leak: insert
('kid', {kid})and triggerPOST /api/v1/admin/keys/rotate-signing.
Revocations are immediate and cluster-wide; the verifier loads the revocation table at startup and refreshes it every N seconds (default 30) plus on demand via an internal pubsub message.
What the format does not do
- It does not encrypt the URL. The CID, preset, exp, aud, and kid are all visible to anyone who can see the URL. If the operator also enables blob-level encryption (the default), the bytes served are still ciphertext to anyone who lacks the per-blob key.
- It does not bind the URL to a specific viewer. Anyone holding the URL within the embedding origin can dereference it.
- It does not prevent replay. A leaked URL is valid until
exp. Operators who need stronger guarantees should set short TTLs (minutes, not days) and rely on revocation for incident response. - It does not authenticate write requests. Signed URLs are read-only. Writes require a bearer token.
Compatibility notes
- The format is intentionally similar to AWS S3 v4 query-string
signing, with the simplification that we do not include the
request method (signed URLs are GET-only) and we do not
canonicalize headers (we use a separate
audfield instead). - Implementations may pre-validate a
kidparameter against a short denylist of obviously-revoked keys before reaching the DB, for performance, as long as the canonical six-step flow runs on any kid that survives that pre-check.
Test vectors
Implementations must pass the following vectors. The HMAC key is
the ASCII string test-key-do-not-use-in-production (33 bytes).
| canonical | sig (base64url) |
|---|---|
/blob/bafy/0\n0\n\n |
wCA9mV5qAnke18Afc_IC_w0JGX3GbaCrCtxx6QX6gng |
/i/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/p/thumb.webp\n1730000000\nhttps://example.com\nk_2026_05 |
Z5PRGs6i6Jwykx_rbS3eB1lKKTP_VvtUbknTiW6b2Es |
/blob/bafybeigexamplecidvalue\n2000000000\nhttps://app.example\nk_test |
O93GFW_mH_5EYXYyF91c4-Wu7sSIZCggZ46a0_RrZpY |
(The authoritative fixture is internal/auth/signedurl/testdata/vectors.txt,
generated independently of the implementation; signedurl_test.go's
TestVectors asserts Sign reproduces each row.)
Source:
docs/specs/SIGNED_URL_FORMAT.md