...

Control Who Opens Every File You Store

Filestack file security replaces a permanent file URL with a link you define: one file, the actions you allow, and a deadline. Everything outside that comes back 403.

Paste your API key and app secret below and this page will tell you whether your security is working.

1 · The policy {"call":["read"],  "expiry":1788195831,  "handle":"6n0zk5oo…"} 2 · Base64url eyJjYWxsIjpbInJlYWQiXS… 3 · App secret server side, never in the URL 4 · HMAC-SHA256 bb664fe661ea178a1027… 5 · The request cdn.filestackcontent.com /security=p:…,s:… /6n0zk5ooS0W4O5INSEDt The CDN recomputes step 4 from the policy it was sent and compares. No match, no file.
Trusted by 100,000+ applications
SendGrid logo with stylized gray text and overlapping square shapes on the left.
LinkedIn logo followed by the word SlideShare in gray text on a light background.
The word teachable is written in all lowercase, sans-serif letters with a colon between teach and able, in a light purple color on a light background.
A gray Airtable logo featuring a geometric cube design to the left of the word Airtable in bold, modern font.

Check Your Own Setup

Paste your credentials, pick what the link may do, watch it get signed, then send it to Filestack and read what comes back. Everything happens in this browser tab and nothing is stored.

1

Your credentials

From the Developer Portal. The secret is used here to compute the signature and is sent nowhere.

Waiting for all three.
2

Choose what the link may do

Each box is one entry in the call array, and the allow list below says what each one permits. Tick and untick them and the policy rewrites itself underneath.

The policy, as JSON
3

Sign it with your secret

The policy is encoded as URL-safe base64, then hashed with your app secret using HMAC-SHA256. Filestack repeats the same hash with its own copy of the secret and compares.

Encoded, and this is what gets hashed

The secret is used twice, once in each pass, and never travels. Change the expiry by one second and about half of the 64 characters change.

The signature
4

Send it and read the answer

This is the finished link. Request it, then break it on purpose and request it again. A 200 means the signature verified and the action was on the list. A 403 means one of those failed, and the body says which.

Break it on purpose
Put it back
Ready.
The file delivered through its signed link

The file appears here once step 1 has your credentials.

Or run all four checks at once

Your key and secret match
Links without a signature are refused
Links you sign are served
Links past their expiry stop working

What a Signed Link Allows

Switch security on and a Filestack link stops working on its own. Every request has to arrive with a policy saying what is permitted, and a signature proving the policy came from you. The policy answers three questions.

Which file

Name a handle and the link opens that one file. Name a path and it covers a folder. Name neither and it covers the whole account, which is almost never what you want in a browser.

Which actions

The call array is an allow list. Reading, transforming, uploading, overwriting and deleting are each a separate entry, and anything you leave out comes back 403.

Until when

expiry is a unix timestamp. Past it the link stops answering, whether or not anyone meant to withdraw it. Nothing needs revoking and nothing needs cleaning up.

Every Action You Can Grant

These are the values that go in the call array. A policy grants exactly what it lists, so a link built for display cannot upload, overwrite or delete no matter what is appended to it.

Value in call What the link may do Worth knowing
read Deliver the stored file A plain URL with no task on it
convert Run any transformation One value covers resize, crop, OCR, tagging and the rest
stat Read the file's metadata read already covers this
pick Open the file picker Pair it with store for uploads
store Upload a new file Narrow it with path and maxSize
write Overwrite an existing file Replaces content behind a handle that is already public
writeUrl Overwrite from a source URL Server side fetch, so it reaches wherever your server reaches
remove Delete the file Permanent, and no reason to hand it to a browser
exif Read EXIF data Location and camera details live here
runWorkflow Trigger a workflow Needs convert alongside it

The pair that covers a browser

For anything a page displays, grant read and convert together. That serves the stored file and every transformation on it, and grants no upload, no overwrite and no delete. Uploading and deleting belong to policies your server signs for itself.

Read Is Not Remove

The call array is a whitelist, so a policy that grants read cannot delete. Adding remove and re-signing is the whole difference between a refusal and a deletion.

1

Delete with a policy that omits remove

The same credentials, the same handle, the same DELETE request. Only the permission list is short.

{"call":["read","stat"],"expiry":…,"handle":"…"}
Not run yet.
2

Grant remove and send it again

Nothing about the request changes except the policy it carries and the signature over that policy.

{"call":["read","stat","remove"],"expiry":…,"handle":"…"}
Not run yet.

This deletes the file. The handle stops resolving and the delete cannot be undone, so point it at a file you uploaded for the purpose. Both steps run against the credentials in the panel above.

Ready.

The Same Flow in Your Language

