File hosting
Access rules
Protect hosted files with signed links, your app's sign-in (JWT) or an approval webhook, with recipes for Better Auth, Clerk, Supabase and Firebase.
Hosted files are public by default: anyone with the URL can load them. Access rules let you protect a whole space, a folder or a single file, so the CDN only serves it to the right people. The URLs don't change; the CDN checks each request before sending any bytes, including resized images.
You set this up with templates (how to check a request) and then apply them to paths. Manage both in the dashboard under Hosting → Access rules, or from the API as below.
The four modes#
| Mode | access | How a request gets in | Good for |
|---|---|---|---|
| Public | public | Anyone with the URL. | Assets, marketing images. |
| Signed links | signed | The URL carries ?exp=<unix>&sig=<hex>: an HMAC-SHA256 of <space>/<path>\n<exp> with the template's secret. Your server makes it after its own login check. | Any auth system; short-lived download links. |
| Your app's sign-in | jwt | The user's sign-in token (JWT), sent as Authorization: Bearer, ?token= or a cookie you name, checked against your provider's JWKS, issuer, audience and expiry. Optional who-can-open rules. | Apps on Clerk, Auth0, Supabase, Firebase, Cognito, Better Auth or any JWKS. |
| Approval webhook | webhook | Slingdrop asks your HTTPS endpoint about every request and serves the file only on a 2xx. | Custom logic: paywalls, per-file permissions in your database. |
Every template also has a few shared settings:
namestring, required- Unique per account, up to 60 characters. Usable wherever a template id is (CLI --template, ?template=, file overrides).
maxAgeseconds- How long browsers may cache the file. Protected files are always cached
private. Presets: Public 300, Signed links 300, sign-in 60, webhook 0. allowedOriginsstring[]- Hotlink protection: only pages on these origins (
https://app.example.com,https://*.example.com) may embed the files. Requests without anOriginorReferer(typed URLs, apps, curl) still pass. Up to 20. dispositionauto | inline | attachment- Force downloads with
attachment. HTML, JavaScript and SVG always download. defaultboolean- Make this your account default (see the resolution order below).
You can have up to 20 templates. A template that a space, rule or file still uses can't be deleted.
Which template applies#
For every request the CDN takes the first of these that exists:
The file's own override
Set with
PATCH /hosting/{space}/objects/{path},sd.hosting.setFileAccess(),?template=on upload or--templatein the CLI.The space's first matching path rule
An ordered list of
{ glob, templateId }, up to 50.*matches within a folder,**across folders,?one character, and a pattern without a slash matches the file name anywhere (*.pdf).The space's default template
defaultTemplateIdonPUT /hosting/{space}/access.Your account default
PUT /hosting/default-templateorsd.hosting.templates.setDefault(id). Covers every space, including new ones.Public
When nothing else applies, the file is public, as it is today.
import { Slingdrop } from "@slingdrop/sdk";
const sd = new Slingdrop({ apiKey: process.env.SLINGDROP_API_KEY });
// 1. A template. Signed-link and webhook templates return their secret once: store it now.
const tpl = await sd.hosting.templates.create({ name: "Signed links", access: "signed" });
console.log(tpl.secret); // sdsk_…
// 2. Apply it: everything under private/ in the acme space
await sd.hosting.setAccess("acme", {
rules: [{ glob: "private/**", templateId: tpl.id }],
});
// …or one file, by template id or name (null goes back to the space's rules)
await sd.hosting.setFileAccess("acme", "reports/q3.pdf", "Signed links");
// Which template applies to a path, and why
const access = await sd.hosting.access("acme");# 1. Create a template (the secret is in the response, once)
curl https://api.slingdrop.my/v1/hosting/templates \
-H "Authorization: Bearer $SLINGDROP_API_KEY" -H "content-type: application/json" \
-d '{"name":"Signed links","access":"signed"}'
# 201 → { "data": { "id": "k8f2…", "access": "signed", … }, "secret": "sdsk_…" }
# 2. Space default and ordered path rules (first match wins)
curl -X PUT https://api.slingdrop.my/v1/hosting/acme/access \
-H "Authorization: Bearer $SLINGDROP_API_KEY" -H "content-type: application/json" \
-d '{"defaultTemplateId":null,"rules":[{"glob":"private/**","templateId":"k8f2…"}]}'
# 3. Or one file
curl -X PATCH https://api.slingdrop.my/v1/hosting/acme/objects/reports/q3.pdf \
-H "Authorization: Bearer $SLINGDROP_API_KEY" -H "content-type: application/json" \
-d '{"template":"Signed links"}'
# Which template applies to a path, and why
curl "https://api.slingdrop.my/v1/hosting/acme/access?path=private/report.pdf" \
-H "Authorization: Bearer $SLINGDROP_API_KEY"# Upload straight under a template
npx slingdrop host ./reports --space acme --prefix private --template "Signed links"A path under a protected rule is checked even if no file exists there, and a denied request gets the same answer either way, so nobody can probe which files you have.
Propagation and caching#
- File overrides apply at once. They're read with the file on every request.
- Rules, defaults and template edits take up to 15 seconds to reach every edge location. Plan for a short overlap when you tighten access.
- Public files may already be cached. A public file is served with
public, max-age=300(or a year if it'simmutable), so browsers and shared caches that fetched it before you protected it can keep their copy until it expires. Protect files before you share their URLs, or upload them under a new path. - Protected files are never stored by shared caches. They're sent as
privatewith the template'smaxAge, never longer than a signed link lasts, and sign-in files vary onAuthorizationandCookie. - Sign-in checks aren't cached: every request's token is checked. Webhook answers are reused for
webhook.cacheSeconds(default 60, up to 300) per token (or IP address) and path.
Errors#
A denied request gets a small JSON body, { "error": "…", "code": "…" }, with Cache-Control: no-store. Sign-in denials with 401 also send WWW-Authenticate: Bearer. Error bodies never include values from the token.
| Status | code | When |
|---|---|---|
| 401 | signature_required | Signed links: no exp or sig in the URL. |
| 403 | invalid_signature | Signed links: the signature doesn't match (wrong secret, path or space). |
| 403 | expired | Signed links: exp has passed. |
| 401 | token_required | Sign-in: no token in the header, ?token= or the cookie. |
| 401 | invalid_token | Sign-in: bad signature, unknown key, wrong issuer or audience, or expired (403 if your JWKS can't be reached). |
| 403 | denied_by_rule | Sign-in: a deny rule matched. |
| 403 | not_allowed | Sign-in: there are allow rules and none matched. |
| 403 | outside_path_scope | Sign-in: the file is outside the person's path scope. |
| 401 / 403 | not_approved | Webhook: your endpoint didn't answer 2xx within 2 seconds (401 when the request had no token). |
| 403 | origin_not_allowed | Hotlink protection: the page's origin isn't on allowedOrigins. |
| 403 | forbidden | The template is misconfigured or missing. Access rules fail closed. |
Unrelated to access rules, the CDN can also answer 404 (no such file), 429 (the space used its monthly bandwidth or resizes) and 451 (blocked by safety checks).
Recipe: signed links#
Signed links work with any login system. Your server checks the user, then hands out a URL that stops working after a while. Create the template once (above) and keep its sdsk_… secret in your server's environment. Lost it? POST /hosting/templates/{id}/rotate returns a new one and the old links stop working.
From a Next.js route, with the API#
// app/api/files/[...path]/route.ts — hand out a link after your own login check
import { NextResponse } from "next/server";
import { Slingdrop } from "@slingdrop/sdk";
import { auth } from "@/lib/auth"; // your own session check
const sd = new Slingdrop({ apiKey: process.env.SLINGDROP_API_KEY });
export async function GET(_req: Request, { params }: { params: Promise<{ path: string[] }> }) {
const session = await auth();
if (!session) return new NextResponse("Sign in first", { status: 401 });
const { path } = await params;
const { url } = await sd.hosting.signUrl("acme", `private/${path.join("/")}`, { expiresIn: 600 });
return NextResponse.redirect(url); // or return { url } as JSON
}/hosting/{space}/sign{ path, expiresIn } → { url, signed, expiresAt, template }expiresIn is 60 seconds to 7 days (default 1 hour). A path that resolves to a public template gets its plain URL back with signed: false; one under a sign-in or webhook template returns 400 not_signed.
Without an API call#
Signing locally is faster and has no rate limit. Links you sign yourself can last longer than 7 days.
// No API call: sign with the template's secret (sdsk_…)
import { signHostedUrl } from "@slingdrop/sdk";
const url = await signHostedUrl(
"https://cdn.slingdropcontent.my/acme/private/report.pdf",
process.env.SLINGDROP_SIGNING_SECRET!,
{ expiresIn: 600 }, // seconds, default 3600
);// The same, by hand with node:crypto
import { createHmac } from "node:crypto";
const space = "acme";
const path = "private/report.pdf"; // as stored: not URL-encoded
const exp = Math.floor(Date.now() / 1000) + 600;
const sig = createHmac("sha256", process.env.SLINGDROP_SIGNING_SECRET!)
.update(`${space}/${path}\n${exp}`)
.digest("hex");
const url = `https://cdn.slingdropcontent.my/${space}/${path.split("/").map(encodeURIComponent).join("/")}?exp=${exp}&sig=${sig}`;Recipe: Better Auth#
With Better Auth's jwt plugin, your users' browsers can load protected files directly with their own sign-in token, no signing route needed.
Add the jwt plugin
On the server, add
jwt().definePayloaddecides what's in the token; add the claims your rules will check, likeroleandgroups.Create a sign-in template
Point it at
/api/auth/jwks. The issuer and audience are both your app'sbaseURLunless you changed them. Issuer is required.Send the token
Get it with
authClient.token()(or theset-auth-jwtheader thatgetSessionreturns) and send it asAuthorization: Bearer, or as?token=for<img>and<video>.
// lib/auth.ts — add the jwt plugin to Better Auth
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
// …your database, providers and other plugins
plugins: [
jwt({
jwt: {
// What goes in the token. sub is the user id.
definePayload: ({ user }) => ({
email: user.email,
role: user.role,
groups: user.groups,
}),
},
}),
],
});
// Keys are published at https://<your-app>/api/auth/jwks (EdDSA by default, which Slingdrop supports).// One-off setup with your Slingdrop API key
const tpl = await sd.hosting.templates.create({
name: "Signed-in users",
access: "jwt",
maxAge: 60,
jwt: {
provider: "better-auth",
jwksUrl: "https://app.example.com/api/auth/jwks",
issuer: "https://app.example.com", // Better Auth's baseURL
audience: "https://app.example.com", // also the baseURL, unless you changed it
},
});
await sd.hosting.setAccess("acme", { rules: [{ glob: "users/**", templateId: tpl.id }] });
// Check a real token against it, the way the CDN would (the token isn't stored)
const result = await sd.hosting.templates.test(tpl.id, { token, path: "users/user_123/report.pdf" });
console.log(result.allowed, result.code, result.reason);// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { jwtClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({ plugins: [jwtClient()] });
// Anywhere in the browser: get a token (it lasts 15 minutes) and load the file
const { data } = await authClient.token();
const res = await fetch("https://cdn.slingdropcontent.my/acme/users/" + userId + "/report.pdf", {
headers: { Authorization: `Bearer ${data?.token}` },
});
// <img> and <video> can't send headers: put the token in the URL instead
img.src = `https://cdn.slingdropcontent.my/acme/users/${userId}/avatar.png?token=${data?.token}`;Tokens in URLs
A ?token= URL works for anyone who has it until the token expires (15 minutes by default in Better Auth), and can end up in logs and browser history. Prefer the header for fetch(), keep tokens short-lived, and set maxAge low.
Who can open: claim rules and path scope#
By default a sign-in template lets anyone with a valid token from your app open the files. To narrow that, add rules and a pathScope to the template's jwt settings. They're checked after the token itself.
Rules#
rules is an ordered list of { effect, claim, op, value }:
effectallow | deny- What a match means.
claimstring- A claim name or dot path:
sub,email,role,groups,org.id. opis | one_of | contains | ends_with | existsis: equalsvalue.one_of: equals one of a list.contains: an array claim hasvalue, or a string claim contains it.ends_with: a string ends withvalue(handy foremaildomains).exists: the claim is present (novalue).valuestring | string[]- What to compare with. A list for
one_of.
- Deny always wins. If any deny rule matches, the request is refused (
denied_by_rule). - If there are allow rules, at least one must match (otherwise
not_allowed). - No rules: any valid token is allowed.
Path scope#
pathScope is a glob with placeholders filled in from each person's own token: {sub} or {claim:org.id}. With users/{sub}/**, the user user_123 can only open files under users/user_123/; anything else is outside_path_scope. Use orgs/{claim:org.id}/** for shared team folders. Claim values are inserted as plain text, so a token can never widen the pattern.
Example#
With the Better Auth setup above (definePayload adds role and groups): allow anyone whose groups contains design, deny the user user_z, and keep everyone inside users/{sub}/**.
// Only the design group, never user_z, and each person only inside their own folder
await sd.hosting.templates.update(tpl.id, {
jwt: {
jwksUrl: "https://app.example.com/api/auth/jwks",
issuer: "https://app.example.com",
audience: "https://app.example.com",
rules: [
{ effect: "allow", claim: "groups", op: "contains", value: "design" },
{ effect: "deny", claim: "sub", op: "is", value: "user_z" },
],
pathScope: "users/{sub}/**",
},
});Test a real token before you roll it out: sd.hosting.templates.test(id, { token, path }) answers with allowed, the code the CDN would return and the rule that decided. You can also edit and test rules in the dashboard under Hosting → Access rules.
Clerk, Supabase and Firebase#
Any provider that publishes a JWKS works the same way: create a jwt template with its JWKS URL and issuer, then send the user's token. Supported algorithms are RS256, ES256 and EdDSA (Ed25519). Set provider to label it in the dashboard.
| Provider | JWKS URL | Issuer / audience | Token |
|---|---|---|---|
| Clerk | https://<your-clerk-frontend-api>/.well-known/jwks.json | https://<your-clerk-frontend-api> / none | await getToken() |
| Supabase | https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json | https://<project-ref>.supabase.co/auth/v1 / authenticated | session.access_token. Needs asymmetric JWT signing keys; legacy HS256 secrets can't be checked. |
| Firebase | https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com | https://securetoken.google.com/<project-id> / <project-id> | await user.getIdToken() |
Auth0 and Amazon Cognito presets are in the dashboard too. Keep the issuer exact: Firebase signs every project's tokens with the same keys, so the issuer is what ties a token to your app.
Recipe: approval webhook#
With an approval webhook, Slingdrop asks your server about each request. Create a template with access: "webhook" and webhook: { url, cacheSeconds }; the URL must be HTTPS on a public host. The response includes a whsec_… secret, once.
For each request Slingdrop sends POST to your URL with this JSON body. token is the caller's Authorization: Bearer token or ?token= value, and is left out when there isn't one:
{
"space": "acme",
"path": "private/report.pdf",
"ip": "203.0.113.7",
"userAgent": "Mozilla/5.0 …",
"referer": "https://app.example.com/reports",
"token": "eyJhbGciOi…"
}The request is signed with Slingdrop-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of <t>.<raw body> with the template's secret, the same scheme as event webhooks. Answer any 2xx within 2 seconds to serve the file. Anything else, a redirect, a timeout or an error, denies it.
import express from "express";
import { verifyWebhook } from "@slingdrop/sdk";
const app = express();
// Keep the raw body: the signature covers the exact bytes Slingdrop sent
app.post("/slingdrop/access", express.raw({ type: "application/json" }), async (req, res) => {
const body = req.body.toString("utf8");
const ok = await verifyWebhook(process.env.SLINGDROP_ACCESS_SECRET!, req.get("slingdrop-signature") ?? null, body);
if (!ok) return res.sendStatus(401);
const { space, path, ip, userAgent, referer, token } = JSON.parse(body);
const allowed = await canOpen(token, space, path); // your own decision
res.sendStatus(allowed ? 204 : 403); // any 2xx serves the file; anything else denies
});
app.listen(3000);import { Hono } from "hono";
import { verifyWebhook } from "@slingdrop/sdk";
const app = new Hono();
app.post("/slingdrop/access", async (c) => {
const body = await c.req.text(); // the raw body, before any JSON parsing
const ok = await verifyWebhook(process.env.SLINGDROP_ACCESS_SECRET!, c.req.header("slingdrop-signature") ?? null, body);
if (!ok) return c.body(null, 401);
const { space, path, token } = JSON.parse(body) as { space: string; path: string; ip: string; userAgent: string | null; referer: string | null; token?: string };
return c.body(null, (await canOpen(token, space, path)) ? 204 : 403);
});
export default app;// What verifyWebhook does, if you'd rather not add the SDK
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret: string, header: string | null, body: string, toleranceSec = 300) {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Your endpoint is on the path of every uncached request, so keep it fast. Use cacheSeconds (default 60, up to 300) to reuse an answer for the same token (or IP address) and path, and rotate the secret with POST /hosting/templates/{id}/rotate if it leaks.
Endpoints#
/hosting/templatesYour templates, and the presets/hosting/templatesCreate a template (secret returned once)/hosting/templates/{id}Change a template/hosting/templates/{id}Delete an unused template/hosting/templates/{id}/testTest a token against a sign-in template/hosting/templates/{id}/rotateNew secret for a signed or webhook template/hosting/default-templateSet the account default (null for Public)/hosting/{space}/accessA space's default and rules; add ?path= to see what applies/hosting/{space}/accessSet a space's default and rules/hosting/{space}/objects/{path}Set or clear one file's template/hosting/{space}/signMake a signed linkRequest and response schemas for each are in the API reference.