Skip to content
Slingdrop

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#

ModeaccessHow a request gets inGood for
PublicpublicAnyone with the URL.Assets, marketing images.
Signed linkssignedThe 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-injwtThe 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 webhookwebhookSlingdrop 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 an Origin or Referer (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:

  1. The file's own override

    Set with PATCH /hosting/{space}/objects/{path}, sd.hosting.setFileAccess(), ?template= on upload or --template in the CLI.

  2. 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).

  3. The space's default template

    defaultTemplateId on PUT /hosting/{space}/access.

  4. Your account default

    PUT /hosting/default-template or sd.hosting.templates.setDefault(id). Covers every space, including new ones.

  5. Public

    When nothing else applies, the file is public, as it is today.

setup.ts
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");

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's immutable), 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 private with the template's maxAge, never longer than a signed link lasts, and sign-in files vary on Authorization and Cookie.
  • 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.

StatuscodeWhen
401signature_requiredSigned links: no exp or sig in the URL.
403invalid_signatureSigned links: the signature doesn't match (wrong secret, path or space).
403expiredSigned links: exp has passed.
401token_requiredSign-in: no token in the header, ?token= or the cookie.
401invalid_tokenSign-in: bad signature, unknown key, wrong issuer or audience, or expired (403 if your JWKS can't be reached).
403denied_by_ruleSign-in: a deny rule matched.
403not_allowedSign-in: there are allow rules and none matched.
403outside_path_scopeSign-in: the file is outside the person's path scope.
401 / 403not_approvedWebhook: your endpoint didn't answer 2xx within 2 seconds (401 when the request had no token).
403origin_not_allowedHotlink protection: the page's origin isn't on allowedOrigins.
403forbiddenThe 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).

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#

route.ts
// 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
}
POST/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.

sign.ts
// 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
);

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.

  1. Add the jwt plugin

    On the server, add jwt(). definePayload decides what's in the token; add the claims your rules will check, like role and groups.

  2. Create a sign-in template

    Point it at /api/auth/jwks. The issuer and audience are both your app's baseURL unless you changed them. Issuer is required.

  3. Send the token

    Get it with authClient.token() (or the set-auth-jwt header that getSession returns) and send it as Authorization: Bearer, or as ?token= for <img> and <video>.

lib/auth.ts
// 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).
setup.ts
// 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);
client.ts
// 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 | exists
is: equals value. one_of: equals one of a list. contains: an array claim has value, or a string claim contains it. ends_with: a string ends with value (handy for email domains). exists: the claim is present (no value).
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}/**.

rules.ts
// 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.

ProviderJWKS URLIssuer / audienceToken
Clerkhttps://<your-clerk-frontend-api>/.well-known/jwks.jsonhttps://<your-clerk-frontend-api> / noneawait getToken()
Supabasehttps://<project-ref>.supabase.co/auth/v1/.well-known/jwks.jsonhttps://<project-ref>.supabase.co/auth/v1 / authenticatedsession.access_token. Needs asymmetric JWT signing keys; legacy HS256 secrets can't be checked.
Firebasehttps://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.comhttps://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:

POST your-webhook-url
{
  "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.

server.ts
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);

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#

GET/hosting/templatesYour templates, and the presets
POST/hosting/templatesCreate a template (secret returned once)
PATCH/hosting/templates/{id}Change a template
DELETE/hosting/templates/{id}Delete an unused template
POST/hosting/templates/{id}/testTest a token against a sign-in template
POST/hosting/templates/{id}/rotateNew secret for a signed or webhook template
PUT/hosting/default-templateSet the account default (null for Public)
GET/hosting/{space}/accessA space's default and rules; add ?path= to see what applies
PUT/hosting/{space}/accessSet a space's default and rules
PATCH/hosting/{space}/objects/{path}Set or clear one file's template
POST/hosting/{space}/signMake a signed link

Request and response schemas for each are in the API reference.