> ## 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.

# Video access control

> Gate Livepeer streams and assets with JWTs or webhook-based authorisation. Covers signing key creation, JWT generation, and player configuration.

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>;
};

<CenteredContainer preset="readable90">
  <Tip>JWT access control verifies a signed token at playback. Webhook access control calls your server on every playback request. Use JWTs for most applications -- webhooks for dynamic per-user logic.</Tip>
</CenteredContainer>

<CustomDivider />

Both access control methods require setting a `playbackPolicy` on stream or asset creation. Without a playback policy, content is publicly playable by anyone with the playback URL.

<CustomDivider />

## JWT vs webhook

<StyledTable variant="bordered">
  <thead>
    <TableRow header>
      <TableCell header>Factor</TableCell>
      <TableCell header>JWT</TableCell>
      <TableCell header>Webhook</TableCell>
    </TableRow>
  </thead>

  <tbody>
    <TableRow>
      <TableCell>**How it works**</TableCell>
      <TableCell>Sign a token server-side; player presents it at playback</TableCell>
      <TableCell>Livepeer calls your server on every playback request</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>**Server load**</TableCell>
      <TableCell>Token signing only (one request per session)</TableCell>
      <TableCell>One request per viewer per playback start</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>**Revocation**</TableCell>
      <TableCell>Token expiry only (no instant revocation)</TableCell>
      <TableCell>Immediate -- deny at webhook, playback stops</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>**Use for**</TableCell>
      <TableCell>Subscription content, time-limited access, standard gating</TableCell>
      <TableCell>NFT ownership checks, real-time access decisions, complex per-user logic</TableCell>
    </TableRow>
  </tbody>
</StyledTable>

<CustomDivider />

## JWT access control

### Step 1 -- Create a signing key

Create a signing key pair in Studio under **Settings > Signing Keys**, or via API:

```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
const signingKey = await client.signingKey.create();
// signingKey.privateKey -- base64-encoded private key (save securely, not retrievable later)
// signingKey.publicKeyId -- ID to reference in the JWT
```

Store the private key in your secrets manager. It is shown only once.

### Step 2 -- Create a gated stream or asset

```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
// Gated livestream
const stream = await client.stream.create({
  name: 'members-only-stream',
  playbackPolicy: { type: 'jwt' },
});

// Gated asset
const asset = await client.asset.create({
  name: 'premium-video.mp4',
  playbackPolicy: { type: 'jwt' },
});
```

### Step 3 -- Sign a JWT (server-side API route)

The JWT must be signed server-side. Never sign tokens in browser code.

```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
// Next.js API route: /api/sign-jwt.ts
import { signAccessJwt } from '@livepeer/core/crypto';

export async function POST(req: Request) {
  const { playbackId, userId } = await req.json();

  // Add your own authorisation check here
  const isAuthorised = await checkUserSubscription(userId);
  if (!isAuthorised) {
    return Response.json({ error: 'Forbidden' }, { status: 403 });
  }

  const token = await signAccessJwt({
    privateKey: process.env.ACCESS_CONTROL_PRIVATE_KEY!,
    publicKey: process.env.NEXT_PUBLIC_ACCESS_CONTROL_PUBLIC_KEY!,
    issuer: 'https://yourapp.com',
    playbackId,
    expiration: '1h',      // token expires in 1 hour
    custom: { userId },    // optional custom claims
  });

  return Response.json({ token });
}
```

### Step 4 -- Pass the JWT to the player

```tsx theme={"theme":{"light":"github-light","dark":"dark-plus"}}
import * as Player from '@livepeer/react/player';

// Fetch token from your API, then pass to player
export const GatedPlayer = ({ playbackId, jwt }: { playbackId: string; jwt: string }) => (
  <Player.Root src={`https://livepeercdn.studio/hls/${playbackId}/index.m3u8`} jwt={jwt}>
    <Player.Container>
      <Player.Video />
    </Player.Container>
  </Player.Root>
);
```

The Player sends the JWT in the `Livepeer-Jwt` header for WebRTC and HLS requests, and as a `jwt` query parameter for MP4.

For custom players, add the header manually:

```javascript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
// HLS.js with JWT header
const hls = new Hls({
  xhrSetup: (xhr) => {
    xhr.setRequestHeader('Livepeer-Jwt', jwt);
  },
});
```

<CustomDivider />

## Webhook access control

Create a gated stream with `webhook` type and a reference to your webhook:

```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
// First create a webhook
const webhook = await client.webhook.create({
  name: 'access-control',
  url: 'https://your-server.com/livepeer/access',
  events: ['playback.accessControl'],
});

// Then create a gated stream referencing the webhook
const stream = await client.stream.create({
  name: 'webhook-gated-stream',
  playbackPolicy: {
    type: 'webhook',
    webhookId: webhook.webhook.id,
    webhookContext: { plan: 'premium' }, // passed to your webhook
  },
});
```

Your webhook endpoint receives a POST with the viewer's request details and must respond within 250 milliseconds:

```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
// Your webhook handler
export async function POST(req: Request) {
  const payload = await req.json();
  const { playbackId, userId } = payload;

  const allowed = await checkAccess(userId, playbackId);

  // Respond 200 to allow, any other status to deny
  return Response.json({ allowed }, { status: allowed ? 200 : 403 });
}
```

<CustomDivider />

## Related pages

<CardGroup cols={2}>
  <Card title="Create a Livestream" icon="video" href="/v2/developers/build/video/live-events">
    Create streams with or without playback policies.
  </Card>

  <Card title="Webhooks" icon="bell" href="/v2/developers/build/video/live-events">
    Full webhook setup, signature verification, and event types.
  </Card>
</CardGroup>
