Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ZemerTeam/zemer-cipher/llms.txt

Use this file to discover all available pages before exploring further.

YouTube streaming URLs do not arrive ready to use. Each URL contains an s= query parameter holding an obfuscated signature that must be decoded before the CDN will serve any data. Until the correct signature parameter value is appended, every request to that URL returns a 403. Zemer Cipher decodes the signature by executing the same JavaScript function that the official YouTube player would use — inside an Android WebView with network access blocked.

The signatureCipher Parameter

The signatureCipher value attached to a stream format is a URL-encoded query string with three components:
s=<obfuscated>&sp=signature&url=<base_url>
ParameterMeaning
sThe obfuscated signature. Its characters have been scrambled by a per-player JavaScript function.
spThe name of the query parameter to attach the result to. Almost always signature.
urlThe base CDN URL. The final playable URL appends sp=<deobfuscated_s> with & or ? as the separator depending on whether url already contains a query string.
The s value is different for every video and changes every time YouTube rotates its player JavaScript. Only the correct deobfuscated value will be accepted by the CDN; any other value produces a 403.

How Deobfuscation Works

Zemer Cipher follows a five-step pipeline each time a stream URL must be decoded: 1. Fetch the player JS PlayerJsFetcher first requests https://www.youtube.com/iframe_api to extract the current player_ias.vflset hash, then downloads the actual player JS (~2.8 MB) from the CDN. The downloaded file is cached for 6 hours so subsequent deciphers within the same session reuse it without a network round-trip. 2. Identify the signature function FunctionNameExtractor.extractSigFunctionInfo() resolves which function to call and how to call it:
  • The player hash is looked up in PlayerConfigStore. If a config entry is found, the hardcoded sig expression (e.g. mP(4,155,INPUT)) is used directly — no scanning of the JS source required.
  • If no config entry exists, a set of legacy regex patterns scans the player JS to extract the function name heuristically.
3. Inject and load into a WebView CipherWebView appends the sig function export inside the player’s IIFE closure, just before the closing })(_yt_player);:
window._cipherSigFunc = function(sig) {
  try { return mP(4, 155, sig); } catch(e) { return null; }
};
The modified player JS is written to a cache file and loaded into a WebView via loadDataWithBaseURL. Network access is unconditionally blocked (blockNetworkLoads = true) so the WebView cannot make outbound requests. 4. Call the function via the Java↔JS bridge deobfuscateSignature(obfuscatedSig) dispatches a deobfuscateSig(...) call through WebView.evaluateJavascript. The result is delivered back to Kotlin through the CipherBridge @JavascriptInterface, which resumes the suspended coroutine with the decoded string. 5. Build the final URL The deobfuscated signature is URL-encoded with Uri.encode and appended to the base URL:
val separator = if ("?" in baseUrl) "&" else "?"
val finalUrl = "$baseUrl${separator}${sigParam}=${Uri.encode(deobfuscatedSig)}"
The resulting URL is ready to pass directly to a media player or HTTP client.

Config-Driven vs. Regex Fallback

Hardcoded player configs are always preferred over legacy regex patterns:
  • Config entries are validated against the live CDN before they are committed — validation checks that the deciphered URL returns HTTP 206. A config entry is ground-truth: it is known to produce an accepted signature.
  • Regex patterns are heuristics. They scan unanchored across ~2 MB of minified JavaScript and can false-match on similar-looking constructs that happen to be in the wrong context. A false-positive regex match will produce a non-throwing but wrong result — one that the WebView executes without error but that the CDN rejects.
When a player hash is unknown and regex extraction succeeds, the extraction is still treated as incomplete (isHardcoded = false). This triggers a remote config refresh in PlayerConfigStore.forceRefresh(), so if a validated entry for the new player has already been pushed to the repository, it is pulled in and the WebView is rebuilt before the next decipher attempt.

Security

Every sig value from a remote player config is validated by PlayerConfigParser before it is used. The value must match:
^[A-Za-z0-9$_]{1,8}\(\d+,\d+,INPUT\)$
This regex locks the shape to a single function call of the form name(int,int,INPUT). No free-form JavaScript, no chained expressions, no string literals. A remote config that contains anything outside this shape is rejected — the individual entry is skipped, or the whole file is rejected if the file-level checks fail. The INPUT placeholder is replaced locally in CipherWebView.buildModifiedPlayerJsImpl. The substitution uses a fixed template string; the remote config never controls the injection template itself.
The signatureTimestamp (sts) from the player config must be sent in the /player InnerTube request body. Use CipherDeobfuscator.signatureTimestamp() to retrieve the correct value for the currently loaded player. During A/B rollouts, the player fetched by a third-party extractor may differ from the one Zemer Cipher is using — sending a mismatched sts value causes the CDN to 403 the deciphered stream.

Build docs developers (and LLMs) love