Build a restrictive policy, watch the delete get refused, add remove, sign again, delete. Four runnable programs, one mechanism.

              main.go · go run .
              
func buildSecurity(p Policy) (security, error) {
    raw, err := json.Marshal(p)
    if err != nil {
        return security{}, fmt.Errorf("marshal policy: %w", err)
    }
    encoded := base64.URLEncoding.EncodeToString(raw)

    // The App Secret is used here and nowhere else. Only the
    // digest it produces goes over the wire.
    mac := hmac.New(sha256.New, []byte(appSecret))
    mac.Write([]byte(encoded))
    sig := hex.EncodeToString(mac.Sum(nil))

    return security{policy: encoded, signature: sig}, nil
}

func main() {
    expiry := time.Now().Add(time.Hour).Unix()

    // Step 1: read and stat only. "remove" is deliberately omitted.
    restricted, _ := buildSecurity(Policy{
        Expiry: expiry, Handle: handle,
        Call: []string{"read", "stat"},
    })
    code, body, _ := deleteFile(restricted)
    fmt.Printf("HTTP %d\n%s\n\n", code, body) // HTTP 403

    // Step 2: same policy plus "remove", re-signed.
    allowed, _ := buildSecurity(Policy{
        Expiry: expiry, Handle: handle,
        Call: []string{"read", "stat", "remove"},
    })
    code, body, _ = deleteFile(allowed)
    fmt.Printf("HTTP %d\n%s\n", code, body) // HTTP 200
}

The full program, with the request helper and the credential handling, is in Fileschool/filestack-snippets. It reads the key, secret and handle from the environment, so exporting the three and running go run . is the whole setup.

              delete.js · node delete.js
              
const crypto = require('crypto');

const { FILESTACK_APP_KEY: KEY, FILESTACK_APP_SECRET: SECRET,
        FILESTACK_HANDLE: HANDLE } = process.env;
const FILE_API = 'https://www.filestackapi.com/api/file';

// The App Secret is used here and nowhere else.
function buildSecurity(policy) {
  const encoded = Buffer.from(JSON.stringify(policy)).toString('base64url');
  const signature = crypto.createHmac('sha256', SECRET)
                          .update(encoded).digest('hex');
  return { policy: encoded, signature };
}

async function deleteFile({ policy, signature }) {
  const url = `${FILE_API}/${HANDLE}?key=${KEY}` +
              `&policy=${policy}&signature=${signature}`;
  const res = await fetch(url, { method: 'DELETE' });
  return { code: res.status, body: await res.text() };
}

(async () => {
  const expiry = Math.floor(Date.now() / 1000) + 3600;

  // Step 1: read and stat only. "remove" is deliberately omitted.
  let r = await deleteFile(buildSecurity(
    { expiry, handle: HANDLE, call: ['read', 'stat'] }));
  console.log('HTTP', r.code, r.body); // HTTP 403

  // Step 2: same policy plus "remove", re-signed.
  r = await deleteFile(buildSecurity(
    { expiry, handle: HANDLE, call: ['read', 'stat', 'remove'] }));
  console.log('HTTP', r.code, r.body); // HTTP 200
})();

Node 18 or later for the built-in fetch and base64url. No dependencies.

              delete.py · python3 delete.py
              
import base64, hashlib, hmac, json, os, time, urllib.request, urllib.error

KEY = os.environ['FILESTACK_APP_KEY']
SECRET = os.environ['FILESTACK_APP_SECRET']
HANDLE = os.environ['FILESTACK_HANDLE']
FILE_API = 'https://www.filestackapi.com/api/file'

def build_security(policy):
    """The App Secret is used here and nowhere else."""
    encoded = base64.urlsafe_b64encode(json.dumps(policy).encode())
    signature = hmac.new(SECRET.encode(), encoded, hashlib.sha256).hexdigest()
    return encoded.decode(), signature

def delete_file(policy, signature):
    url = (f'{FILE_API}/{HANDLE}?key={KEY}'
           f'&policy={policy}&signature={signature}')
    req = urllib.request.Request(url, method='DELETE')
    try:
        with urllib.request.urlopen(req) as r:
            return r.status, r.read().decode()
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode()

expiry = int(time.time()) + 3600

# Step 1: read and stat only. "remove" is deliberately omitted.
print(*delete_file(*build_security(
    {'expiry': expiry, 'handle': HANDLE,
     'call': ['read', 'stat']}))) # 403

# Step 2: same policy plus "remove", re-signed.
print(*delete_file(*build_security(
    {'expiry': expiry, 'handle': HANDLE,
     'call': ['read', 'stat', 'remove']}))) # 200

