> ## Documentation Index
> Fetch the complete documentation index at: https://docs.livepeer.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Token-gate videos with Lit Protocol

> Gate Livepeer video playback behind NFT ownership using Lit Protocol for decentralised access control. A full tutorial with working code.

export const CenteredContainer = ({children, maxWidth = "800px", padding = "0", preset = "default", width = "", minWidth = "", marginRight = "", marginBottom = "", textAlign = "", style = {}, className = "", ...rest}) => {
  const presets = {
    default: {},
    fitContent: {
      width: "fit-content",
      minWidth: "fit-content"
    },
    readable70: {
      width: "70%",
      minWidth: "fit-content"
    },
    readable80: {
      width: "80%",
      minWidth: "fit-content"
    },
    readable90: {
      width: "90%"
    },
    wide900: {
      maxWidth: "900px"
    }
  };
  const presetStyle = presets[preset] || presets.default;
  return <div className={className} style={{
    maxWidth: presetStyle.maxWidth || maxWidth,
    margin: "0 auto",
    padding: padding,
    ...presetStyle.width ? {
      width: presetStyle.width
    } : {},
    ...presetStyle.minWidth ? {
      minWidth: presetStyle.minWidth
    } : {},
    ...width ? {
      width
    } : {},
    ...minWidth ? {
      minWidth
    } : {},
    ...marginRight ? {
      marginRight
    } : {},
    ...marginBottom ? {
      marginBottom
    } : {},
    ...textAlign ? {
      textAlign
    } : {},
    ...style
  }} {...rest}>
      {children}
    </div>;
};

