Skip to content

EP06: Publish/play security — signing, tokens and anti-hotlinking

Recap EP05 "How Simulcast works and what it's for" covered how layers are encoded and switched
Next EP07 "How to strengthen resilience on weak networks"

0. Goals of this episode

By the end, the viewer should be able to:

  1. Explain why the playback link's anti-hotlink MAC and the ingest auth Token use different cryptographic mechanisms, and the replay risk both bearer credentials share.
  2. Understand what role keyId is meant to play in key rotation: the format already supports it, but you need to tell apart "a reserved rotation interface" from "rotation capability that's actually live".

1. Opening hook (script notes)

Previous episodes were about making the picture fast and stable; this one changes topic — how to stop others from stealing your playback link for use elsewhere, and how to stop forged ingest requests impersonating your host. Both look like "just add a signature check", but this episode explains why this project uses two completely different cryptographic tools for the two cases.


2. Playback side: txTime / txSecret MAC anti-hotlinking

2.1 Mechanism: HMAC over the canonical resource and expiry

The playback link a viewer receives (or the request used to obtain it) carries Authorization: Bearer {appId}:{txTime}:{txSecret}. Here:

  • txTime: a hex-encoded Unix-seconds expiry timestamp, visible in plaintext, not encrypted.
  • txSecret: currently v1.{keyId}.{hex(HMAC-SHA256)}. The HMAC input is the exact byte concatenation keyId + ":" + trim(streamID, "/") + ":" + strings.ToUpper(txTime), where streamID is the canonicalized {appId}/{streamName}. Issuer and verifier must use identical path canonicalization, field order, separators, case and character encoding — not a similar variant in a diagram or SDK.

This is the common timestamp + HMAC anti-hotlink scheme. Strictly, HMAC is a shared-key message authentication code (MAC), not a public/private-key signature: anyone holding appSecret can both generate and verify, so the key must never be handed to an untrusted client.

Why include streamName (the path) in the MAC: this is the core of "anti-hotlinking" — the txSecret authenticates "this stream's path + this expiry"; the same value won't verify against a different path. So even if someone gets a valid link for room A, they can't move it as-is to room B; but it can still be replayed for room A within its validity.

Why have the plaintext txTime expiry: once a link leaks (forwarded, screenshotted), it's only valid until expiry and doesn't become a permanent backdoor.

But expiry is not anti-replay: Authorization: Bearer ... is a bearer credential; an attacker who intercepts it within its validity can replay it as-is. Always use TLS, keep tokens out of URLs, Referer, access logs and analytics, and set the shortest feasible TTL. For one-time semantics you also need a server-side, atomically-consumed jti/nonce, or a credential bound to the session/ client key — HMAC + txTime alone cannot do this.

This signing scheme is actually used twice along the playback path, not once: the client first calls ppcenter's playback API with a Bearer {appId}:{txTime}:{txSecret} it computed itself (or obtained from its login session). Once ppcenter verifies that, it doesn't forward the token as-is to the edge node — it signs again with the same GenTxSecretV1, this time over the WHEP address the edge node will verify, in the form of URL query parameters ?txTime=..&txSecret=.. (see GenerateEdgeWHEPURL/GenerateLegacyStreamBase in internal/services/stream.go). That second signature covers the bare {appId}/{streamName}, without a codec suffix like /h264 or /hevc — the edge node strips the codec segment from the path before verifying. That way the same signature verifies both /h264/whep and /hevc/whep, and the client decides which suffix to append.

2.2 Two easily-missed verification details that matter

  • Constant-time comparison: MAC verification must not use an early-exit string compare; this project uses hmac.Equal. This avoids the comparator leaking timing about the matching prefix; the full service must still avoid other branches, logs or error responses becoming side channels.
  • Security-sensitive failures stay undifferentiated — expiry is the exception: the current implementation (ApiAuthVerifyHandler in ppcenter/internal/apis/auth.go, V1PlayHandler in player_v1.go) encodes "expired" as its own distinct error (secret expired / viewer_token_expired), because expiry by itself isn't sensitive — the client needs to know it so it can decide whether to fetch a fresh signature. But the two more sensitive failure modes, "signature doesn't match" and "appId doesn't exist", are folded into one generic error (invalid txSecret / invalid_viewer_token) that never tells the caller which step failed. The goal is to deny anyone probing the system any hint for "improving the next attempt" — what gets distinguished is decided by whether it leaks security-relevant information, not by making every failure look identical.

