September 16, 2026

Signed video URLs in a React app

Saripella Narendra Varma
Saripella Narendra Varma
Software Engineer

A signed playback URL is the manifest URL with a JSON Web Token in the query string. That token has to be signed with a private key. So the first attempt at private video in a React app usually reaches for an environment variable. Vite then inlines anything prefixed with VITE_ into the bundle, and the signing key is published to everyone who opens devtools. The mistake isn't the prefix. It's that signing is a server operation, and a Vite app has no server.

TL;DR

Access policy is a property of the media, not of the link: a private playback ID refuses to play without a valid JWT. That token is signed with a private key, so it's minted on a server per request and never in the browser. A VITE_ prefix would publish the key to every visitor. Keep expiry short, because a token that outlives the session is a shareable link. In React the flow is a fetch to your own endpoint for a fresh token, then the token attribute on the player. DRM is separate and needs drm-token as well.

What a signed URL actually does

Access policy is a property of the media, not of the link. A public playback ID plays for anyone who has it. A private one refuses to play without a valid token, so the same URL shape behaves completely differently depending on how the media was created.

text
Public:   https://stream.fastpix.com/{playbackId}.m3u8
Private:  https://stream.fastpix.com/{playbackId}.m3u8?token={JWT}

The token carries claims rather than a session. aud scopes it to one playback ID, exp sets an expiry, and kid names which signing key to verify against. So a leaked URL is useless once it expires, and useless for any other playback ID even before then. Thumbnails and spritesheets are scoped the same way, on images.fastpix.com, which matters because a private video with a public poster frame is a leak nobody notices.

What this isn't is DRM. The video isn't encrypted, so an authenticated viewer can still capture the stream. Signed URLs control who starts playback, which is a different problem from what happens afterwards. We covered the general model in protecting video content with signed URLs.

What you need before you start

Step 1: Put the private key somewhere the browser cannot reach

Store the private key and key ID as server-side environment variables with no VITE_ prefix, so Vite never sees them. The public half stays with FastPix and is what verifies your tokens.

This is the whole security boundary and it's worth being blunt about it. Anyone holding the private key can mint a token for any video in the workspace, with any expiry they like. Rotate it immediately if it reaches a bundle, a repository or a log line.

FastPix publishes a hosted JWT signer for development, and the docs say plainly that it isn't a replacement for authentication in production. Use it to check a token shape once, then build the route below.

Step 2: Mint a token per viewer, per video, on the server

The route does two things: decide whether this viewer may watch this video, then sign a token that says so. The authorisation check is your business logic and the part an attacker would most like you to skip.

javascript
// server.js
app.get("/api/playback-token/:playbackId", requireSession, async (req, res) => {
  const { playbackId } = req.params;

  const allowed = await userCanWatch(req.user.id, playbackId);
  if (!allowed) return res.status(403).json({ error: "Not allowed" });

  const token = signPlaybackToken({
    kid: process.env.FASTPIX_SIGNING_KEY_ID,
    aud: `media:${playbackId}`,
    exp: Math.floor(Date.now() / 1000) + 60 * 15,
  });

  res.json({ token, expiresIn: 900 });
});

You can test this against a real adaptive stream first. The free plan covers 10 videos and 100K streaming minutes a month with no credit card.

Keep exp short. Fifteen minutes is a reasonable starting point. Long enough that most viewers never notice a refresh. Short enough that a URL pasted into a group chat stops working before anyone clicks it. Returning expiresIn alongside the token lets the client schedule its own refresh without decoding the JWT.

signPlaybackToken is a standard JWT library call using your private key. The exact signing setup, including the claim reference, is in generate JWTs for secure media.

Step 3: Fetch the token in React, and handle the refresh

The naive version fetches a token in an effect and renders the player. It works, and it breaks after fifteen minutes on any video longer than the expiry, which is most of them.

text
import { useEffect, useState } from "react";

export function usePlaybackToken(playbackId, onBeforeSwap) {
  const [token, setToken] = useState(null);

  useEffect(() => {
    if (!playbackId) return;
    let timer;
    let cancelled = false;

    async function refresh() {
      const res = await fetch(`/api/playback-token/${playbackId}`);
      if (!res.ok) return setToken(null);

      const { token, expiresIn } = await res.json();
      if (cancelled) return;

      onBeforeSwap?.();
      setToken(token);
      timer = setTimeout(refresh, (expiresIn - 60) * 1000);
    }

    refresh();
    return () => { cancelled = true; clearTimeout(timer); };
  }, [playbackId]);

  return token;
}

Refresh a minute early rather than on expiry. The request itself takes time, and a clock skewed by a few seconds shouldn't end a viewing session. onBeforeSwap runs immediately before the new token lands, which is the only moment you can still read the old player's position; Step 4 uses it. The cancelled flag stops a slow response setting state on an unmounted component. The clearTimeout stops the refresh loop outliving the component that started it.

Step 4: Make the refreshed token actually reach the player