export const CustomDivider = ({color = "var(--lp-color-border-default)", middleText = "", spacing = "default", style = {}, className = "", ...rest}) => {
  const spacingPresets = {
    default: {
      margin: "24px 0"
    },
    overlap: {
      margin: "-1rem 0 -1rem 0"
    },
    tight: {
      margin: "0 0 -1rem 0"
    },
    section: {
      margin: "0 0 -2rem 0"
    },
    sectionOverlap: {
      margin: "-1rem 0 -2rem 0"
    },
    deepOverlap: {
      margin: "-1rem 0 -1.5rem 0"
    }
  };
  const spacingStyle = spacingPresets[spacing] || spacingPresets.default;
  return <div role="separator" aria-orientation="horizontal" className={className} style={{
    display: "flex",
    alignItems: "center",
    ...spacingStyle,
    fontSize: style?.fontSize || "16px",
    height: "fit-content",
    ...style
  }} {...rest}>
      <span style={{
    marginRight: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
      </span>
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      {middleText && <>
          <Icon icon="circle" size={2} />
          <span style={{
    margin: "0 8px",
    fontWeight: "bold",
    color: color,
    opacity: 0.7
  }}>
            {middleText}
          </span>
          <Icon icon="circle" size={2} />
        </>}
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      <span style={{
    marginLeft: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <span style={{
    display: "inline-block",
    transform: "scaleX(-1)"
  }}>
          <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
        </span>
      </span>
    </div>;
};

export const TableCell = ({children, align = "left", header = false, style = {}, className = "", ...rest}) => {
  const Component = header ? "th" : "td";
  return <Component className={className} style={{
    padding: "0.75rem 1rem",
    textAlign: align,
    border: header ? "none" : "1px solid var(--lp-color-border-default)",
    ...style
  }} {...rest}>
      {children}
    </Component>;
};

export const TableRow = ({children, header = false, hover = false, style = {}, className = "", ...rest}) => {
  const rowId = `table-row-${Math.random().toString(36).substr(2, 9)}`;
  return <>
      {hover && <style>{`
          #${rowId}:hover {
            background-color: var(--lp-color-bg-card);
          }
        `}</style>}
      <tr id={rowId} className={className} style={{
    ...header && ({
      backgroundColor: "var(--lp-color-accent-strong)",
      color: "var(--lp-color-on-accent)",
      fontWeight: "bold"
    }),
    ...style
  }} {...rest}>
        {children}
      </tr>
    </>;
};

export const StyledTable = ({children, variant = "default", style = {}, className = "", ...rest}) => {
  const wrapperVariants = {
    default: {
      border: "1px solid var(--lp-color-border-default)",
      backgroundColor: "var(--lp-color-bg-card)",
      overflow: "hidden"
    },
    bordered: {
      border: "2px solid var(--lp-color-accent)",
      backgroundColor: "var(--lp-color-bg-page)",
      overflow: "hidden"
    },
    minimal: {
      border: "none",
      backgroundColor: "transparent",
      overflow: "visible"
    }
  };
  return <div data-docs-styled-table-shell className={className} style={{
    width: "100%",
    padding: 0,
    margin: 0,
    ...wrapperVariants[variant],
    ...style
  }} {...rest}>
      <table data-docs-styled-table style={{
    width: "100%",
    borderCollapse: "collapse",
    borderSpacing: 0,
    margin: 0,
    backgroundColor: "transparent"
  }}>
        {children}
      </table>
    </div>;
};

export const StyledStep = ({title, icon, titleSize = 'h3', iconColor = null, titleColor = null, children, className = '', style = {}, ...rest}) => {
  const styledTitle = titleColor ? <span style={{
    color: titleColor
  }}>{title}</span> : title;
  return <Step title={styledTitle} icon={icon} iconColor={iconColor || undefined} titleSize={titleSize} className={className} style={style} {...rest}>
      {children}
    </Step>;
};

export const StyledSteps = ({children, iconColor, titleColor, lineColor, iconSize = '24px', className = '', style = {}, ...rest}) => {
  const resolvedIconColor = iconColor || 'var(--accent-dark, #18794E)';
  const resolvedTitleColor = titleColor || 'var(--lp-color-accent)';
  const resolvedLineColor = lineColor || 'var(--lp-color-accent)';
  return <div className={['docs-styled-steps', className].filter(Boolean).join(' ')} style={style} {...rest}>
      <style>{`
        .docs-styled-steps .steps > div > div.absolute > div {
          background-color: ${resolvedIconColor};
        }
        .docs-styled-steps .steps > div > div.w-full > p {
          color: ${resolvedTitleColor};
        }
        .docs-styled-steps .steps > div > div.absolute.w-px {
          background-color: ${resolvedLineColor};
        }
        .docs-styled-steps .steps > div:last-child > div.absolute.w-px::after {
          content: '';
          position: absolute;
          bottom: 0;
          left: 50%;
          transform: translateX(-50%);
          width: 6px;
          height: 6px;
          background-color: ${resolvedLineColor};
          transform: translateX(-50%) rotate(45deg);
        }
      `}</style>
      <div>
        <Steps>{children}</Steps>
      </div>
    </div>;
};

<CenteredContainer preset="readable90">
  <Tip>Lit Protocol verifies NFT ownership on-chain without a centralised server. Livepeer enforces access at the CDN edge. Together they create a fully decentralised token-gated video pipeline.</Tip>
</CenteredContainer>

<CustomDivider />

Token-gated video combines Lit Protocol's decentralised access control with Livepeer's JWT playback policy. A viewer proves NFT ownership to Lit; Lit returns a signed JWT; the Livepeer Player presents the JWT to the CDN edge for access.

**What you will build:** A React application where only holders of a specific NFT contract can play a Livepeer-hosted video.

<CustomDivider />

## Token-gating architecture

```
Viewer wallet → Lit Network (ownership check) → Signed JWT → Livepeer CDN (JWT verified) → Video playback
```

Three systems interact:

* **Lit Protocol** -- verifies on-chain conditions (NFT ownership) and issues access control conditions (ACCs) that gate a symmetric encryption key or a signed payload
* **Livepeer-compatible Gateway** -- creates a JWT-protected asset and provides a signing key
* **Playback Gateway or CDN** -- validates the JWT on every playback request at the edge

<CustomDivider />

## Prerequisites

* Node.js 18 or later
* A deployed NFT contract (ERC-721) on Ethereum mainnet or any supported chain
* A Gateway provider API key or self-hosted Gateway with JWT playback access control enabled
* Familiarity with React and ethers.js or wagmi

```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}}
npm install livepeer @livepeer/react @lit-protocol/lit-node-client ethers
```

<CustomDivider />

## Build the gate

<StyledSteps iconColor="#2d9a67" titleColor="var(--accent)">
  <StyledStep title="Create a JWT-protected Livepeer asset" icon="shield">
    Create the asset with a JWT playback policy and a signing key. Follow the <a href="/v2/developers/guides/auth-and-security/access-control">access control guide</a> for full setup.

    ```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    import { Livepeer } from 'livepeer';

    const client = new Livepeer({ apiKey: process.env.LIVEPEER_API_KEY });

    // Create signing key
    const { privateKey, id: publicKeyId } = await client.signingKey.create();
    // Store privateKey in your secrets manager

    // Create gated asset
    const { asset } = await client.asset.create({
      name: 'nft-gated-video.mp4',
      playbackPolicy: { type: 'jwt' },
    });

    console.log('Playback ID:', asset.playbackId);
    ```
  </StyledStep>

  <StyledStep title="Define Lit access control conditions" icon="key">
    Access control conditions (ACCs) specify what a viewer must prove to get access. This example requires ownership of at least one token from a specific ERC-721 contract:

    ```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    const accessControlConditions = [
      {
        contractAddress: '0xYourNFTContractAddress',
        standardContractType: 'ERC721',
        chain: 'ethereum',
        method: 'balanceOf',
        parameters: [':userAddress'],
        returnValueTest: {
          comparator: '>',
          value: '0',
        },
      },
    ];
    ```

    Lit evaluates this condition against the viewer's connected wallet. If `balanceOf(viewerAddress) > 0`, the condition passes and Lit proceeds to issue the access.
  </StyledStep>

  <StyledStep title="Encrypt the Livepeer signing key with Lit" icon="lock">
    Encrypt your Livepeer private signing key with the access control conditions so only qualifying wallets can decrypt it:

    ```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    import * as LitJsSdk from '@lit-protocol/lit-node-client';

    const litClient = new LitJsSdk.LitNodeClient({ litNetwork: 'mainnet' });
    await litClient.connect();

    // Encrypt the Livepeer private key -- only holders of the NFT can decrypt it
    const { ciphertext, dataToEncryptHash } = await LitJsSdk.encryptString(
      {
        accessControlConditions,
        dataToEncrypt: process.env.ACCESS_CONTROL_PRIVATE_KEY!, // Livepeer signing key
      },
      litClient
    );

    // Store ciphertext and dataToEncryptHash alongside your asset metadata
    ```
  </StyledStep>

  <StyledStep title="Decrypt the key and sign a JWT in the browser" icon="unlock">
    When a viewer connects their wallet, use Lit to decrypt the Livepeer signing key client-side, then sign a JWT:

    ```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    import { signAccessJwt } from '@livepeer/core/crypto';

    async function getPlaybackJWT(
      viewerAddress: string,
      authSig: object,
      playbackId: string
    ): Promise<string> {
      const litClient = new LitJsSdk.LitNodeClient({ litNetwork: 'mainnet' });
      await litClient.connect();

      // Decrypt the Livepeer private key -- Lit verifies NFT ownership on-chain
      const { decryptedString: privateKey } = await LitJsSdk.decryptToString(
        {
          accessControlConditions,
          ciphertext,
          dataToEncryptHash,
          authSig,
          chain: 'ethereum',
        },
        litClient
      );

      // Sign a Livepeer JWT with the decrypted key
      const jwt = await signAccessJwt({
        privateKey,
        publicKey: process.env.NEXT_PUBLIC_ACCESS_CONTROL_PUBLIC_KEY!,
        issuer: 'https://yourapp.com',
        playbackId,
        expiration: '1h',
        custom: { viewer: viewerAddress },
      });

      return jwt;
    }
    ```
  </StyledStep>

  <StyledStep title="Pass the JWT to the Livepeer Player" icon="play">
    ```tsx theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    import * as Player from '@livepeer/react/player';
    import { useAccount, useSignMessage } from 'wagmi';

    export const TokenGatedPlayer = ({ playbackId }: { playbackId: string }) => {
      const { address } = useAccount();
      const [jwt, setJwt] = useState<string | null>(null);
      const { signMessageAsync } = useSignMessage();

      const handleUnlock = async () => {
        if (!address) return;

        // Get auth signature from the viewer's wallet
        const authSig = await signMessageAsync({ message: 'Sign to verify NFT ownership' });

        // Get playback JWT (Lit verifies ownership, decrypts key, we sign JWT)
        const token = await getPlaybackJWT(address, authSig, playbackId);
        setJwt(token);
      };

      if (!jwt) {
        return (
          <div>
            <p>This video requires NFT ownership to watch.</p>
            <button onClick={handleUnlock}>Connect and Verify</button>
          </div>
        );
      }

      return (
        <Player.Root
          src={`${process.env.NEXT_PUBLIC_PLAYBACK_BASE_URL}/asset/hls/${playbackId}/index.m3u8`}
          jwt={jwt}
        >
          <Player.Container>
            <Player.Video />
            <Player.Controls />
          </Player.Container>
        </Player.Root>
      );
    };
    ```
  </StyledStep>
</StyledSteps>

<CustomDivider />

## Extending the gate

The access control conditions accept any Lit-supported chain and condition type:

<StyledTable variant="bordered">
  <thead>
    <TableRow header>
      <TableCell header>Gate type</TableCell>
      <TableCell header>Condition method</TableCell>
      <TableCell header>Notes</TableCell>
    </TableRow>
  </thead>

  <tbody>
    <TableRow>
      <TableCell>ERC-721 ownership</TableCell>
      <TableCell>`balanceOf`</TableCell>
      <TableCell>Any ERC-721; condition shown above</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>Specific token ID</TableCell>
      <TableCell>`ownerOf`</TableCell>
      <TableCell>Gates on ownership of one specific token ID</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>ERC-20 balance</TableCell>
      <TableCell>`balanceOf`</TableCell>
      <TableCell>Gate on holding a minimum token amount</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>ERC-1155 balance</TableCell>
      <TableCell>`balanceOf`</TableCell>
      <TableCell>Gates on semi-fungible token ownership</TableCell>
    </TableRow>
  </tbody>
</StyledTable>

<CustomDivider />

## AI agent prompt

```text theme={"theme":{"light":"github-light","dark":"dark-plus"}}
Build the "Token-gate videos with Lit Protocol" tutorial as a React/TypeScript app. Create a project that installs livepeer, @livepeer/react, @lit-protocol/lit-node-client, ethers, wagmi, and the wallet connector dependencies needed by wagmi. Use placeholders for LIVEPEER_API_URL=<gateway asset API base URL>, LIVEPEER_API_KEY=<gateway provider API key>, NEXT_PUBLIC_PLAYBACK_BASE_URL=<provider playback base URL>, NEXT_PUBLIC_ACCESS_CONTROL_PUBLIC_KEY=<Livepeer signing key public ID>, LIVEPEER_ACCESS_CONTROL_PRIVATE_KEY=<private signing key>, NFT_CONTRACT_ADDRESS=<ERC-721 contract>, and CHAIN=<Lit-supported chain>. Implement server-side signing-key and JWT helpers, Lit access-control conditions for ERC-721 ownership, a wallet-gated React player, and a README with exact run and verification steps. Do not use Livepeer Studio keys or hardcoded livepeercdn.studio URLs.
```

<CustomDivider />

## Related pages

<CardGroup cols={2}>
  <Card title="Access Control" icon="lock" href="/v2/developers/guides/auth-and-security/access-control">
    JWT and webhook-based access control without Lit Protocol.
  </Card>

  <Card title="IPFS Video Integration" icon="cloud" href="/v2/developers/build/tutorials/ipfs-video-integration">
    Store video on IPFS and deliver via Livepeer.
  </Card>
</CardGroup>