2.3 keyId: the rotation interface is reserved, but not wired up yet

The keyId field in v1.{keyId}.{signature} is where key rotation is supposed to hook in. Without this field at all, rotating appSecret would have one consequence: every link already issued and not yet expired would instantly stop verifying, because the server would no longer know which key to check them against.

Imagine a complete multi-version key store: once keyId has somewhere to point, it should let you:

  • Stamp every newly issued signature with the key version currently active, as keyId (say, a month string like 2026-07).
  • Have the server parse keyId out of the Bearer value during verification and look up the matching key version — no guessing, and no verifying an old-key link against a brand-new key.
  • Rotate by having newly issued links switch to the new keyId + new key, while the old keyId's key stays valid through a transition window — old links aren't killed off the moment you rotate; they expire naturally on their own txTime.
  • Validate the format of keyId itself: no . or : separator characters allowed, so it can't be confused with the token's other delimiters or abused for parser-level injection (see normalizeTxSecretKeyID in internal/utils/utils.go).

But that "complete implementation" is not where ppcenter actually stands today. Read internal/models/app_credential.go (and the underlying user_app.go) and you'll find AppCredential stores exactly one AppSecret field per app — there's no slot for a second or third key. internal/utils/utils.go's GenTxSecretV1WithKeyID does accept an arbitrary keyId, but every actual signing call site in the repo — ApiAuthGenHandler in auth.go, play_link_v1.go, origin_v1.go, record_manifest_v1.go, services/stream.go — calls GenTxSecretV1 instead, which hardcodes keyID = "default". No public API today can issue a signature with any keyId other than default. On the verification side, verifyTxSecretInternal may well parse a different keyId out of the Bearer value, but it still recomputes the HMAC against that one AppSecret — it never "looks up another key by keyId", because there is no other key to look up.

In other words: if you actually rotate an app's AppSecret today, the effect is identical to not having a keyId field at all — every old link fails immediately, regardless of its keyId. The field is reserved in the wire format, paving the way for a future multi-version key store, but that storage layer and the per-version revocation logic don't exist yet. "The format supports it" should not be misread as "rotation without interruption works today".

2.4 Backward compatibility: retiring the old MD5 signature

The system still keeps an earlier MD5 format (md5(appSecret + streamID + strings.ToUpper(txTime))) for migration-period compatibility only. It is not HMAC and lacks clear domain separation and modern protocol design guarantees; MD5 is unsuitable for new security design. New signing must use only HMAC-SHA256 v1; production should reject legacy MD5 by default and, for apps that truly need it, use a separate switch, monitoring and a clear sunset date. Even with HMAC, if the claims don't bind action, node or session, the same bearer may still be replayed across uses; an algorithm upgrade is not a substitute for authorization-scope design.


3. Ingest side: the WHIP Token uses encryption, not a signature

This is not the nodeSecret used for node registration

The "ingest Token" discussed here is the application-layer credential an ingest client like OBS/ppobs exchanges for a chance to publish. It is a completely separate mechanism from the nodeSecret an mmx node uses to register itself with ppcenter (WS NodeRegister/ NodeRegisterAck, a node-level credential — and as of the recent v1.0.70, self-hosted nodes switched from "displaying the nodeSecret" to "registering with a licenseCode"). One authenticates "this publish request"; the other authenticates "this node itself". Neither substitutes for or is reused as the other, and their decryption keys and issuance/revocation lifecycles are completely different.

3.1 Why the playback approach can't be copied

