Hakari

Signed playback

When signed playback is enforced, every playback request must carry a ?token=<JWT> on the URL. The Hakari edge validates the token before any bytes are served — expired, tampered, or wrong-stream tokens return 401.

Toggle it per stream and per VOD on the detail page. Off by default; once on, all playback paths (/app/<key>/* for live + /vod/<id>/* for VOD) enforce.

Two algorithms are supported:

  • HS256 (default) — Hakari signs the token with a per-stream symmetric secret. Zero integration work on your side.
  • RS256 (enterprise) — you upload an RSA public key and sign tokens with your private key on your own infrastructure. The edge verifies with the public key. Hakari never sees your private key.

URL shape

https://stream.hakari.cloud/app/hkr_abc/index.m3u8?token=<JWT>

Payload claims:

{
  "streamKey": "hkr_abc",
  "exp": 1730000000,
  "nbf": 1729999000,
  "allowIp": "203.0.113.0/24",
  "projectId": "6630abc...",
  "streamExpire": 1730003600,
  "iat": 1729999000
}

| Claim | Type | Meaning | | --- | --- | --- | | streamKey | string | Required. Must match the stream/VOD id in the URL path — rebinds tokens to a specific resource. | | exp | seconds epoch | Required. Reject after this instant. | | nbf | seconds epoch | Optional. Not-before — reject earlier than this. | | allowIp | string | Optional. IPv4 CIDR or single IP. Reject if the client's IP isn't in the range. | | projectId | ObjectId | Required for RS256. Lets the edge look up the right public key on multi-project setups. | | streamExpire | seconds epoch | Optional. Still-connected clients are cut after this. | | iat | seconds epoch | Optional. Issued-at timestamp. |

The JWT header carries the algorithm + key id:

{ "alg": "HS256", "kid": "default" }
// or
{ "alg": "RS256", "kid": "client-2026-a" }

Security note: the edge strictly pins algorithms: ['HS256','RS256'] on every verify. The alg: none / unsigned downgrade attack is rejected.

Two ways to mint URLs

1. Server-side via API (Hakari does the signing)

Simplest. POST the ticket endpoint, get back a fully-formed signed URL. Works for both HS256 (default) and — if you've uploaded a public key — RS256 by passing alg: "RS256" + kid.

curl -X POST https://api.hakari.cloud/v1/projects/my-project/streams/<id>/playback-ticket \
  -H "Authorization: Bearer hkr_xxx" \
  -H "Content-Type: application/json" \
  -d '{"expiresInSec": 900, "allowIp": "203.0.113.5"}'

Response:

{
  "ticket": "<DRM JWT, used only if DRM is on>",
  "expiresInSec": 900,
  "drmEnabled": false,
  "playbackUrls": {
    "llhls":       "https://stream.hakari.cloud/app/hkr_abc/index.m3u8?token=...",
    "llhlsDirect": "...",
    "hls":         "...",
    "webrtc":      "..."
  },
  "policy": { "url_expire": 1730000000000, "allow_ip": "203.0.113.5" }
}

Same endpoint shape for VODs: POST /v1/projects/my-project/vod/<vodId>/playback-ticket.

2. Client-side signing — HS256 (symmetric)

For high-volume use — embed a VOD on every article page, sign a live URL for each WebSocket connection — calling /playback-ticket is a network round-trip per request. Sign in-process if you hold the stream or VOD's HS256 secret.

The HS256 secret is equivalent to admin access for that stream / VOD. Keep it server-side, never ship it to browsers.

Node.js

import jwt from "jsonwebtoken";

export function signPlaybackUrl(rawUrl, streamKey, secret, claims = {}) {
  const token = jwt.sign(
    {
      streamKey,
      exp: Math.floor((Date.now() + 15 * 60 * 1000) / 1000),
      ...claims,
    },
    secret,
    { algorithm: "HS256" }
  );
  const sep = rawUrl.includes("?") ? "&" : "?";
  return `${rawUrl}${sep}token=${token}`;
}

const signed = signPlaybackUrl(
  "https://stream.hakari.cloud/app/hkr_abc/index.m3u8",
  "hkr_abc",
  process.env.HAKARI_STREAM_SECRET,
  { allowIp: "203.0.113.5" }
);

Python

import time, jwt  # pip install pyjwt

def sign_playback_url(raw_url: str, stream_key: str, secret: str, **claims) -> str:
    payload = {
        "streamKey": stream_key,
        "exp": int(time.time()) + 15 * 60,
        **claims,
    }
    token = jwt.encode(payload, secret, algorithm="HS256")
    sep = "&" if "?" in raw_url else "?"
    return f"{raw_url}{sep}token={token}"

signed = sign_playback_url(
    "https://stream.hakari.cloud/app/hkr_abc/index.m3u8",
    "hkr_abc",
    "<secret>",
    allowIp="203.0.113.5",
)

Go

import (
    "fmt"
    "strings"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

func SignPlaybackURL(rawURL, streamKey, secret string, extra jwt.MapClaims) (string, error) {
    claims := jwt.MapClaims{
        "streamKey": streamKey,
        "exp":       time.Now().Add(15 * time.Minute).Unix(),
    }
    for k, v := range extra { claims[k] = v }
    tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
    if err != nil { return "", err }
    sep := "?"
    if strings.Contains(rawURL, "?") { sep = "&" }
    return fmt.Sprintf("%s%stoken=%s", rawURL, sep, tok), nil
}

3. Client-side signing — RS256 (customer-managed keys)

Generate an RSA keypair on your own infrastructure. The private key never leaves your side; Hakari only ever sees the public key.

openssl genpkey -algorithm RSA -out hakari-private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -in hakari-private.pem -pubout -out hakari-public.pem

Upload the public key to your project:

curl -X POST https://api.hakari.cloud/v1/projects/my-project/signing-keys \
  -H "Authorization: Bearer hkr_xxx" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg pem "$(cat hakari-public.pem)" '{kid: "prod-2026-a", pem: $pem}')"

Now sign tokens in your backend with the private key + matching kid:

import jwt from "jsonwebtoken";
import fs from "fs";

const privateKey = fs.readFileSync("hakari-private.pem", "utf8");

export function signRs256(rawUrl, streamKey, projectId, kid, claims = {}) {
  const token = jwt.sign(
    {
      streamKey,
      projectId,
      exp: Math.floor((Date.now() + 15 * 60 * 1000) / 1000),
      ...claims,
    },
    privateKey,
    { algorithm: "RS256", keyid: kid }
  );
  const sep = rawUrl.includes("?") ? "&" : "?";
  return `${rawUrl}${sep}token=${token}`;
}

The edge reads kid from the JWT header, fetches the matching public key from Redis, and verifies.

Key rotation

  • HS256: rotating a stream's push credentials also rotates the playback secret. Any previously-minted token stops working immediately.
  • RS256: upload a new key with a new kid + deprecate the old one. Both remain valid through the transition window — existing tokens keep playing until their exp elapses, new tokens use the new kid.

Use rotation whenever a secret might have leaked.

How the edge verifies

On every playback hit:

  1. Extracts the stream/VOD id from the URL path.
  2. Extracts ?token=<JWT> from the query string.
  3. Decodes the JWT header — reads alg + kid.
  4. Fetches the verification key: for HS256, the per-stream HS256 secret; for RS256, the kid → public key mapping.
  5. Runs jwt.verify with algorithms: ['HS256','RS256'] — strict allowlist blocks the alg: none downgrade.
  6. Checks streamKey matches the URL path, enforces exp / nbf, enforces allowIp CIDR against the client's real IP.
  7. Allows the request, or returns 401 with an X-Deny-Reason header.

No bytes are served until that check passes — there's no bypass path.

Propagation through derivative requests

HLS and DASH playlists reference relative URIs for chunklists / segments. Hakari automatically copies the ?token=... from the master request onto every rewritten URI in the response, so a player that preserves query params (hls.js, OvenPlayer, video.js) keeps the signed URL valid for every follow-up fetch.

A key design choice: the JWT signs the policy, not the full URL. This means the same token is valid across the master playlist + every chunklist + every segment request — you don't need fresh tokens for each derivative URL. It remains bound to the stream (via the streamKey claim) so it can't be replayed against a different stream.

Summary

| | | | --- | --- | | Algorithms | HS256 (default, Hakari-signed) or RS256 (enterprise, customer-signed) | | Format | Standard JWT (?token=<jwt>) | | Scope binding | streamKey claim must match URL path — prevents cross-stream replay | | Lifetime | Bounded by exp; optional nbf / streamExpire / allowIp constraints | | Enforcement | Per-stream and per-VOD toggle on the detail page; changes effective within ~30s | | Leak containment | HS256: one secret per resource. RS256: your private key never reaches us | | Mint options | Server-side via /playback-ticket API, or client-side with jsonwebtoken / pyjwt / golang-jwt |