Skip to content
scroll to zoom · drag to pan

Access and privacy — what a URL means

Every asset has a visibility, and the two values mean genuinely different things. Read the one that applies to you.

public — whoever has the URL has the bytes. private — every public URL answers 404, and the bytes come back only through a signed URL that expires.

public is the default, and it is what almost everything should be.

There is no per-request identity in the delivery path of a public asset. The CDN does not know who you are, does not ask, and cannot be told. A URL is not a pointer to an asset you then get authorised for — the URL is the authorisation.

That is deliberate, and it is what makes delivery cost what it costs: a response that depends on who is asking cannot be cached by URL alone, and public delivery is cached by URL alone. Same trade every URL-addressed media CDN makes. In practice:

  • No credential is checked on delivery. Your runtime key authorises uploads and reads of metadata. It has nothing to do with fetching bytes.
  • Links do not expire.
  • There is no per-asset access log. We can tell you aggregate usage; we cannot tell you who fetched a particular file.

The part that surprises people: the URL is derived from the content

Section titled “The part that surprises people: the URL is derived from the content”

The sha in every URL is the first 16 hex characters of the SHA-256 of the uploaded bytes. Not a secret — but the consequence is worth stating outright:

Terminal window
# no key, no cookie, no header of any kind
$ curl -sO https://8ok.uk/d/v/818105b8e773f53c-o.jpg
$ shasum -a 256 818105b8e773f53c-o.jpg
818105b8e773f53cd0e0063c311dc100aeedbc7be1989cdbb6e96c2d13945c71
└──────┬───────┘
the sha in the URL

So a public URL is not merely hard to guess: it is computable by anyone who already holds a copy of the same file. Given a candidate document, a third party can ask whether you host it and get a yes or no, with no credential.

For listing photographs, a product catalogue or a restaurant menu this is irrelevant — those are meant to be seen. For a signed contract, an ID scan or a medical record, the existence of the file is itself the secret. That is what private is for.

Set visibility: "private" and every public door closes — the stored variants, the raw original, the HLS ladder, and the on-the-fly /t/ transform route. All of them answer 404, to anyone, with the sha and nothing else.

Terminal window
curl -X PATCH https://api.nitida.gofuture.space/assets/<id>/visibility \
-H "Authorization: Bearer $NITIDA_API_KEY" \
-H "X-Tenant-Code: your-tenant" \
-H "Content-Type: application/json" \
-d '{"visibility":"private"}'

Nothing moves. One row changes, the edge cache for that asset is purged, and a 1 GB video flips as fast as a thumbnail — it is the same single copy behind both doors.

Getting the bytes back: a signed URL your backend mints

Section titled “Getting the bytes back: a signed URL your backend mints”
import { getPrivateAssetUrl } from "@nitida/sdk";
// On YOUR server, once you have decided this viewer may see it:
const url = await getPrivateAssetUrl(asset, "lg", tenantSigningKey, {
expiresInSeconds: 300,
});
// → https://8ok.uk/a/5/v/<sha>-l.webp?exp=…&sig=…

The division of labour is the whole design: your backend knows who the viewer is and decides; we verify the signature and serve. We never learn who your end user is, and we do not want to.

  • The signed tree is /a/{tenant}/…, mirroring the public paths.

  • It covers stored variants, the raw original, the whole HLS ladder — the master playlist’s children are re-signed on the way out, so a player just works — and on-the-fly transforms, via getPrivateTransformUrl:

    const url = await getPrivateTransformUrl(
    asset,
    { width: 1280, format: "webp" },
    tenantSigningKey,
    { expiresInSeconds: 300 },
    );

    A private asset servable only at the sizes somebody already generated would barely be a product, so the private tree resizes too. The signature covers the transform itself, so one signed URL authorises one rendition — not every width.

  • exp is mandatory. getPrivateAssetUrl refuses to build a URL without one. A signed URL that never expires is a public URL the moment somebody forwards it.

  • Signed responses are Cache-Control: private. They are not shared-cached, so they cost more per request than public ones. That is the trade.

The SDK refuses rather than hand you a doomed URL

Section titled “The SDK refuses rather than hand you a doomed URL”
getAssetUrl(privateAsset, "lg");
// Error: getAssetUrl: this asset is private, so a public CDN URL for it will
// answer 404 — that is the feature, not a missing file. Mint a signed URL on
// your BACKEND instead: await getPrivateAssetUrl(...)

Every public URL builder does this — getAssetUrl, getAssetSrcSet, getTransformUrl, getTransformSrcSet, getVideoTransformUrl, getHlsStreamingUrl — whenever the value you pass carries visibility. Pass only { sha } and there is nothing to check, so nothing is refused.

Flipping back to private is revocation: URLs handed out while the asset was public stop working. The edge cache is purged on the flip, so this is real and not merely a database opinion.

The caveat, stated rather than buried: propagation is not instantaneous. The CDN refreshes the list of private assets on a 60-second TTL, so a flip can take up to a minute to reach every location. Plan a revocation as “within a minute”, not “this instant”.

One consequence of content-addressed storage

Section titled “One consequence of content-addressed storage”

Storage is deduplicated by content hash. If you mark your copy private but another tenant holds the same bytes and publishes them, the bytes stay reachable — through their public URL, because they published them, not through any door of yours. This is inherent to deduplicating by hash and every platform that does it shares it. If the existence of a file is the secret and the file is one somebody else might also hold, that is worth knowing before you upload.

Some transform URLs carry a ?sig= that has nothing to do with access:

?sig= on /t/… authorises an image width outside the standard ladder. No expiry, no access meaning. Treat such a URL as publicly as an unsigned one
?exp=…&sig=… on /a/… authorises access, until exp

They come from the same tenant signing key but not the same key material — the access signature is computed under a separately derived key, so a signature minted to resize can never be replayed as one to enter.

This is the access rule that costs money if you get it wrong. An amk_rt_* runtime key is a write credential for a paid platform; shipping it in a browser bundle hands a stranger your upload quota.

Uploads from a browser or a phone go one of two ways, and neither ships the key: the browser posts to your route and your route calls us, or your server asks us for a presigned URL scoped to that one upload and hands the browser only that. @nitida/sdk/web exports a client whose type rejects apiKey, so the wrong shape does not compile. See Credentials and Browser-direct upload.

We will not become an identity provider. We will never know who your end user is. Your backend already does, and that is where the decision belongs — you decide yes or no and mint a signed URL; we check the signature and hand over the bytes.