The ingest Token must carry more structured information: device id, target appId/streamName, bound codec (h264/hevc), issue time and expiry. With "plaintext fields + signature", those fields are exposed in plaintext inside the Token — anyone with the Token sees everything, and a signature only proves "not tampered", not "content confidential".

Ingest chose another path: wrap the whole Token content with AES-256-GCM encryption, instead of plaintext + signature. This Token travels in the WHIP-Device-Id request header (falling back to the standard Authorization: Bearer <token>); WHIP publishing requires it — if it's missing, the request is rejected outright, with no switch to turn that requirement off.

SRT publishing uses the exact same Token, but doesn't require it by default: SRT carries the same AES-256-GCM Token in the third segment of streamid (publish:{appId}/{stream}:{token}), and the verifier (mmx) decrypts it with the exact same Go implementation and the same WHIP_AUTH_KEY as WHIP — not a separate mechanism. The only difference is whether verification is enforced, controlled by the srtPublishTokenRequired switch, which defaults to false. In other words, under today's production configuration, an SRT publish without a Token still gets through, falling back to SRT's own streamid user/pass (which amounts to no verification at that layer). This is a stopgap kept for compatibility with older ppobs builds and third-party SRT publishing tools that don't send a token — it is not a design decision that SRT doesn't need authentication. See §3 of docs/design/ppcdn-mmx-publish-whitelist.zh-CN.md for details.

3.2 Mechanism: one encryption pass gives confidentiality and tamper-resistance at once

  • Serialize the fields {uuid, appId, streamName, codec, iat, exp} to JSON as plaintext.
  • Encrypt the JSON with AES-256-GCM. Each encryption generates a new random nonce, encoded into the Token alongside the ciphertext; the nonce must be unique under one AES-GCM key, or confidentiality and integrity are badly broken. Use a CSPRNG, check for randomness-source errors, monitor key rotation, and avoid counter/fixed nonces that could repeat across implementations.
  • The current implementation turns the configured string into a 32-byte AES key via SHA-256(authKey). That's a deterministic hash mapping, not a salted, cost-parameterized password KDF, and adds no strength to a low-entropy passphrase. authKey must be CSPRNG-generated with enough entropy; if the product allows human passphrases, switch to Argon2id/scrypt/PBKDF2 with a separate salt. Different uses should also apply domain separation (e.g. HKDF) rather than reusing one raw key for other protocols.
  • GCM is an "authenticated encryption" (AEAD) algorithm: it carries an integrity check within the encryption, so any tampering with the ciphertext makes decryption fail outright — no separate signature field as in the MAC scheme. That's the essential difference from the playback-side HMAC: HMAC is two-part "plaintext + separate signature", GCM folds encryption and tamper-resistance into one operation.
  • After decryption the expiry is also checked; because codec is bound, a Token signed only for the h264 path is rejected when used against the hevc path — in multitrack, the two codecs' publish rights never cross.

AES-GCM provides only confidentiality, integrity and authenticity from a shared key; it does not provide replay protection. The WHIP Token is likewise a bearer token: stolen before exp, it can be replayed. Beyond TLS, short TTL and log redaction, high-risk publishing should use server-side one-time jti/nonce consumption records, or bind the credential to a device-held key and a specific session. Also validate iat, exp, allowed clock skew, target app/stream/codec and necessary action/audience; every node's clock must stay synchronized.

3.3 Two mechanisms, two design goals

Playback (txTime/txSecret) Ingest (WHIP Token)
Crypto primitive HMAC-SHA256 (shared-key MAC) AES-256-GCM (authenticated encryption)
Content in plaintext? Yes (path, expiry plaintext; MAC resists tampering) No (claims are encrypted)
Who can verify Any holder of appSecret can compute and verify Only the holder of the encryption key can decrypt
Key rotation Format carries keyId; verifier still needs a multi-version key store and revocation Current format has no keyId; rotation needs deployment coordination or a token-envelope upgrade
Design goal Lightweight, stateless, self-computable — good for short-lived playback links Confidentiality + tamper-resistance in one — good for sensitive structured fields
Replay property Replayable within validity unless one-time state or key binding is added Replayable within validity; a GCM nonce is not an anti-replay nonce

