Skip to content

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_statusMeaning
pendingScan not finished yet. The file exists but cannot be shared or served.
cleanScanned 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.

bash
# 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)
python
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)
typescript
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 404 as "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.
bash
# 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"
# 404

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 .html or .svg cannot execute script or phish when opened directly through its raw link.
  • Cross-Origin-Resource-Policy: cross-origin and permissive CORS (Access-Control-Allow-Origin: *, GET, HEAD, OPTIONS only, 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-Disposition is 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 a view-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.

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 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; handle 409 (ConflictError) as quarantine-until-clean, not a hard failure.
  • Treat any 404 as "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.

FluidCloud API — part of the Fluidvip ecosystem.