> ## Documentation Index
> Fetch the complete documentation index at: https://bunnynet-cb9733c2-nathan-draft-sep-8.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Sign Bunny Player embed URLs on the server

> Play videos from a library with embed view token authentication in Next.js, Nuxt, or SvelteKit, by signing the embed URL on the server so the key never reaches the browser.

With [embed view token authentication](/stream/token-authentication) on, Bunny Player turns away any embed URL without a valid `token` and `expires`. The key behind those tokens can sign every video in the library. Keep it on the server and send the browser a finished URL.

<CardGroup cols={3}>
  <Card title="Next.js" icon="https://mintcdn.com/bunnynet-cb9733c2-nathan-draft-sep-8/EMGBBzipJnaVqwed/logo/frameworks/nextjs.svg?fit=max&auto=format&n=EMGBBzipJnaVqwed&q=85&s=63d2b7d0c3d396d8e5d7501f6683e937" href="https://github.com/BunnyWay/examples/tree/main/stream/player-token-auth-nextjs" width="24" height="24" data-path="logo/frameworks/nextjs.svg">
    A Server Component.
  </Card>

  <Card title="Nuxt" icon="https://mintcdn.com/bunnynet-cb9733c2-nathan-draft-sep-8/EMGBBzipJnaVqwed/logo/frameworks/vue.svg?fit=max&auto=format&n=EMGBBzipJnaVqwed&q=85&s=5278b03fbdf838e82b7819a33afe3f26" href="https://github.com/BunnyWay/examples/tree/main/stream/player-token-auth-nuxt" width="24" height="24" data-path="logo/frameworks/vue.svg">
    A server route and `useFetch`.
  </Card>

  <Card title="SvelteKit" icon="https://mintcdn.com/bunnynet-cb9733c2-nathan-draft-sep-8/EMGBBzipJnaVqwed/logo/frameworks/svelte.svg?fit=max&auto=format&n=EMGBBzipJnaVqwed&q=85&s=c7c6fb13b1f2b82875f88d2d5fdf3274" href="https://github.com/BunnyWay/examples/tree/main/stream/player-token-auth-sveltekit" width="24" height="24" data-path="logo/frameworks/svelte.svg">
    A server `load` function.
  </Card>
</CardGroup>

## Turn on token authentication

On your library's **Security** page, enable **Embed view token authentication**. Copy the key from the **Token authentication key** card below it. The field there is labelled **API key**, but it's a different value from the library's API key on the **API** page.

The same key signs CDN token authentication URLs for the library, so resetting it breaks both.

Unsigned embeds start returning 403 straight away. Deploy the signing code first.

## Sign the URL

The token is a hex SHA-256 of the key, video ID, and expiry, joined with nothing between them. The expiry is Unix time in seconds.

<Warning>
  Check that the viewer may watch the video before you sign its URL. The page or route that signs is a public endpoint, so anyone can request it with any video ID from your library. Without that check, token authentication protects nothing. The comment in each example below marks where the check goes.
</Warning>