In one line: "only needs to authenticate content and can stay public" → MAC; "content must be confidential and tamper-proof" → authenticated encryption. Neither is inherently replay-proof for bearers; authorization scope and token lifetime still need separate design.


4. Diagrams

4.1 Playback-side MAC verification

Client requests playback
   │  carries Authorization: Bearer {appId}:{txTime}:{txSecret}
   ▼
Server parses keyId out of txSecret's "v1.{keyId}.{hex}" form
   │
   ▼
Fetch that appId's single appSecret (no per-keyId lookup across multiple
key versions exists today, see §2.3)
   │
   ▼
Compute HMAC-SHA256(keyId + ":" + trim(streamID,"/") + ":" + upper(txTime)) with canonical rules
   │
   ▼
Constant-time compare against the client signature, bit by bit
   │
   ├─ expired                   ──▶ return "expired" (a distinct error from the one below)
   ├─ not expired, but mismatch ──▶ return a uniform "invalid signature" (an unknown appId gets
   │                                  the same response, with no further distinction)
   └─ not expired and matches   ──▶ pass, return the playback address

4.2 Key rotation: keyId only works this way once paired with a multi-version key store (not the case today)

This diagram shows the target design, not ppcenter's behavior today

Per the code check in §2.3, ppcenter today stores exactly one AppSecret per app, and every signing call site hardcodes keyId = "default". What follows describes how keyId is meant to support rotation once a multi-version key store is wired in — understanding it clarifies "why this field exists", but planning a production rotation around this diagram would fail today, since looking up an old key by keyId and revoking by version are both still unimplemented on the backend.

Timeline ──────────────────────────────────────────────▶

  [old key keyId=2026-06 active]
        │
        │  links signed with the old key are still in viewers' hands, until their txTime
        │
        ▼
  [rotation: new key keyId=2026-07 starts signing new links]
        │
        ├─ new requests ──▶ signed with keyId=2026-07
        │
        └─ old links (keyId=2026-06) ──▶ server keeps the old key, verifies them normally
        │
        ▼
  [all links for the old key expire naturally]
        │
        ▼
  [ops can safely retire keyId=2026-06]

4.3 Ingest Token: encrypt and decrypt

ppcenter issues an ingest Token
   │
   │  plaintext claims: {uuid, appId, streamName, codec, iat, exp}
   ▼
Generate a unique random nonce, run AES-256-GCM
   │  current key mapping = SHA-256(authKey); authKey must be a high-entropy random secret
   │
   ▼
Hand the ciphertext Token to ppobs

ppobs starts a WHIP publish request with the Token
   │
   ▼
ppmmx decrypts with the same key via AES-256-GCM
   │
   ├─ decryption fails (ciphertext tampered, GCM check fails) ──▶ reject
   ├─ decrypts but time claims invalid (iat/exp/clock skew)   ──▶ reject
   ├─ decrypts but codec doesn't match the requested path     ──▶ reject
   ├─ jti already consumed (if one-time tokens are enabled)   ──▶ reject replay
   └─ all pass                                                ──▶ allow publish

5. Wrap-up and next episode (script notes)

This episode clarified the boundaries of the two security mechanisms: playback-side HMAC protects the integrity of plaintext claims, ingest-side AES-GCM provides confidentiality and integrity at once; the keyId field already reserves an interface for multi-version key rotation, but ppcenter today still has only a single AppSecret per app, hardcoded to default — true no-interruption rotation only becomes real once the backend wires in a multi-version key store. A random GCM nonce addresses the uniqueness encryption needs, not bearer replay. Both credentials require TLS, short TTLs, strict claim binding and log redaction; to be non-replayable you must add server-side one-time state or a holder-key binding.

Next episode, back to weak networks — beyond the protocol-layer degradation from EP04, what other engineering measures strengthen a live stream's weak-network resilience?