Private media
A private file has no public URL. How visibility works, how an asset token authorizes signing, and how your backend hands a reader a URL that expires in five minutes.
A private file lives in a different bucket - one bound to no public hostname. No URL serves its bytes to the world. The only way to read one is a short-lived signed URL that your backend asks cmssy to mint, for a reader your backend has decided is entitled to it.
That split is the whole design. cmssy decides whether the caller may sign; you decide who the reader is. cmssy never learns your end users, and does not need to.
Making a file private
Upload it as private, or flip an existing file in the media library. The flip copies the object into the other bucket, checks the copy arrived whole, updates the library, and only then removes the old copy - in that order, so a failure anywhere leaves the file readable rather than stranded.
If only the last step fails you get:
The file is now private, but the old public copy could not be removed. Run this again to clear it.Running it again is safe, and is exactly what clears the leftover.
Two things to know before flipping a file that is already on a page:
- The public URL stops working. Anything holding it - a cached page, a bookmark, another site hotlinking - breaks at once.
- Delivery starts returning
url: null. A block that renders its image unconditionally will render a broken one.
What delivery returns
{
"id": "6a6495e44d1ee7dedcae1f52",
"url": null,
"visibility": "private",
"alt": "Quarterly report cover",
"width": 1600,
"height": 900
}Everything except the bytes still comes through: the id, the dimensions, the alt text. That is what makes graceful degradation possible - you know an image is there, you know its shape, and you know you are not entitled to it here.
const src = mediaUrl(content.src);
if (!src) return null;A transform on a private reference does nothing, because there is no URL to transform.
AI page generation never offers a private file to the model either. Its stored address points into a bucket with no public hostname, so offering it would only have the model write a dead URL into a page.
Asset tokens
Signing is authorized by an asset token: a credential that belongs to the workspace rather than to a person, and does exactly one thing - mint signed URLs for that workspace's private files.
It is deliberately not an API token. An API token authenticates a user, and everything that user may do flows from it; handing one to a consumer's server would grant the lot in order to permit one operation.
Create one under Settings → Headless, or over GraphQL:
mutation {
assetToken {
create(name: "storefront", expiresAt: "2027-01-01T00:00:00Z") {
id
prefix
token
}
}
}The token is shown once and never again - cmssy keeps only a hash of it. What you see afterwards is the prefix: csa_ plus seven characters, enough to tell two tokens apart when you decide which to revoke. expiresAt is optional and worth setting.
assetToken { list } returns them with lastUsedAt, so a token nothing has used in months is easy to spot. assetToken { delete(id: "...") } revokes one immediately. Both minting and revoking are written to the audit log, against the person who did it.
Server-side only. In a browser bundle, this token grants signing for every private file in the workspace. It belongs in a server environment variable - never NEXT_PUBLIC_, never a client component, never a build-time inlined constant.
Minting a signed URL
POST https://api.cmssy.io/media/{assetId}/sign
Authorization: Bearer csa_...{
"url": "https://...",
"expiresAt": "2026-08-09T18:35:00.000Z"
}The URL is good for five minutes. Treat it as per-request: mint it when a reader asks for the page, hand it over, let it expire.
A public asset answers the same call with its CDN URL and expiresAt: null, so a consumer never has to branch on visibility before asking.
The failures are worth reading precisely:
401- no token, or the token is invalid or expired.404- no such asset in this token's workspace. A file belonging to someone else answers 404 rather than 403 on purpose: a 403 would confirm the id exists, which would turn the endpoint into a lookup oracle for other tenants.429- 120 signings a minute per token, and 300 a minute per calling address. The address limit is charged before the token is even examined, because validating one costs a bcrypt comparison, and that is not something a stranger should be able to spend on your behalf.
Each signing is metered against the workspace. No plan caps it today; it is counted so that it is visible.
Wiring it up
Put the token in your backend and a route in front of it. The route is where entitlement lives - a member tier, a purchase, a subscription - exactly as it does for everything else you gate:
// app/api/asset/[id]/route.ts
export async function GET(request: Request, { params }) {
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const signed = await fetch(`${process.env.CMSSY_API_HOST}/media/${id}/sign`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.CMSSY_ASSET_TOKEN}` },
});
if (!signed.ok) return new Response("Not found", { status: 404 });
const { url } = await signed.json();
return Response.redirect(url, 307);
}Redirect rather than return the URL. A signed link in your HTML sits there for the whole five minutes it stays valid - in a CDN cache, in a shared page source, in someone's view-source.
Note the host: CMSSY_API_URL points at the GraphQL endpoint, while signing sits one level up. Keep the API host in its own variable instead of doing string surgery on the other one.
Next steps
- Media - the library, the media field, transformations and deletion.
- Block schema & field types -
fields.mediaamong the rest.