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.
Your credentials
From the Developer Portal. The secret is used here to compute the signature and is sent nowhere.
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.
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.
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.
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.
The file appears here once step 1 has your credentials.
Or run all four checks at once
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.
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.
Delete with a policy that omits remove
The same credentials, the same handle, the same DELETE request. Only the permission list is short.
Grant remove and send it again
Nothing about the request changes except the policy it carries and the signature over that policy.
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.
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.
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.
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.
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.
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.
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.