Securing a React Picker With Signed Policies

Posted on | Last updated on
Securing a React Picker With Signed Policies

Securing a React picker with signed policies takes one server endpoint and one prop. The endpoint signs a short-lived statement of what the client may do, the prop hands that statement to the picker, and your app secret never reaches the browser.

The whole implementation is in filestack-snippets, as a small policy server and the picker that calls it.

Key takeaways

  • The API key identifies your app but constrains nothing; a policy adds the rules.
  • expiry is the only mandatory field, and an hour is a sensible default.
  • Sign on the server with the app secret, and never prefix that secret with VITE_ or NEXT_PUBLIC_.
  • Do not render the picker before the policy arrives, or the upload starts unsigned.
  • Policies constrain the upload, not the file’s contents or who can read it afterwards.

What the API key does and does not do

Both packages are needed, since v7 takes filestack-js as a peer dependency:

npm install filestack-react@^7.0.1 filestack-js@^4.0.1

The API key in your React bundle is meant to be there. It names your application so uploads land in the right account, and it is readable by anyone who opens developer tools. That is by design and is not the thing to protect.

What it does not do is constrain anything. On its own, a key that can upload can upload anything, from anywhere, forever. For a prototype that is fine. For an application with real users it means someone who reads your bundle can upload to your account from their own page.

A policy is what turns the key from an identifier into an identifier with rules.

What a policy contains

The full list of fields and what each one restricts is in the Security Policies documentation.

A policy is a small JSON object, base64url encoded, describing what is permitted and until when.

{
  expiry: 1754467200,              // required, seconds since the epoch
  call: ['pick', 'read', 'store'], // which operations are allowed
  maxSize: 10 * 1024 * 1024,       // largest file accepted
}

expiry is the only field that is never optional, and it is the field that does the work. A policy without one never stops being valid, which recreates the problem the policy was supposed to solve. An hour is a reasonable default for an upload session.

call is the list of operations. A picker that only uploads needs pick, read and store. Leave out remove and write, since a policy that permits deletion is a policy someone can use to delete.

Other fields narrow it further. maxSize and minSize bound the file, path restricts where files land, and container limits which storage container is writable.

Signing it on the server

The reasoning behind keeping the secret off the client is set out in the guide to locking down an untrusted client.

The signature is an HMAC-SHA256 of the encoded policy, keyed with your app secret. Node needs no dependencies for this.

// server/policy-server.mjs
import { createHmac } from 'node:crypto';

function signPolicy(policy) {
  const encoded = Buffer.from(JSON.stringify(policy)).toString('base64url');
  const signature = createHmac('sha256', process.env.FILESTACK_APP_SECRET)
    .update(encoded)
    .digest('hex');
  return { policy: encoded, signature };
}

The endpoint that uses it should generate the policy per request rather than returning a stored one, so that every session gets a fresh expiry:

// server/policy-server.mjs, continued
import express from 'express';

const app = express();

app.get('/api/filestack-policy', (request, response) => {
  const oneHourFromNow = Math.floor(Date.now() / 1000) + 60 * 60;
  response.json(
    signPolicy({
      expiry: oneHourFromNow,
      call: ['pick', 'read', 'store'],
      maxSize: 10 * 1024 * 1024,
    }),
  );
});

app.listen(3001);

signPolicy returns { policy, signature }, which is the shape the picker expects under clientOptions.security.

This endpoint is the place to apply your own authorization. It should check the session before signing anything, because an endpoint that hands a policy to anyone who asks has moved the problem rather than solved it. Narrowing path per user is the usual next step, so that one user’s policy cannot write into another’s directory.

The app secret belongs in an environment variable with no client-side prefix. Not VITE_, not NEXT_PUBLIC_. Anything with those prefixes is published in your JavaScript.

Using it in the picker

The client fetches the pair and passes it through clientOptions:

// src/examples/SecurePicker.tsx
import { useEffect, useState } from 'react';
import { PickerOverlay } from 'filestack-react';
import type { Security } from 'filestack-react';

export default function SecurePicker() {
  const [security, setSecurity] = useState<Security | null>(null);

  useEffect(() => {
    fetch('/api/filestack-policy')
      .then((response) => response.json())
      .then(setSecurity);
  }, []);

  if (!security) return <p>Preparing upload</p>;

  return <PickerOverlay clientOptions={{ security }} />;
}