<Tabs>
  <Tab title="Next.js">
    Put `BUNNY_STREAM_LIBRARY_ID` and `BUNNY_STREAM_TOKEN_AUTH_KEY` in `.env.local`. The `server-only` import breaks the build if a Client Component ever pulls this file in.

    ```ts lib/bunny-stream.ts theme={null}
    import "server-only";
    import { createHash } from "node:crypto";

    const TOKEN_TTL_SECONDS = 60 * 60;

    export function signEmbedUrl(videoId: string): { src: string; expires: number } {
      const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID!;
      const key = process.env.BUNNY_STREAM_TOKEN_AUTH_KEY!;
      const expires = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS;
      const token = createHash("sha256").update(`${key}${videoId}${expires}`).digest("hex");
      const query = new URLSearchParams({ token, expires: String(expires) });

      return { src: `https://player.mediadelivery.net/embed/${libraryId}/${videoId}?${query}`, expires };
    }
    ```

    `await connection()` signs a fresh URL on every request. Without it, Next.js can prerender a token at build time. Call it, and read `params`, in a component inside `<Suspense>`. With Cache Components on, as in a new `create-next-app` project, request data outside `<Suspense>` fails the build.

    ```tsx app/videos/[id]/page.tsx theme={null}
    import { Suspense } from "react";
    import { connection } from "next/server";
    import { signEmbedUrl } from "@/lib/bunny-stream";

    export default function VideoPage({ params }: { params: Promise<{ id: string }> }) {
      return (
        <Suspense>
          <SignedPlayer params={params} />
        </Suspense>
      );
    }

    async function SignedPlayer({ params }: { params: Promise<{ id: string }> }) {
      await connection();
      const { id } = await params;
      // Check that the signed-in viewer may watch this video before signing.
      // Anyone can request this with any video ID, so never skip the check.
      const { src } = signEmbedUrl(id);

      return (
        <iframe
          src={src}
          title="Video player"
          style={{ width: "100%", aspectRatio: "16 / 9", border: 0 }}
          allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
          allowFullScreen
        />
      );
    }
    ```

    For playback control, pass `token` and `expires` to the [Next.js guide](/stream/player/nextjs)'s component through `params`.
  </Tab>

  <Tab title="Nuxt">
    Private `runtimeConfig`, outside `public`, only exists on the server. Set it with `NUXT_BUNNY_STREAM_LIBRARY_ID` and `NUXT_BUNNY_STREAM_TOKEN_AUTH_KEY`. `nuxt dev` and `nuxt preview` read them from `.env`. The production server doesn't, so set them in your host's environment.

    Add the two keys to the config you already have.

    ```ts nuxt.config.ts theme={null}
    export default defineNuxtConfig({
      // ...keep your existing options, such as compatibilityDate.
      runtimeConfig: {
        bunnyStreamLibraryId: "",
        bunnyStreamTokenAuthKey: "",
      },
    });
    ```

    The signing code imports `node:crypto`. Run `npm install -D @types/node` so `nuxt typecheck` can find its types.

    ```ts server/utils/bunny-stream.ts theme={null}
    import { createHash } from "node:crypto";

    const TOKEN_TTL_SECONDS = 60 * 60;

    export function signEmbedUrl(videoId: string): { url: string; expires: number } {
      const { bunnyStreamLibraryId, bunnyStreamTokenAuthKey } = useRuntimeConfig();
      // Nuxt parses NUXT_* values, so a numeric library ID arrives as a number.
      const libraryId = String(bunnyStreamLibraryId);
      const expires = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS;
      const token = createHash("sha256").update(`${bunnyStreamTokenAuthKey}${videoId}${expires}`).digest("hex");

      return {
        url: `https://player.mediadelivery.net/embed/${libraryId}/${videoId}?token=${token}&expires=${expires}`,
        expires,
      };
    }
    ```

    ```ts server/api/embed/[id].get.ts theme={null}
    export default defineEventHandler((event) => {
      // Check that the signed-in viewer may watch this video before signing.
      // Anyone can request this with any video ID, so never skip the check.
      return signEmbedUrl(getRouterParam(event, "id")!);
    });
    ```

    `useFetch` runs during server rendering. The signed URL arrives with the HTML, and the browser reads it from the payload without a second request.

    ```vue app/components/SignedPlayer.vue theme={null}
    <script setup lang="ts">
    const props = defineProps<{ videoId: string }>();
    const { data: embed, error } = await useFetch(`/api/embed/${props.videoId}`);
    </script>

    <template>
      <p v-if="error">Could not sign the embed URL: {{ error.message }}</p>
      <iframe
        v-else-if="embed"
        :src="embed.url"
        title="Video player"
        style="width: 100%; aspect-ratio: 16 / 9; border: 0"
        allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
        allowfullscreen
      />
    </template>
    ```

    Mount it on a page. If `app/app.vue` still shows the welcome screen, replace it with `<NuxtPage />`.

    ```vue app/pages/videos/[id].vue theme={null}
    <script setup lang="ts">
    const id = useRoute().params.id as string;
    </script>

    <template>
      <SignedPlayer :video-id="id" />
    </template>
    ```

    For playback control, pass `token` and `expires` to the [Vue and Nuxt guide](/stream/player/vue)'s component through `params`.
  </Tab>

  <Tab title="SvelteKit">
    Put `BUNNY_STREAM_LIBRARY_ID` and `BUNNY_STREAM_TOKEN_AUTH_KEY` in `.env`, and declare them in `src/env.ts`. Without `public: true`, they're only importable from server code.

    ```ts src/env.ts theme={null}
    import { defineEnvVars } from "@sveltejs/kit/env";

    export const variables = defineEnvVars({
      BUNNY_STREAM_LIBRARY_ID: {},
      BUNNY_STREAM_TOKEN_AUTH_KEY: {},
    });
    ```

    Install `@types/node` as a dev dependency for the `node:crypto` types. SvelteKit won't let browser code import anything under `src/lib/server`.

    ```ts src/lib/server/bunny-stream.ts theme={null}
    import { createHash } from "node:crypto";
    import { BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_TOKEN_AUTH_KEY } from "$app/env/private";

    const TOKEN_TTL_SECONDS = 60 * 60;

    export function signEmbedUrl(videoId: string): { url: string; expires: number } {
      const expires = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS;
      const token = createHash("sha256")
        .update(`${BUNNY_STREAM_TOKEN_AUTH_KEY}${videoId}${expires}`)
        .digest("hex");
      const query = new URLSearchParams({ token, expires: String(expires) });

      return { url: `https://player.mediadelivery.net/embed/${BUNNY_STREAM_LIBRARY_ID}/${videoId}?${query}`, expires };
    }
    ```

    ```ts src/routes/videos/[id]/+page.server.ts theme={null}
    import { signEmbedUrl } from "#lib/server/bunny-stream.ts";
    import type { PageServerLoad } from "./$types";

    export const load: PageServerLoad = ({ params }) => {
      // Check that the signed-in viewer may watch this video before signing.
      // Anyone can request this with any video ID, so never skip the check.
      return { embed: signEmbedUrl(params.id) };
    };
    ```

    ```svelte src/routes/videos/[id]/+page.svelte theme={null}
    <script lang="ts">
      let { data } = $props();
    </script>

    <iframe
      src={data.embed.url}
      title="Video player"
      style="width: 100%; aspect-ratio: 16 / 9; border: 0"
      allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
      allowfullscreen
    ></iframe>
    ```

    For playback control, pass `token` and `expires` to the [Svelte and SvelteKit guide](/stream/player/svelte)'s component through `params`.
  </Tab>
</Tabs>

## Choose an expiry

Anyone holding a signed URL can play it until it expires. We use an hour in the examples to make them easy to try. Production pages sign a fresh URL on every load, which makes a window of a few minutes painless.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The iframe shows a 403">
    Check the inputs. The key comes from the **Token authentication key** card on the **Security** page, not the library's API key on the **API** page. The video ID must match the URL, and `expires` must be in seconds.

    With a good token, look at the library's [embedding restrictions](/stream/embedding#embedding-restrictions).
  </Accordion>

  <Accordion title="Every visitor gets the same expired link">
    The page was rendered once and cached. In Next.js, call `await connection()` in the component that signs, inside `<Suspense>`. Check the route isn't prerendered in Nuxt or SvelteKit.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.