> ## Documentation Index
> Fetch the complete documentation index at: https://bunny.net/docs/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](/docs/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/DjRqgJNXExRw6wH5/logo/frameworks/nextjs.svg?fit=max&auto=format&n=DjRqgJNXExRw6wH5&q=85&s=5b3e21aac28b53ddddf491f02c654ca8" 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/DjRqgJNXExRw6wH5/logo/frameworks/vue.svg?fit=max&auto=format&n=DjRqgJNXExRw6wH5&q=85&s=a13bf4316b6cc927f1950a2ee5ab0f2f" 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/DjRqgJNXExRw6wH5/logo/frameworks/svelte.svg?fit=max&auto=format&n=DjRqgJNXExRw6wH5&q=85&s=69320f0f9fd7731bb8da9d433819a10e" 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** and copy the key. 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.

<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()` makes the page render per request. Without it, Next.js can prerender a token at build time.

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

    export default async function VideoPage({ params }: { params: Promise<{ id: string }> }) {
      await connection();
      const { id } = await params;
      // Check that the viewer may watch this video before signing.
      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](/docs/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`.

    ```ts nuxt.config.ts theme={null}
    export default defineNuxtConfig({
      runtimeConfig: {
        bunnyStreamLibraryId: "",
        bunnyStreamTokenAuthKey: "",
      },
    });
    ```

    ```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 viewer may watch this video before signing.
      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>
    ```

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

  <Tab title="SvelteKit">
    Put `BUNNY_STREAM_LIBRARY_ID` and `BUNNY_STREAM_TOKEN_AUTH_KEY` in `.env`. 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 { env } from "$env/dynamic/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(`${env.BUNNY_STREAM_TOKEN_AUTH_KEY}${videoId}${expires}`)
        .digest("hex");
      const query = new URLSearchParams({ token, expires: String(expires) });

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

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

    export const load: PageServerLoad = ({ params }) => {
      // Check that the viewer may watch this video before signing.
      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](/docs/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 **Security** page, and it's a separate value from the API key. The video ID must match the URL, and `expires` must be in seconds.

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

  <Accordion title="Every visitor gets the same expired link">
    The page was rendered once and cached. Call `await connection()` in Next.js, and 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.