Do not render the picker before the policy arrives. A picker created without security starts an unsigned upload, and the failure arrives as a rejected request partway through rather than as an error at the start.

Setting clientOptions on FilestackProvider instead applies the policy to every picker in the tree, which is usually what you want once more than one screen uploads.

The picker running with a signed policy supplied through clientOptions
The picker running with a signed policy supplied through clientOptions

 

Handling expiry

An hour is comfortable for a normal session and short for a page someone leaves open over lunch. When a policy expires the uploads begin failing, and the fix is to fetch a new one rather than to lengthen the original.

The pattern that works is to re-fetch when the user opens the picker rather than when the page loads, which keeps the window small and makes the common case correct without any expiry tracking. If a page can upload repeatedly over a long session, refresh on an interval shorter than the expiry.

A twelve hour policy sitting in a browser tab is the same exposure as an unsigned key, arriving more slowly.

Join the Filestack developer community on Discord

Domain allow lists

Policies control what can be done. The domain allow list in your Filestack application settings controls where from, and the two work together rather than as alternatives.

Adding your production and staging domains means a policy leaked from your bundle cannot be replayed from an attacker’s page. It is one configuration change, and it applies whether or not the application signs policies yet. The domain whitelisting page covers the setting itself.

Where to put the endpoint

The signing endpoint is ordinary server code and it fits wherever your application already has some. The example repo runs it standalone on its own port so it can be read in isolation, which is not how you would ship it.

In Next.js it is a route handler under app/api/filestack-policy/route.ts, which gives you the session from your existing auth without any extra wiring. In Remix it is a resource route returning JSON from a loader. In a Vite application with a separate backend it belongs in that backend, alongside the other endpoints that already know who the user is.

The one arrangement to avoid is a serverless function with no authentication in front of it, reachable from anywhere, returning a policy to any caller. That is a common shortcut because it is easy to deploy and it hands out upload permission to the internet. If the endpoint cannot identify the caller, the policy it signs cannot be narrower than the unsigned key it replaced.

Testing that the policy is doing something

An upload succeeds whether the policy is tight, loose or ignored entirely. Three checks establish that it is being enforced.

Set expiry to a moment in the past and confirm the upload fails. If it succeeds, the security object is not reaching the picker, which usually means it was passed as a prop the component does not read or the picker rendered before the fetch resolved.

Set maxSize to something small, a hundred kilobytes, and try a larger file. The rejection should arrive from the policy rather than from a client-side check, so remove any maxSize from pickerOptions while testing this.

Then narrow call: ['pick', 'read', 'store'] to call: ['read'] and confirm the upload no longer goes through. That establishes the operation list is being enforced rather than decorative.

Run all three once when you build the endpoint, then restore the working policy.

What this does not cover

Signed policies constrain the upload. They say nothing about what is inside the file.

A policy with maxSize and a mimetype restriction still accepts a file that has been renamed, since the type is asserted by the client. Content-level checking happens after the file lands, and it is a separate step in your own pipeline rather than something the policy can express.

The same applies to the delivered file. Handles are addressable to anyone who has them, so if a document must stay private, the policy is not the mechanism. Signed delivery URLs are.

After the upload

The handle comes back as it always does, and transformations sit in front of it whether or not the upload was signed. Where those bytes live afterwards, and what a storage location should be doing for you, is the subject of storing and delivering user images.

Policies are one layer. For how they sit alongside the rest, the Filestack security guide covers the whole surface.

FAQ

Is it a problem that my API key is visible in the bundle?

No. The key names your application so uploads reach the right account, and it is meant to be readable. What it lacks is any constraint, which is what the signed policy supplies.

How long should a policy last?

About an hour for a normal upload session. Fetch a fresh one when the user opens the picker rather than when the page loads, since a long-lived policy sitting in an open tab is close to having no policy at all.

Why did my upload succeed with an expired policy?

The security object never reached the picker. Either it was passed as a prop the component does not read, or the picker rendered before the fetch resolved and started an unsigned upload.

Do signed policies keep the uploaded file private?

No. They constrain what the client may upload, not who can read the result. A handle is addressable by anyone who has it, so private documents need signed delivery URLs as well.

Read More →