Standard library only. Python 3.8 or later.

              sign.sh · policy and signature by hand
              
# Step 1: read and stat only. "remove" is deliberately omitted.
POLICY=$(printf '{"call":["read","stat"],"expiry":%s,"handle":"%s"}' \
  "$(( $(date +%s) + 3600 ))" "$FILESTACK_HANDLE" \
  | openssl base64 -A | tr '+/' '-_')

SIGNATURE=$(printf '%s' "$POLICY" \
  | openssl dgst -sha256 -hmac "$FILESTACK_APP_SECRET" -hex \
  | sed 's/^.* //')

URL="https://www.filestackapi.com/api/file/$FILESTACK_HANDLE"
URL="$URL?key=$FILESTACK_APP_KEY&policy=$POLICY&signature=$SIGNATURE"

curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$URL"
# 403

# Step 2: rebuild POLICY with "remove" in the call array, re-sign,
# and send the identical request.
# 200

Standard library only. Python 3.8 or later.

              sign.sh · policy and signature by hand
              
# Step 1: read and stat only. "remove" is deliberately omitted.
POLICY=$(printf '{"call":["read","stat"],"expiry":%s,"handle":"%s"}' \
  "$(( $(date +%s) + 3600 ))" "$FILESTACK_HANDLE" \
  | openssl base64 -A | tr '+/' '-_')

SIGNATURE=$(printf '%s' "$POLICY" \
  | openssl dgst -sha256 -hmac "$FILESTACK_APP_SECRET" -hex \
  | sed 's/^.* //')

curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
  "https://www.filestackapi.com/api/file/$FILESTACK_HANDLE"`
`"?key=$FILESTACK_APP_KEY&policy=$POLICY&signature=$SIGNATURE"
# 403

# Step 2: rebuild POLICY with "remove" in the call array, re-sign,
# send the identical request.
# 200

The signature is a hex HMAC-SHA256 over the base64 policy string, not over the JSON.

The secret belongs on the server

Every program above reads the secret from the environment and runs outside the browser. A secret shipped to a client can be read out of the bundle, and whoever reads it can mint any policy they like, including one that grants remove on every handle in the account. Sign on the server, return the policy and signature pair, and let the client hold only that.

How Long Should a Signed URL Live

The expiry has to outlive the use, not the request. A page left open, a video played to the end, a download that pauses and resumes: the link has to still work at the end of all of those.

Seconds to minutes

An avatar on a page the user is already looking at, a thumbnail in a list, a signed upload the client is about to perform. The link is used the moment it is issued and nothing is served from it afterwards.

Tens of minutes

A document opened in a viewer, a video streamed to the end, a large file downloaded over a slow connection. The session outlives the click, so the expiry has to cover the session.

Hours

A link mailed to someone who will open it when they get to it, an export a batch job collects on a schedule. Long enough to be found, short enough that a forwarded mail stops working.

Days, rarely

A URL embedded in something you cannot reissue: a PDF already sent, a webhook payload already delivered. Reach for a redirect through your own server before reaching for a long expiry.

Expiry is not revocation

A signed URL runs out on its own and nothing is consumed when it does. The handle stays valid the whole time, so reissuing is one HMAC over a policy with a later timestamp, not a new upload and not a new file. That is what the Sign a fresh policy for this handle button does in the live demo above: same handle, new expiry, new signature, 200 again.

Frequently Asked Questions

Why do a bad signature and an expired policy both return 403?

Both are refusals to authorize the request, so both carry the same status. The body differs: a signature that does not verify reports that credentials were missing or did not match, and a policy past its expiry reports that the signature has expired. Read the body rather than the status when you are working out which one you have.

Does a policy that grants read let a client delete the file?

No. The call array is a whitelist, so a policy listing read and stat authorizes read and stat and nothing else. A delete sent with that policy returns 403. Deletion needs remove in the call array and a signature over that policy.

Is it safe to publish the policy and the signature in a URL?

Yes, and there is no way to send them other than in the open. The policy is base64 that anyone can decode, and the signature is a one-way digest that reveals nothing about the app secret that produced it. What matters is that the pair only authorizes what the policy says, until the expiry it names.

Do I need a new signature for every file a user views?

Only if you pin the policy to a handle. A policy that names a handle authorizes that one file, which is the tightest useful scope. A policy without a handle covers every file on the account for the calls it grants, so it is the wrong thing to hand to a browser. Sign per file, or per path prefix when the files share one.

What happens to a signed URL after the file is deleted?

The signature still verifies and the handle no longer resolves, so the request fails on the file rather than on the policy. Deletion is permanent, which is why the remove demo on this page asks you to point it at a file uploaded for the purpose.