Security & content
This page covers what FluidCloud does to keep your tenant's data private and your raw links safe to publish: automatic scanning of every upload, strict tenant isolation, and a hardened, cookieless serving domain for shared bytes. It also documents two known limitations honestly, so you can design around them.
If you only read one thing: a file must be scanned clean before it can be shared or served (otherwise you get 409), and your data is isolated per tenant (a foreign id returns 404, never a leak).
Every upload is scanned
When you upload a file, FluidCloud automatically scans it before it can be shared or served. The scan result lives on the file as scan_status:
scan_status | Meaning |
|---|---|
pending | Scan not finished yet. The file exists but cannot be shared or served. |
clean | Scanned clean. The file can be shared and served. |
| (other) | The file did not pass scanning and will not be served. |
The bytes never flow through the FluidCloud API: uploads go directly from your client to storage via presigned URLs (see guides/uploading.md). The scan runs after the upload completes, so there is a short window where a freshly uploaded file is pending.
Quarantine-until-clean (409)
Until scan_status is clean, attempts to share or serve the file are refused with 409 Conflict. This applies to creating a share link:
POST /shares -> 409
{ "detail": "File is not shareable yet (scan_status='pending'); must be 'clean'." }This is a feature, not an error to retry blindly: it guarantees that anything reachable through a raw link has been scanned. The right response to a 409 here is to poll the file until scan_status == "clean", then create the share link.
# Poll the file until it is clean, then create the link.
curl -s -H "X-API-Key: $FLUIDCLOUD_API_KEY" \
"https://api-cloud.fluidvip.com/api/v1/files/$FILE_ID"
# -> { ..., "scan_status": "pending" } (wait and retry)
# -> { ..., "scan_status": "clean" } (now safe to share)import time
from fluidcloud import FluidCloud, ConflictError
fc = FluidCloud(api_key="fck_live_...")
def share_when_clean(file_id: str):
while True:
f = fc.files.get(file_id)
if f.scan_status == "clean":
return fc.shares.create(file_id=file_id, permission="view")
if f.scan_status != "pending":
raise RuntimeError(f"file not servable: scan_status={f.scan_status}")
time.sleep(2)import { FluidCloud, ConflictError } from "fluidcloud";
const fc = new FluidCloud({ apiKey: "fck_live_..." });
async function shareWhenClean(fileId: string) {
for (;;) {
const f = await fc.files.get(fileId);
if (f.scan_status === "clean") {
return fc.shares.create({ file_id: fileId, permission: "view" });
}
if (f.scan_status !== "pending") {
throw new Error(`file not servable: scan_status=${f.scan_status}`);
}
await new Promise((r) => setTimeout(r, 2000));
}
}If you prefer to react to the error rather than poll proactively, catch ConflictError (the SDKs' typed mapping of 409) and back off. See errors.md for the full status-to-error mapping.
Tenant isolation
Your API key is scoped to a single tenant. Every endpoint filters every query by your tenant, so a key can only ever address its own spaces, folders, files, and share links.
Foreign ids return 404, not 403. If you reference an id that belongs to another tenant — or that simply does not exist — FluidCloud returns 404 not found, identically. This is deliberate: a 403 would confirm that the id exists somewhere, leaking information across tenant boundaries. By collapsing "not yours" and "doesn't exist" into the same 404, the API never reveals whether another tenant's resource exists.
Practical consequences for your integration:
- Treat
404as "this id is not addressable by my key" — it does not distinguish "deleted", "never existed", or "belongs to someone else". Do not branch on those cases; you cannot observe them. - You never need to (and cannot) pass a tenant id. Your key already determines the tenant.
- Listing endpoints only ever return your tenant's rows.
# A well-formed UUID that isn't in your tenant -> 404, never 403.
curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-API-Key: $FLUIDCLOUD_API_KEY" \
"https://api-cloud.fluidvip.com/api/v1/files/00000000-0000-0000-0000-000000000000"
# 404Raw links are served from a hardened, cookieless domain
Shared file bytes are not served from the API host. They are served from a separate, cookieless domain:
https://files.fluidadmin.com/s/<token>This separation is a security boundary, and the serving layer applies several hardening measures on every response.
Why a separate domain
files.fluidadmin.com shares no session, cookies, or origin with the API (api-cloud.fluidvip.com) or the dashboard (cloud.fluidvip.com). Even if a user uploads and then opens a malicious file, it executes (if at all) in an origin that holds none of your or your users' credentials. There is nothing on that origin to steal.
Headers applied to every served object
The serving layer sets these on every file response:
X-Content-Type-Options: nosniff— the browser must honor the stored content type and will not MIME-sniff a file into something executable.Content-Security-Policy: default-src 'none'; sandbox— this is the same pattern raw-content hosts use. It forces a unique, opaque origin with scripts, forms, and popups disabled and blocks every sub-resource. The effect: an uploaded.htmlor.svgcannot execute script or phish when opened directly through its raw link.Cross-Origin-Resource-Policy: cross-originand permissive CORS (Access-Control-Allow-Origin: *,GET, HEAD, OPTIONSonly, no credentials) — reads are allowed cross-origin so a permanent public link works as an<img>/<video>src. A response CSP does not govern cross-origin embedding, so hotlinking a permanent public link is unaffected by the sandbox above.Content-Dispositionis set safely: filenames are sanitized (CR/LF/quotes stripped) and carried as both an ASCII fallback and a UTF-8 (RFC 5987) form. Expiring links download by default; a permanent public link or aview-permission link is served inline.
You do not configure any of this — it is applied automatically to every shared object. See guides/raw-links.md for how to create and use raw links, and the reference/shares.md endpoint reference.
Known limitations
We document these honestly so you can design around them. Both are current behavior, not promises.
Share-link passwords are not enforced at serve time
When you create a share link you may pass a password. It is accepted and stored hashed at rest — but it is not enforced when the file is served. The serving layer streams the bytes on a valid link token alone and does not prompt for or check a password.
Do not rely on a share-link password as an access control. If a request must be gated by a secret, gate it in your own application before you hand the raw link to anyone. Treat a raw link's token as the only thing standing between a recipient and the bytes.
A revoked permanent link can serve from cache for up to ~1 hour
A permanent public link is served with Cache-Control: public, max-age=3600 so it works efficiently as a hotlinkable asset and caches at the edge. As a result, when you revoke a permanent link, edge caches may continue to serve the already-cached copy until the cached entry expires — up to 3600 seconds (one hour) after revocation.
Two things to know:
- Revocation is immediate at the origin. A revoked link stops being served fresh right away; only an already-cached edge copy can linger.
- Expiring links do not have this caching window — they are not served with a long cache lifetime. If you need revocation to take effect quickly, prefer an expiring link over a permanent public link, and keep its lifetime short.
If a permanent asset may ever need hard, immediate revocation, the robust pattern is to delete the underlying file (or replace it) rather than rely solely on revoking the link, and to assume up to a one-hour tail on any already-distributed permanent URL.
See reference/shares.md for the revoke endpoint and guides/deleting-data.md for deleting the underlying file.
Checklist for integrators
- Wait for
scan_status == "clean"before creating a share link; handle409(ConflictError) as quarantine-until-clean, not a hard failure. - Treat any
404as "not addressable by my key" — never infer existence across tenants. - Treat a raw link's token as the sole gate on the bytes; add your own auth in front if access must be restricted (share-link passwords are not serve-time enforced).
- For content that may need fast revocation, prefer short-lived expiring links; for permanent public assets, assume up to a one-hour cache tail after revocation and delete the file when you need a hard cutoff.
Related
- authentication.md — API keys, the
X-API-Keyheader, and scopes - concepts.md — tenants, spaces, files, and the lifecycle of an upload
- guides/uploading.md — presigned, direct-to-storage uploads
- guides/raw-links.md — creating and using raw file links
- errors.md — full status-code and typed-error reference