Setting state isn't enough, and this is the part that catches people. <fastpix-player> reads token once, when it connects. It observes only the theme attribute afterwards, so React writing a new token onto a mounted element changes nothing. The player keeps streaming with the token it started with, and the refresh you just built has no effect.

There are two documented ways through it. Remount the element on a new token and tell it where to resume:

text
import { useCallback, useRef } from "react";
import "@fastpix/fp-player";

export default function PrivatePlayer({ playbackId }) {
  const playerRef = useRef(null);
  const resumeAt = useRef(0);

  const capturePosition = useCallback(() => {
    resumeAt.current = playerRef.current?.currentTime ?? 0;
  }, []);

  const token = usePlaybackToken(playbackId, capturePosition);
  if (!token) return <p>Checking access…</p>;

  return (
    <fastpix-player
      key={token}
      ref={playerRef}
      playback-id={playbackId}
      token={token}
      start-time={resumeAt.current}
      stream-type="on-demand"
    />
  );
}

key={token} is what forces the remount. start-time is read at connect like everything else, so the new element starts where the old one stopped. The position has to be captured before setToken runs, which is why the hook takes a callback rather than leaving you to read it in an effect: effects run after React has already committed the change, by which point the old element is gone.

The alternative avoids the remount entirely. Drop key, and swap the source in place instead:

text
useEffect(() => {
  const player = playerRef.current;
  if (!player || !token) return;

  player.loadByPlaybackId(playbackId, { token });
  player.video.currentTime = resumeAt.current;
}, [token, playbackId]);

Note the seek goes through player.video, the inner element. currentTime on the player itself is a getter with no setter, so assigning to it throws.

Step 5: Give the token to the player

The FastPix web player takes the token as an attribute, and that's the only difference between a public and a private player. The component in Step 4 is the private one; drop token, key and the hook and you have the public one:

text
<fastpix-player playback-id={playbackId} stream-type="on-demand" />
<fastpix-player playback-id={playbackId} token={token} stream-type="on-demand" />

A third-party player takes the same token in the URL instead, at https://stream.fastpix.com/{playbackId}.m3u8?token={JWT}. That's how you wire it into hls.js. Thumbnails and spritesheets on images.fastpix.com take the same ?token=, and the player passes it for you. A separate thumbnail-token attribute exists if you sign images independently, but most integrations never set it. All three are documented in secure video playback. A custom domain works with all three through the custom-domain attribute.

Where signed URLs stop being enough

Two limits are worth stating before someone else discovers them in a security review.

The first is that authenticated viewers can still record. The stream is delivered unencrypted, so anything from a screen recorder to a command-line downloader works once a valid token exists. Shortening the expiry doesn't change this, because the viewer already has the video.

The second is that domain and user agent restrictions are a separate control. To make playback fail when the manifest is requested from somewhere other than your own site, configure a playback restriction. It isn't expressed in the token.

For content where capture itself is the threat, the answer is DRM. The stream is encrypted and decrypted inside the browser's content decryption module, with a licence acquired per session. FastPix supports Widevine, PlayReady and FairPlay, set up in DRM encryption. DRM is more work than a signed URL, and it's the only thing that addresses capture. Pick it deliberately rather than by default.

Ship private playback in your React app

Create a signing key in the dashboard. Add the token route from Step 2, with your own authorisation check in front of it. Pass the result to <fastpix-player> as the token attribute. A private playback ID then refuses to play without it, including its thumbnails. The free plan covers 10 videos and 100K streaming minutes a month, no credit card. Upload one private asset and confirm the unauthorised case fails before wiring it into your app.

Frequently Asked Questions (FAQs)

How do I play a private video in React?

Create the media with a private access policy. Then fetch a short-lived JWT from your own server and pass it to the player. With the FastPix web player, that's the token attribute; with a third-party player, it's ?token={JWT} appended to the manifest URL. The token must be minted server-side because signing requires a private key that cannot be in the browser.

Can I generate a signed video URL in the browser?

No. Signing requires the private half of a signing key pair, and anything in a React bundle is public. Vite inlines VITE_-prefixed variables at build time, and even without the prefix, any value the client can read an attacker can read. Mint tokens on a server that also performs the authorization check.

How long should a video playback token last?

Short enough that a leaked URL expires quickly, but long enough that refreshes are rare. Fifteen minutes is a reasonable default, with the client refreshing about a minute before expiry. Return the lifetime alongside the token so the client can schedule the refresh without decoding the JWT.

What happens when a playback token expires mid-video?

Playback fails when the player next requests a segment or a manifest refresh, which can be some way into the video rather than at the moment of expiry. Refresh proactively on a timer instead of waiting for the error. The FastPix player reads token once at connect, so a refreshed token only takes effect if you remount with key={token} or call loadByPlaybackId. Carry the position over with start-time.

Do signed URLs stop people downloading my video?

No. Signed URLs control who can start playback, not what happens afterward, and the stream itself is unencrypted. An authenticated viewer can record the screen or pull the segments. Preventing capture requires DRM, where the content is encrypted and decrypted inside the browser's content decryption module under a per-session license.

Share

Stay Ahead of Video
Streaming Trends

Start shipping video today.