Repository: streamlink/streamlink

Issue:
plugins.twitch: no playable streams on this url 2023-05-31
### Checklist

- [X] This is a plugin issue and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest build from the master branch

### Description

twitch streams are no longer loading for me, giving the message `No playable streams found on this url`. Tested on both latest release and master.

In addition I tried disabling twitch-specific options in the debug log like `twitch-disable-ads` and `twitch-low-latency` but the same error happens regardless.

### Debug log

```text
[cli][debug] OS:         Linux-6.3.2-arch1-1-x86_64-with-glibc2.37
[cli][debug] Python:     3.11.3
[cli][debug] Streamlink: 5.5.1+41.g9e05e321
[cli][debug] Dependencies:
[cli][debug]  certifi: 2022.12.7
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.2
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.17
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.28.2
[cli][debug]  urllib3: 1.26.15
[cli][debug]  websocket-client: 1.5.1
[cli][debug]  importlib-metadata: 5.0.0
[cli][debug] Arguments:
[cli][debug]  url=https://twitch.tv/gamesdonequick
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player=mpv
[cli][debug]  --default-stream=['best']
[cli][debug]  --twitch-disable-ads=True
[cli][debug]  --twitch-low-latency=True
[cli][info] Found matching plugin twitch for URL https://twitch.tv/gamesdonequick
[plugins.twitch][debug] Getting live HLS streams for gamesdonequick
error: No playable streams found on this URL: https://twitch.tv/gamesdonequick
```



Discussion:
Twitch killed all third party tools and adblockers by this recent change, this is the response of the GQL (where the playlist is fetched) of anyone using streamlink

```json
{
    "data": {
        "streamPlaybackAccessToken": null
    },
    "errors": [
        {
            "message": "failed integrity check",
            "path": [
                "streamPlaybackAccessToken"
            ]
        }
    ],
    "extensions": {
        "challenge": {
            "type": "integrity"
        },
        "durationMilliseconds": 1,
        "operationName": "PlaybackAccessToken",
        "requestID": "01H1SYB01BD45P11TQ7CMFT3G6"
    }
}
```

As you can see they added some sort of integrity check (`"message": "failed integrity check"`), i don't really know if it can be bypassed.

*I just wanted to see forsen and streamlink never opened* :(

**Edit**: Even if you are subscribed to a channel and you have configured the `--twitch-api-header=Authorization=OAuth <token>` argument, you will not be able to open the stream.
Before we get too many comments here I'm going to ask that people only add a comment if they have useful and relevant data. There is no need for "me as well" or "I'm also running in to this" comments and those will be removed to keep the thread clean. I will pin this issue to try and avoid duplicate issues being created. Any duplicate issues will be closed and marked as a duplicate.

----

edit by @bastimeyer 

# Please don't turn this into a help thread. If you want to discuss anything off-topic, then please use the Streamlink Matrix/Gitter channel
https://matrix.to/#/#streamlink_streamlink:gitter.im

Otherwise, we'll have to lock this thread, which would be annoying because this issue is still very much unsolved.

----

**See https://github.com/streamlink/streamlink/issues/5370#issuecomment-1572576728 for a temporary solution** (Twitch API Client-ID of the Nintendo Switch)
Looks like the `PlaybackAccessToken` GQL query requires the `Client-Integrity` HTTP header.
I can see two requests being made by my web browser, where one request fails with the same GQL error response and another one with that header succeeds. I have no idea yet where the header value is coming from, but its format is
```
v4.public.SOME-LONG-BASE64-VALUE
```

I had just gone to bed...
Minimum set of headers that seem to be checked as of now are `Authorization` (oauth token), `Client-Integrity` (expires every 16 hours based on `https://gql.twitch.tv/integrity`
 response), `X-Device-Id` (unsure of expiry)

More information on the integrity token, it appears to be a jwt token with IP, user id, device id, and some sort of bot detection field

https://github.com/mauricew/twitch-graphql-api#integrity

edit: X-Device-Id needs to match the device id in Client-Integrity
not sure if related, but firefox also gets black screens on all twitch streams (not vods) as of today if you have `resistFingerprinting` in enabled
I'm also half in bed, so sorry for the "not that precise answer", but maybe it can give you some info.

```
curl 'https://gql.twitch.tv/integrity' -X POST -H 'Client-Id: xxxx'
```

Returns a token, but it is shorter than the full token. Full token is returned by

```
curl 'https://gql.twitch.tv/integrity' -X POST -H 'Client-Id: xxx' -H 'X-Device-Id: xxx' -H 'Client-Request-Id: xxx' 
```

I do not know if the shorted token would work. I'll try to figure out what those `-Id` needs to be.

The `X-Device-Id` is the id set by the `unique_id` cookie on the initial html document response.

The `Client-Id` is the id of the client, that is hardcoded in the plugin <https://github.com/streamlink/streamlink/blob/9e05e321b013f8966fd932405f2e6b367159bb24/src/streamlink/plugins/twitch.py#L254> 

Not sure about request id.
Without X-Device-Id, the integrity token has `"is_bad_bot": "true"` set, which blocks it from getting the playlist

edit: i have yet to figure out the right combination to get `is_bad_bot: false`
This is from the deobfuscated JS code
```js
                            return "https://gql.twitch.tv/integrity",
                            n = {
                                "Client-Id": this.config.authSettings.clientID,
                                "X-Device-Id": this.session.deviceID,
                                "Client-Request-Id": e,
                                "Client-Session-Id": this.session.appSessionID,
                                "Client-Version": this.config.buildID
                            },
                            this.authToken && (n.Authorization = "OAuth ".concat(this.authToken)),
                            [4, fetch("https://gql.twitch.tv/integrity", {
                                headers: n,
                                method: "POST"
                            })];
```

- `Client-Id` is the regular one
- `X-Device-Id` is the `unique_id` cookie set when accessing `https://twitch.tv/` for the first time
- `Client-Request-Id` is a randomized 32 bytes long alphanumerical string (random for each request)
- `Client-Session-Id` is a randomized 16 bytes long hex string (stays for the entire session)
- `Client-Version` is a uuid4 string, can be read from embedded JS on `https://twitch.tv/` or from the JSON response from `https://static.twitchcdn.net/config/manifest.json?v=1`

No success so far though. I'm getting an integrity token, but it's not valid when trying to get an access token
related https://github.com/Kappador/twitch-integrity/issues/1

this may have to do with kasada as well, they gave up on it because they got in an arms race with kasada's bot detection
> not sure if related, but firefox also gets black screens on all twitch streams (not vods) as of today if you have `resistFingerprinting` in enabled

I have "privacy.resistFingerprinting = True" in 'about:config', and I can load twitch streams just fine.  I cannot confirm this black screen behavior.
![Untitled](https://github.com/streamlink/streamlink/assets/135182844/8e3e2891-0f5a-4af4-9874-9000662c0c58)

Even replaying an api request to `https://gql.twitch.tv/integrity` (copy as curl with all headers) gets me a token with `is_bad_bot: true` set.

edit: they're base64 encoded jwt tokens that's where the attributes come from
Some more details here, regarding the kasada bot detection. https://github.com/yt-dlp/yt-dlp/issues/7183#issuecomment-1571109121
> Prior research on this integrity check, including knowledge of why `privacy.resistFingerprinting` is implicated: https://digipres.club/@moralrecordings/109496127240969369
> 
> GitHub repo that appears to mention the same fields, and points fingers at which anti-automation framework is involved: https://github.com/unicorn-aio/kpsdk


In order to get a valid integrity token, you'll also need the `x-kpsdk-cd`, `x-kpsdk-ct`, `x-kpsdk-v` headers ("kasada bot detection").

Here's the relevant JS section
```js
            n.prototype.loadKPSDK = function() {
                return (0,
                i.__awaiter)(this, void 0, Promise, (function() {
                    var e, n = this;
                    return (0,
                    i.__generator)(this, (function(t) {
                        return this.loadPromise || (e = !1,
                        this.loadPromise = new Promise((function(t, r) {
                            var a = performance.now()
                              , o = function(e) {
                                return n.trackKPSDKLatency(a, e)
                            };
                            document.addEventListener(f.Load, (function() {
                                var r;
                                if (!e) {
                                    o(g.Loaded);
                                    var a = (0,
                                    i.__spreadArray)([], n.endpointsToProtectFromConstructor, !0);
                                    if ((0,
                                    N.t7)("auth_kpsdk_load")) {
                                        var s = new URL(_e.config.passportBaseURL);
                                        a.push({
                                            protocol: s.protocol,
                                            method: "POST",
                                            domain: s.host,
                                            path: "/integrity"
                                        })
                                    }
                                    null === (r = window.KPSDK) || void 0 === r || r.configure(a),
                                    a.length || t()
                                }
                            }
                            )),
                            document.addEventListener(f.Ready, (function() {
                                e || (o(g.Ready),
                                t())
                            }
                            ));
                            var s = n.dynamicSettings.get("kpsdk_timeout", 15e3);
                            setTimeout((function() {
                                n.isKPSDKReady() || (o(g.Timeout),
                                e = !0,
                                r())
                            }
                            ), s);
                            var l = document.createElement("script");
                            l.src = "https://k.twitchcdn.net/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/p.js",
                            document.body.appendChild(l)
                        }
                        ))),
                        [2, this.loadPromise]
                    }
                    ))
                }
                ))
            }
```

Which loads the following JS script (this will probably change a lot with lots of updates)
`https://k.twitchcdn.net/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/p.js`
and which adds the `window.KPSDK` object.

And if you take a look at this, you'll see that a JS runtime is required.

# R I P
@bastimeyer Would this work?  `https://github.com/PiotrDabkowski/Js2Py`
The language is not an issue here. These anti-bot frameworks utilize subtle differences in browser JavaScript engines to verify whether it's a real browser. They probably also use TLS fingerprinting on the server side to check whether the request came from a real browser (not checked on Twitch, although this would be fairly trivial to bypass).

I can confirm I can fetch a valid integrity token using a headless Chrome with a few modifications, e.g., screen size.

(Adding here to not clutter the discussion)

> Assuming python gets access to a browser, could it lift the credentials from the browser after the stream is opened there to hand it off to streamlink?

> Couldn't we pass integrity check through chromium and then send stream URL to player?

If you have access to a browser, you can just get a m3u8 stream link. The whole purpose of these projects is to eliminate the need for a browser.
> Would this work?

No, because of ES6 (you can't just transpile it to ES5) and there's also no support for the DOM (I can see DOM nodes and DOM event stuff). This is meant to be executed by web browsers.

Have a look with "proper" formatting and synax highlighting:
https://gist.github.com/bastimeyer/d3bd77fbb6afc87460b2985393614caa

That's very likely it for the Twitch plugin and also for my Twitch GUI app...

I'm going to bed now...

----

> TLS fingerprinting

That isn't something which could be solved here anyway, if they actually do that. That'd have to be resolved by urllib3 or by httpx.
> I can confirm I can fetch a valid integrity token using a headless Chrome with a few modifications, e.g., screen size.

Couldn't we pass integrity check through chromium and then send stream URL to player?
@thinkpad4 usually, the issue is not the javascript engine (you could use quickjs from https://bellard.org ) but the web engine.
Gotcha @sylware 
I'd be surprised if this was useful, but I still have a streamlink session open to Twitch.  It should remain up for the next several days (fingers crossed).  I imagine it'd be tough connecting to the running Python process--but I believe it's doable if necessary.
What is the intention behind this? Keeping the bots away? That won't work. The code is obfuscated, but you can always reverse engineer it because it's also public, otherwise the concept of the service doesn't work. All they are doing is buying a little time. DOMReady and the pseudo encryption can be implemented as well.

A ton of obfuscation won't save Twitch on that front. Now they've burned through a lot of money again just to get this monstrosity done. The timing is also peculiar, right before Blizzard releases D4. In the end, streamers are just losing viewers. Is that the goal? Fewer viewers?
@thinkpad4 they want to force you to use their big tech web engines (blink/webkit) and will do anything to "break" any alternative, usually the msft way: adding complexity and size until nobody can replicate, goodely enough, the dynamic behavior... which would be what is fingerprinted.
@git42leech they want to sell ads, so they thought removing bots would restore the trust of the advertisers, which is wrong or malicious: the first one the advertisers would not trust is twitch itself as they can forge their metrics much more easily than sellers of viewers, for their own benefits or their friends'.

The only reliable metrics for the advertisers on internet are their actual sales.

Yep, this is plain wrong, and malicious as it tries to trick advertisers into giving more trust in their own metrics.

Or they want to reduce the cost of their bandwidth by removing bots traffic.
Let's try to stay on topic with the issue at hand being how to work with the integrity token rather than clog up the thread with comments bashing Twitch.
I don't understand about these things but if it helps in any way I'll post the request made by the Twitch app for Android. I will remove some values as they may be sensitive to my account.
```
POST /gql HTTP/1.1
X-APOLLO-OPERATION-ID: [REDACTED]
X-APOLLO-OPERATION-NAME: StreamAccessTokenQuery
Accept: multipart/mixed; deferSpec=20220824, application/json
X-APOLLO-CAN-BE-BATCHED: false
X-APOLLO-OPERATION-ID: [REDACTED]
X-APOLLO-OPERATION-NAME: StreamAccessTokenQuery
Accept: multipart/mixed; deferSpec=20220824, application/json
Content-Type: application/json
Content-Length: 268
Host: [gql.twitch.tv](http://gql.twitch.tv/)
Connection: Keep-Alive
Accept-Encoding: gzip
Client-ID: [REDACTED]
Authorization: OAuth [REDACTED]
Accept-Language: pt-br
X-Device-ID: [REDACTED]
Api-Consumer-Type: mobile; Android/1502010
X-App-Version: 15.2.1
Client-Session-Id: [REDACTED]
User-Agent: Dalvik/2.1.0 (Linux; U; Android 8.1.0; SM-J710MN Build/M1AJQ) [tv.twitch.android.app/15.2.1/1502010](http://tv.twitch.android.app/15.2.1/1502010)

{"operationName":"StreamAccessTokenQuery","variables":{"channelName":"olkabone","params":{"platform":"android","playerType":"mobile_player"}},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"4aeb12b75899a00ed245a3bd272047407f60a15f473caaa8a8e64719b6f6a8d5"}}}
```
FWIW, Android client still works https://github.com/crackededed/Xtra so you can look how they extract streams.
@jebibot true. But for the purposes of @bastimeyer's GUI project, a userscript or something that reduces browser use to once/16hr by spitting out 5000 base64 chars encoding all this week's secret handshakes would be better than nothing. For the moment though, could you share code snippets describing how you coerced valid tokens out of a headless chrome? I have my own wrapper script I'd like to repair.
> FWIW, Android client still works https://github.com/crackededed/Xtra so you can look how they extract streams.

I noticed this earlier. However their ability to proxy the m3u8 request is broken or luminous-ttv's public server is down. 
>luminous-ttv's public server is down

luminous-ttv is broken due to the changes (source: am its developer), as well as TTV-LOL v1 and similar services
Hello, everyone. 

In fact, there is a solution...

first, Selenium or similar tools at the front end to receive the following header information: `Authorization`, `Client-Id`, `Client-Integrity`, `X-Device-Id`. 
second, with this information, you can construct and execute the CLI command as shown below.

```
streamlink "twitch.tv/STREAMER_NAME" "best" -o "output.ts" \
--twitch-api-header="Authorization=OAuth [REDACTED]" \
--twitch-api-header="Client-Id=[REDACTED]" \
--twitch-api-header="Client-Integrity=[REDACTED]" \
--twitch-api-header="X-Device-Id=[REDACTED]"
```

Please note that the headers obtained using this method are valid for **only 16 hours**. 
Keep this in mind and understand that this is not a fundamental solution.

anyway,,, I hope you have a great day! thanks :)
> 
> And if you take a look at this, you'll see that a JS runtime is required.
> # R I P

Do you think it is worth it to try to write a tool, using bun for example, that would run this JS and get the integrity header? I do not know exactly how strong this bot detection is.
Potential relevant issue regarding to kdsdk

https://orionfeedback.org/d/4601-twitch-stopped-to-recgonise-the-browser-independent-of-configuration/21

https://github.com/WebKit/WebKit/pull/10760
> Hello, everyone.
> 
> In fact, there is a solution...
> 
> first, Selenium or similar tools at the front end to receive the following header information: `Authorization`, `Client-Id`, `Client-Integrity`, `X-Device-Id`. second, with this information, you can construct and execute the CLI command as shown below.
> 
> ```
> streamlink "twitch.tv/STREAMER_NAME" "best" -o "output.ts" \
> --twitch-api-header="Authorization=OAuth [REDACTED]" \
> --twitch-api-header="Client-Id=[REDACTED]" \
> --twitch-api-header="Client-Integrity=[REDACTED]" \
> --twitch-api-header="X-Device-Id=[REDACTED]"
> ```
> 
> Please note that the headers obtained using this method are valid for **only 16 hours**. Keep this in mind and understand that this is not a fundamental solution.
> 
> anyway,,, I hope you have a great day! thanks :)

I can confirm this works, just open any stream while logged into your account in any browser that supports "Network Inspector" like tools, copy the necessary headers out, and I'm getting the actual stream retrieved.

The "valid only every 16 hours" thing is definitely going to be obnoxious to deal with, but I'm able to get the neccesary headers in Firefox that's loaded down with NoScript and µBlock Origin still operating over the entire page.
I hate to ask but where do I find this information exactly in the browser console tools ? i looked everywhere i possibly can like in "storage inspector" where it lists all the websites - player.twitch.tv, gql.twitch.tv, etc and i cant find ANY entries that say "client id", client integrity, or X-device-id 
i found API token and Auth Token, and all my stuff like my username and and so on, but those other 3 entries are no where to be found whatsoever. And i dont know how exactly where to go to get those values or info. or what to type in the "console" box. I tried the one mentioned in the streamlink documents that mentions how to get your Auth token, and that works. But that's it. 
I apoligize for my newbie-ness, i dont know anything about javascript and other programming languages on how to find this information. 

https://i.ibb.co/p1Q6sKJ/twitch-stuff.png

where do i go to find those missing values ? :( (x device, client id, and client integrity) i already tried the front twitch page and they dont show up there either.
@kujakiller go to network, search GQL, click a request with POST, look at the headers part, copy from there
![headers](https://github.com/streamlink/streamlink/assets/48969632/0ece5722-2f61-4c36-942b-46275313265c)

# Please don't turn this into a help thread. If you want to discuss anything off-topic, then please use the Streamlink Matrix/Gitter channel
https://matrix.to/#/#streamlink_streamlink:gitter.im

Otherwise, we'll have to lock this thread, which would be annoying because this issue is still very much unsolved.

----

> In fact, there is a solution...

Yes, as mentioned earlier, but I don't know how realistic it is to use Selenium for that and ship it with Streamlink on all platforms.
Fetching the access token like that btw is exactly the same as getting the HLS URL directly.

I'll have to leave the house now and will be home later. I already have a local branch which sets all those headers, as mentioned earlier, but the integrity token obviously requires external tools.
@KynikossDragonn 

Hmm... I don't want to discourage you, but the truth is, this is an incomplete solution.
Among these headers, the Client-Integrity has the characteristic of changing for each public IP address.
So, for example, if you copy and execute the header value on an AWS EC2 instance, it won't work.

Therefore, tools like Selenium are necessary, and these web tools have a larger scope than the problem itself, making it difficult to call them a proper solution.

It would be better to consider it as simply a temporary measure or emergency treatment.

I would prefer not to provide specific instructions on how to use this solution.
> Hello, everyone.
> 
> In fact, there is a solution...
> 
> first, Selenium or similar tools at the front end to receive the following header information: `Authorization`, `Client-Id`, `Client-Integrity`, `X-Device-Id`. second, with this information, you can construct and execute the CLI command as shown below.
> 
> ```
> streamlink "twitch.tv/STREAMER_NAME" "best" -o "output.ts" \
> --twitch-api-header="Authorization=OAuth [REDACTED]" \
> --twitch-api-header="Client-Id=[REDACTED]" \
> --twitch-api-header="Client-Integrity=[REDACTED]" \
> --twitch-api-header="X-Device-Id=[REDACTED]"
> ```
> 
> Please note that the headers obtained using this method are valid for **only 16 hours**. Keep this in mind and understand that this is not a fundamental solution.
> 
> anyway,,, I hope you have a great day! thanks :)

But you cannot have a valid `Client-Integrity` <-> `X-Device-Id` when you load the Twitch using headless browsers, because they are considered as "bad-bot"s in the integrity check and you'll receive an error when you pass them to `/gql` endpoint.
> > Hello, everyone.
> > In fact, there is a solution...
> > first, Selenium or similar tools at the front end to receive the following header information: `Authorization`, `Client-Id`, `Client-Integrity`, `X-Device-Id`. second, with this information, you can construct and execute the CLI command as shown below.
> > ```
> > streamlink "twitch.tv/STREAMER_NAME" "best" -o "output.ts" \
> > --twitch-api-header="Authorization=OAuth [REDACTED]" \
> > --twitch-api-header="Client-Id=[REDACTED]" \
> > --twitch-api-header="Client-Integrity=[REDACTED]" \
> > --twitch-api-header="X-Device-Id=[REDACTED]"
> > ```
> > 
> > 
> >     
> >       
> >     
> > 
> >       
> >     
> > 
> >     
> >   
> > Please note that the headers obtained using this method are valid for **only 16 hours**. Keep this in mind and understand that this is not a fundamental solution.
> > anyway,,, I hope you have a great day! thanks :)
> 
> But you cannot have a valid `Client-Integrity` <-> `X-Device-Id` when you load the Twitch using headless browsers, because they are considered as "bad-bot"s in the integrity check and you'll receive an error when you pass them to `/gql` endpoint.

Not really, there's a tons of solutions to deep fake real browser from headless, see Undetected ChromeDriver.

But this should not be our final solution because it's too heavy.

Python does support faking real web browser by using tls-client library: https://github.com/FlorianREGAZ/Python-Tls-Client

Which is a choice for streamlink, but we should be able to find a better solution without using any dependency.
Pepega Clap

```js
// Hardcoded
const challengeType = {
	difficulty: 10,
	platformInputs: "tp-v2-input",
	subchallengeCount: 2
};

// https://passport.twitch.tv/<uuid-v4>/<uuid_v4>/fp?x-kpsdk-v=j-0.0.0
	// Then get KPSDK.message
const rawMessage = 'KPSDK:DONE:XXX::true:2:999';

const challengeHeader = {
	FIELD_NAME_CHALLENGE_DATA: "kpsdkCd",
	KPSDK_HEADER_CHALLENGE_DATA: "x-kpsdk-cd",

	// Other data as I don't know where to put them
	KPSDK_HEADER_CLIENT_TOKEN: "x-kpsdk-ct",
	KPSDK_HEADER_VERSION: "x-kpsdk-v"
};

let serverConfig = {}; // Initialized by parseMessageConfig

function parseMessageConfig(msg) {
	if ("string" == typeof msg && "KPSDK:DONE" === msg.substring(0x0, 0xa)) {
		let splitMsg = msg.split(":").map(decodeURIComponent)
			, encodedClientToken = splitMsg[2]
			, cryptoChallengeEnabled = splitMsg[4]
			, serverTime = splitMsg[6];

		return {
			encodedClientToken: encodedClientToken.length > 0 ? encodedClientToken : void 0x0,
			cryptoChallengeEnabled: "true" === cryptoChallengeEnabled,
			serverTime: Number(serverTime)
		};
	}
}

const challengeModuleV2Data = {
	time: 0, // _0x33781b
	serverTime: 0, // _0x5b0222
	deltaArr: []
};

const challengeModuleV2 = {
	headerNames: ["x-kpsdk-cd"],
	formFieldNames: challengeHeader.FIELD_NAME_CHALLENGE_DATA
};

let globalOffset = 0;

function calcGLobalOffset(time, serverTime) {
	let delta = time - serverTime; // "time" is Date.now() I think

	challengeModuleV2Data.time = time;
	challengeModuleV2Data.serverTime = serverTime;
	challengeModuleV2Data.deltaArr.push(delta);

	if (challengeModuleV2Data.deltaArr.length === 1)
		globalOffset = delta;
	else
		globalOffset = Math.round(challengeModuleV2Data.deltaArr.length / challengeModuleV2Data.deltaArr.reduce(function(accumulator, currentValue) {
			return accumulator + 0x1 / currentValue;
		}, 0));
}

function makeId() {
	for (var str = '', i = 0x0; i < 0x20; i += 0x1)
		str += ('0123456789abcdef')[Math.floor(0x10 * Math.random())];
	return str;
}

function sha256(str) {
	// Should be any generic sha256 function (https://emn178.github.io/online-tools/sha256.html)
	// Sample implementation taken from https://github.com/brillout/forge-sha256/blob/master/build/forge-sha256.js
	return window.forge_sha256(str);
}

function calcDifficulty(hash) {
	return 0x10000000000000 / (Number('0x'.concat(hash.slice(0x0, 0xd))) + 0x1);
}

function calcHash(challengeTypeObj, delta, currId) {
	// Random value for testing I guess
	// "tp-v2-input, 1685607313121, 075695085525b25727553b31e7403f38" (everything within quotes) =>
		// 912b55b6376f7b286ef298b8028d67176fd1a3987aa95c1a951c40e3fd73e6b1

	for (
		var currHash = sha256("".concat(challengeTypeObj["platformInputs"], ", ", delta, ", ", currId)),
			actualDiff = challengeTypeObj["difficulty"] / challengeTypeObj["subchallengeCount"],
			answerArr = [],
			j = 0; j < challengeTypeObj["subchallengeCount"]; j += 1
	) {
		for (var i = 1; ; ) {
			var tempHash = sha256("".concat(i, ", ", currHash));
			if (calcDifficulty(tempHash) >= actualDiff) {
				answerArr.push(i),
				currHash = tempHash;
				break;
			}
			i += 1;
		}
	}

	return {
		answers: answerArr,
		finalHash: currHash
	};
}

function solveChallengeRaw(challengeTypeObj, serverOffset, testId = undefined) { // testId for testing with random id
	var time = Date.now()
		, beforeTime = performance.now()
		, delta = time - serverOffset
		, currId = testId === undefined ? makeId() : testId
		, resHash = calcHash(challengeTypeObj, delta, currId)
		, afterTime = performance.now()
		, duration = Math.round(1000 * (afterTime - beforeTime)) / 1000;

	return {
		workTime: delta,
		id: currId,
		answers: resHash.answers,
		duration: duration,
		d: serverOffset,
		st: 0x0,
		rst: 0x0
	};
}

function solveChallenge(challengeType) {
	let res = solveChallengeRaw(challengeType, globalOffset);
	res.st = challengeModuleV2Data.serverTime;
	res.rst = challengeModuleV2Data.time;
	return res;
}

function makeSolution(msg, headers, challengeType) {
	/*
	"headers" should be something like this:
	{
		"Client-Id": "xxx",
		"X-Device-Id": "xxx",
		"Client-Request-Id": "xxx",
		"Client-Session-Id": "xxx",
		"Client-Version": "xxx"
	}
	//*/

	let tempData = parseMessageConfig(msg);
	tempData.solvedChallenge = solveChallenge(challengeType);
	return {
		interceptData: tempData,
		init: {
			"headers": headers,
			"method": "POST"
		}
	}
}

function makeFeatureFlag() {
	return {
		featureFlags: {
			"interception.version-header": true,
		},
		dynamicConfig: {
			frontend: {
				collectErrors: true,
				collectTimings: true
			}
		}
	};
}

function makeHeadersChallenge(solveSolution) {
	// "solveSolution" are from solveChallenge(...)
	return [challengeHeader.KPSDK_HEADER_CHALLENGE_DATA, JSON.stringify(solveSolution)];
}

function makeHeadersClient(solveData) {
	return [challengeHeader.KPSDK_HEADER_CLIENT_TOKEN, solveData.encodedClientToken];
}

function makeGqlHeader(solution) {
	// "solution" are from makeSolution(...)

	return [
		makeHeadersChallenge(solution['solvedChallenge']),
		makeHeadersClient(solution.interceptData),
		[challengeHeader.KPSDK_HEADER_VERSION, 'j-0.0.0']
	];
}
```

Translated line by line from the obfuscated code. If anyone wants to test if headers are valid I guess.

Back to watching GDQ 💀 
If anyone has the chance to clarify how that java script is used I'd greatly appreciate it. Else I'll just wait until a solution is implemented into a streamlink build. 
vaft script on https://github.com/pixeltris/TwitchAdSolutions seems work now
> vaft script on https://github.com/pixeltris/TwitchAdSolutions seems work now

After taking a look, if I understand it correctly, I think it doesn't solve the challenge, it seems to add a hook to the browser requests, and get the `Client-Integrity` out of the browser. So this doesn't help us.

The code @Trung0246 posted seems fairly simple to re-implement in python. But I wonder if there is a "gotcha" that would make it hard to run outside a browser. 

Maybe we can try first with a node js app, with the js code, and if we can get a working integrity header, we can rewrite it in python.
Something to watch out for would be other forms of browser fingerprinting we don't know about yet, and as mentioned much earlier in the thread from prior art on this work, that this ends up getting in an arms race with kasada.
> The code @Trung0246 posted seems fairly simple to re-implement in python.

This code is unfortunately not 100% complete, but it's a great help so far. I've been trying to implement the relevant KPSDK stuff in Python for the past two and a half hours or so, but there's still some stuff missing. Transpiling the obfuscated code by hand is really tedious and it already makes me want to write a BabelJS plugin for replacing all these stupid symbol lookups.

And yes, what's been posted here doesn't touch any DOM stuff, so a re-implementation in Python would be possible if this is indeed all that's needed for getting the integrity token request headers right.

All of this however comes down to the question of how often Twitch will make changes to the KPSDK. They are not using a third-party library like this for no reason. It's another game of cat and mouse, and it's an annoying one where figuring out changes is really hard. There's also the question when the hardcoded UUID4 strings on the "passport" message request will change. Currently those two seem hardcoded:
`149e9513-01fa-4fb0-aad4-566afd725d1b` and `2d206a39-8ed7-437e-a3be-862e0f06eea3`

The Selenium approach is also far from ideal and I'm not even sure if it's actually viable.

For now though I think trying to re-implement the `x-kpsdk-cd`, `x-kpsdk-ct` and `x-kpsdk-v` header values makes more sense.
Hm looks like there's some prior art, does these help? Just suddenly remembered I read somewhere about this instead of doing by hands like yesterday:

https://www.humansecurity.com/tech-engineering-blog/defeating-javascript-obfuscation
https://steakenthusiast.github.io/2022/05/21/Deobfuscating-Javascript-via-AST-An-Introduction-to-Babel/

Meanwhile I'll attempt to translate all relevant part, btw which part are still incomplete @bastimeyer ? Currently in the channel chat. Also somebody posted a Mastodon thread on some guys doing almost exact same thing like mine (I think in this issue, reposted here for bump): https://digipres.club/@moralrecordings/109496127240969369

Potential alternate approach: https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2/issues/94#issuecomment-1360683876
> Looks like the `PlaybackAccessToken` GQL query requires the `Client-Integrity` HTTP header. I can see two requests being made by my web browser, where one request fails with the same GQL error response and another one with that header succeeds. I have no idea yet where the header value is coming from, but its format is
> 
> ```
> v4.public.SOME-LONG-BASE64-VALUE
> ```


It looks like Paseto v4
 https://github.com/paseto-standard/paseto-spec/blob/master/docs/Rationale-V3-V4.md

It's likely issued by the server.
I would not recommend solving this by reverse engineering the JS challenge because the challenge is trivial to mutate indefinitely & perpetually. This is an arms race that Kasada will always win.

The other strategy, making the scrapers indistinguishable from humans browsing the web, is a bit tricky because the selenium-based `undetected-chromedriver` is too smelly of a dependency.

This could also be achieved by spawning a local server and pointing the user's browsers at it to grab the token. This workflow is standard today for apps implementing Single Sing-On, like the Hashicorp Vault.

The default system browser can be opened, or `--twitch-browser` setting can be added.

Sequence:
1. Start a server thread, bound to loopback on a random port. E.g. `http.server`
2. Start the browser in stand-alone mode. E.g. `chrome --new-window --app="https://localhost:port" --start-maximized --disable-extensions --disable-plugins`
3. Complete the Twitch challenge
4. Tare down the browser and server process
> > > Hello, everyone.
> > > In fact, there is a solution...
> > > first, Selenium or similar tools at the front end to receive the following header information: `Authorization`, `Client-Id`, `Client-Integrity`, `X-Device-Id`. second, with this information, you can construct and execute the CLI command as shown below.
> > > ```
> > > streamlink "twitch.tv/STREAMER_NAME" "best" -o "output.ts" \
> > > --twitch-api-header="Authorization=OAuth [REDACTED]" \
> > > --twitch-api-header="Client-Id=[REDACTED]" \
> > > --twitch-api-header="Client-Integrity=[REDACTED]" \
> > > --twitch-api-header="X-Device-Id=[REDACTED]"
> > > ```

I was able to get a stream playing with the windows-build but not with the linux build (both latest build) 

on linux in only get :

streamlink: error: argument --twitch-api-header: invalid keyvalue value: ''

the client-id stays the same across devices i noticed 

you can grab all the id`s and tokens via the dev-tools from chrome by filtering the network tab for "gql" and looking at the request headers
The passport/gql `/fp` endpoints seem to respond differently depending on your cookies, without proper `KP_UIDz`/`ga__12_abel` cookies it will respond with another obfuscated javascript blob instead of `KPSDK.message`. Those cookies seem to come from POSTing some encrypted blob to the `/tl` endpoint (probably the output of the `/fp` javascript). It seems like this process is only done once, and then subsequent calls to `/fp` just return the token directly (I guess the `KP_UIDz` cookie indicates some kind of proof-of-work to the server so you only have to do it once). There's definitely quite a few more layers to this than the above de-obfuscated javascript alludes to.
> Potential alternate approach: [rdavydov/Twitch-Channel-Points-Miner-v2#94 (comment)](https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2/issues/94#issuecomment-1360683876)

It seems that Client-ID they use is actually still completely unrestricted, specifying `--twitch-api-header Client-ID=ue6666qo983tsx6so1t0vnawi233wa` fixes it (seems to be the Client-ID from the Nintendo Switch?).

I can imagine this being another cat and mouse game though like with the old ad bypass headers.
> Hello, everyone.
> 
> In fact, there is a solution...
> 
> first, Selenium or similar tools at the front end to receive the following header information: `Authorization`, `Client-Id`, `Client-Integrity`, `X-Device-Id`. second, with this information, you can construct and execute the CLI command as shown below.
> 
> ```
> streamlink "twitch.tv/STREAMER_NAME" "best" -o "output.ts" \
> --twitch-api-header="Authorization=OAuth [REDACTED]" \
> --twitch-api-header="Client-Id=[REDACTED]" \
> --twitch-api-header="Client-Integrity=[REDACTED]" \
> --twitch-api-header="X-Device-Id=[REDACTED]"
> ```
> 
> Please note that the headers obtained using this method are valid for **only 16 hours**. Keep this in mind and understand that this is not a fundamental solution.
> 
> anyway,,, I hope you have a great day! thanks :)

Will the stream process end once the keys expire? Or are these needed only for the initial opening of the stream?

This is a real stick in the mud for someone who had this whole VOD backup process automated >.<




> > Potential alternate approach: [rdavydov/Twitch-Channel-Points-Miner-v2#94 (comment)](https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2/issues/94#issuecomment-1360683876)
> 
> It seems that Client-ID they use is actually still completely unrestricted, specifying `--twitch-api-header Client-ID=ue6666qo983tsx6so1t0vnawi233wa` fixes it (seems to be the Client-ID from the Nintendo Switch?).

Awesome, I tested it and indeed simply using the flag `--twitch-api-header Client-ID=ue6666qo983tsx6so1t0vnawi233wa` completely fixes streamlink and makes it work again. That's an easy fix! 😄 



> This is a real stick in the mud for someone who had this whole VOD backup process automated >.<

VODs aren't affected as far as I can tell (for the moment at least).
I previously had that as an `http-header` in my config.twitch file, but I had not considered making it a `twitch-api-header` too; the corresponding line would be

    twitch-api-header=Client-ID=ue6666qo983tsx6so1t0vnawi233wa

and for good measure, go ahead and add this line too:

    http-header=Client-ID=ue6666qo983tsx6so1t0vnawi233wa

That's a decent alternative to specifying command-line flags.
So to sum it up, there are hardcoded client ids for non-browser devices like tv, switch, etc that are not subject to the integrity check?
> > This is a real stick in the mud for someone who had this whole VOD backup process automated >.<
> 
> VODs aren't affected as far as I can tell (for the moment at least).

Sorry for my improper wording but I record the streams to get an unmuted copy of the VOD for video editing 
> So to sum it up, there are hardcoded client ids for non-browser devices like tv, switch, etc that are not subject to the integrity check?

I can indeed confirm that my Android-TV  who is not logged into twitch plays any stream i tested also an chrome incognito tab does play any stream!
Well, I'm glad we have a workaround for now. I tested it on my recording script and it works fine!
> So to sum it up, there are hardcoded client ids for non-browser devices like tv, switch, etc that are not subject to the integrity check?

Probably a matter of time before it, too, is subject to the integrity check. Not fond of the idea of using it, but probably will use it for the time being. 
> Probably a matter of time before it, too, is subject to the integrity check. Not fond of the idea of using it, but probably will use it for the time being.

likely depends on the app in question but it may not have a browser to easily do that, or twitch rotates the client ids, but that's speculation into the future for now. at least for now we have a workaround that currently works while we figure out the integrity check situation.
> does not work on my end..

Because you're using an outdated version or you're setting the CLI arguments wrong. See the install and CLI docs. It's not hard.

Just a quick reminder, don't turn this into a help thread. I will hide a couple of posts now, because it's derailing a bit. If you're having any problems setting the API headers, then ask on the Matrix/Gitter channel, as posted at the very top. Hope you understand that. We want to keep this thread at least a bit clean.
>just a quick reminder, don't turn this into a help thread. I will hide a couple of posts now, because it's derailing a bit. If you're having any problems setting the API headers, then ask on the Matrix/Gitter channel, as posted at the very top. Hope you understand that we want to keep this thread at least a bit clean.

Sorry my bad.. I deleted my postings that went off-topic
I can confirm that doing `streamlink --twitch-api-header Client-ID=ue6666qo983tsx6so1t0vnawi233wa https://www.twitch.tv/esl_csgo best` in the terminal has restored streaming functionality. I've put `--twitch-api-header Client-ID=ue6666qo983tsx6so1t0vnawi233wa` in Streamlink Twitch GUI's Settings -> Streaming -> Custom parameters and that restored its functionality as well. I am on Mac.
@Trung0246 Sorry about the incompleteness comment earlier. I have a tidied-up Python implementation here now which should set the correct headers. However, the passport.twitch.tv request results in a 429 response because it's missing cookies from what I can tell when making the same request with curl. This is another stupid obstacle :/

----

@DavidO2023

> I would not recommend solving this by reverse engineering the JS challenge because the challenge is trivial to mutate indefinitely & perpetually. This is an arms race that Kasada will always win.

Yes, you are right, and I think everyone else is aware of this too. As a short-term goal however this is easier to implement.

The Selenium approach shouldn't be too bad, but it would probably require more user configuration. I don't see it problematic having it as a dependency. The benefit here is that this can be run headless in various web browsers. The only question here is whether a working integrity token can be extracted.

An implementation of a local web server bridge is bad IMO, because it's really inconvenient having to manually navigate with your web browser to each stream in order to make the Twitch plugin work. Or when launching the web browser automatically, this would restrict it to Chromium-based browsers only via the `--app` argument in order to have it isolated and in order to be able to terminate the process again. These bridges are mainly intended for authentication purposes, but for something like opening streams, this is way too inconvenient and annoying. Not to mention that this breaks the CLI workflow.
If they go further with fingerprinting, it might be a good candidate for a browser extension. Loading twitch and grabbing whatever is necessary within a real browser.
It looks like you can't use both OAuth token and use Client-ID bypass like mentioned above at the same time..
> It looks like you can't use both OAuth token and use Client-ID bypass like mentioned above at the same time..

I'm able to do it when using the values pulled from firefox.  At least with unsubscribed streams.  None of the streamers I'm subscribed to have come on yet though.  I'm on Linux.
@bs85 I guess it is time to start to code a python headless web engine, or give up and become a big tech web engine plugin+app (and since I have been using streamlink/yt-dlp to avoid those big tech web engines, that would be the end of this road for me).
Apologies if off-topic. This crude javascript might help anyone trying to get tokens from their browser, proof of concept you can extract tokens from dev tools console

```
const { fetch: originalFetch } = window;
window.fetch = async (...args) => {
    let [resource, config ] = args;
  if (
    config.method === 'POST' &&
    typeof config.headers['Authorization'] === 'string' &&
    typeof config.headers['Client-Id'] === 'string' &&
    typeof config.headers['Client-Integrity'] === 'string' &&
    typeof config.headers['X-Device-Id'] === 'string'
  ) {
    const {Authorization, 'Client-Id': ClientId, 'Client-Integrity': ClientIntegrity, 'X-Device-Id': DeviceID} = config.headers;
    const cmd = `streamlink "${window.location.hostname}${window.location.pathname}" "best" \
--twitch-api-header="Authorization=${Authorization}" \
--twitch-api-header="Client-Id=${ClientId}" \
--twitch-api-header="Client-Integrity=${ClientIntegrity}" \
--twitch-api-header="X-Device-Id=${DeviceID}"`;
    console.log(cmd);
  }
    const response = await originalFetch(resource, config);
    return response;
};
```

> So to sum it up, there are hardcoded client ids for non-browser devices like tv, switch, etc that are not subject to the integrity check?

Let's hope the nintendo switch will never get a browser engine and we should be fine for some time.



> It looks like you can't use both OAuth token and use Client-ID bypass like mentioned above at the same time..

This works for me, I have twitch turbo and it loads all channel I tried without ads.


My config file:

```
default-stream=best
player=mpv
twitch-low-latency
twitch-api-header=Authorization=OAuth REDACTED
twitch-api-header=Client-Id=ue6666qo983tsx6so1t0vnawi233wa
```

With release `5.5.1`
Taking a hint from the game console client ID method, I tested Android and iOS user agents on desktop Firefox with an add-on and privacy hardened config to see what would happen. Turns out I was able to load live streams on the mobile site regardless of user agent, so it's likely that the problematic check hasn't been added to the mobile site (at least yet). Leveraging how they handle mobile devices is the easiest general route in the short term, but I don't know if it's viable in the long term
Definitely using `Client-Id` that does not require additional changes would be easiest short-term fix and monitor what is the behavior. Depending on Twitch's software team and their release cycles it might take some time until those mobile/embedded clients are updated and it is not clear what would be the verification on those. In general DESKTOP WEB client id is often used as first development target where they also test how certain things works.
> This works for me, I have twitch turbo and it loads all channel I tried without ads.

Thanks. Got it working


Streamlink/Twitch is working for me again suddenly? I do not have Twitch Turbo.
I use streamlink/twitch as a library from a python program. Didn't try anything to fix the error and in this moment started to work again.
They must of rolled back the kasada implementation as others are pointing out. I assume they're going to patch whatever holes in it that they can think of.  I do not have the ```Client-ID``` header set in the streamlink launch options and I can open streams just fine now. 
I can confirm they must have rolled it back since I am using only `--twitch-api-header=Authentication=OAuth <REDACTED>` as the header option and it works.

can confirm that `--twitch-api-header Client-ID=ue6666qo983tsx6so1t0vnawi233wa` works in fooling twitch into thinking streamlink is actually a Nintendo Switch.
Just wanted to note that, given the outage duration of almost exactly 24 hours, I believe this is a "planned deprecation warning" similar to those they've done in the past. When they deprecated the v5 "Kraken" API, they first did a 1 hour outage, followed by a 24 hour outage, followed by permanent. I feel like this is what they're doing here as well. Expect a permanent ~~outage of the current system~~ roll-forward of whatever they did in either 1 week or 1 month.
> > So to sum it up, there are hardcoded client ids for non-browser devices like tv, switch, etc that are not subject to the integrity check?
> 
> Let's hope the nintendo switch will never get a browser engine and we should be fine for some time.

To be pedantic, the Switch does have a browser engine, but it only loads for captive portals; still, it would be inconvenient if Twitch required Switch-users to set up their own Wi-Fi access points as captive portals that point to Twitch (akin to how some efforts at jailbreaking involve the same sort of shenanigans, using a specially crafted access point to load an arbitrary Web page).
> To be pedantic, the Switch does have a browser engine, but it only loads for captive portals; still, it would be inconvenient if Twitch required Switch-users to set up their own Wi-Fi access points as captive portals that point to Twitch (akin to how some efforts at jailbreaking involve the same sort of shenanigans, using a specially crafted access point to load an arbitrary Web page).

the client id posted here is for Android TV anyway and not Switch. though there's probably Switch id that also works the same way
Let me give you a recap and an update on the current state about Twitch and plans for the plugin. I've already posted some of this on the Matrix/Gitter channel over the past days, but I'm going to repeat this here again, since more people will read this.

When trying to get an access token, Twitch's web player sets the `Client-Integrity` header. This was made a requirement last week, but it's currently disabled again, as you all know.

Some of it seems to be slightly different now or at least inconsistent, but when the requirement was set, the `Client-Integrity` header value was caluculated in these steps:

1. The web player loads `p.js` with lots of obfuscated JS code that requires a JS runtime.
2. `passport.twitch.tv/.../fp` gets requested and if no cookies from (3) are sent, it results in a 429 HTTP status code and another obfuscated JS script. This script contains string-encoded bytecode (~250 KiB) for a custom JS virtual machine that is then run, which is really complicated. I stepped through some of it where it interprets individual bytecode instructions and reads data from the bytecode, and I found a couple of things, like a block-encryption algorithm with a 128-bit key and 64-bit IV and lots of object+property names that are all related to browser-fingerprinting.
3. Once that script succeeds, it requests `passport.twitch.tv/.../tl` with a large binary `POST` body and the response to this are the `KP_UIDz` and `ga__12_abel` cookies which are probably valid for a longer time (didn't check).
4. `passport.twitch.tv/.../fp` gets requested again and with the right cookies it now returns status code 200 with a `KPSDK.message` string.
5. That message string then gets interpreted by `p.js` (1) where it calculates the `x-kpsdk-cd`, `x-kpsdk-ct` and `x-kpsdk-v` headers that are then set on the `gql.twitch.tv/integrity` GQL API endpoint.
6. That API response then gives us the `Client-Integrity` header value which we need to set on the GQL API query for getting a stream access token. When base64-decoding parts of the client-integrity token, it gives us a JSON object with the `is_bad_bot` key. If its value is `"true"`, then no access token gets returned.

Their web player currently still does the proof-of-work challenge (2) and sets all those headers (5), so we can still check when we're considered a "bad bot" (5), even though the `Client-Integrity` header is currently not a requirement.

----

All of this means that we'll have to use an actual web browser and use automation tools like Selenium (or others) for integrating the data extraction into Streamlink. The problem however becomes how well we can integrate this without causing too many setup/configuration/usability issues. Packaging is another problem in terms of Python dependencies, especially on Linux distros where dependencies are not bundled.

Selenium uses the webdriver interface of each supported browser. Over the past couple of days I've been working on a generic webdriver API in Streamlink that plugins could use and these were the initial problems:
1. The W3C "webdriver" protocol spec can't be used, because we need bi-directional communication with the web browser, because we need to intercept network requests.
2. The W3C "bidi webdriver" protocol spec is a work-in-progress and not implemented in Selenium yet.
3. The only alternative for bi-directional communication is the Chrome Devtools Protocol, which is limited to Chromium-based browsers and it's only partially supported by Firefox. For CDP we don't actually need Selenium and it's only useful as a lib for launching the browser and doing the async CDP communication (using `python-trio`, which is nice).

Then there are issues in regards to running browsers in headless mode (browser running "invisibly" and without requiring a desktop environment):
1. Chromium does have a "new" headless mode since release 112 (current one from a couple of days ago is 114), but it requires a new launch argument.
2. Safari still doesn't have a headless mode.

----

Now to my experimentation so far (not going to write down all the details):

Unfortunately, Twitch's browser fingerprinting and bot detection is really tough. It always detects when a browser is run and controlled via the webdriver, because the browser is run in a slightly different environment in this case. Running in headless mode also can be detected.

Anti-fingerprinting is of course nothing new for various browser-automation projects (Selenium, Puppeteer, Playwright, Cypress, etc.) and there are JS libs which try to reset the environment and try to make various fingerprint detection tools fail. For example, this one here, which bundles a collection of know bot-detection evasion scripts:
https://github.com/berstend/puppeteer-extra/tree/master/packages/extract-stealth-evasions
https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth/evasions
This unfortunately doesn't work on Twitch.

There's also the following service, which lots of sites implement. This might be unrelated to Twitch, but it's another thing that can be used to test whether a JS script thinks we're a bot.
https://fingerprint.com/products/bot-detection/

So with the webdriver implementation being ruled out, this means that Selenium won't work because that's how it interacts with the web browsers. Since I was already working with the Chrome Devtools Protocol (CDP) anyway, as the other protocols can't be used, Selenium was already unnecessary. We can launch the browser on our own if we restrict the whole thing to Chromium-based browsers anyway.

If you're not using the webdriver, Chromium can be controlled via the CDP by launching it with the `--remote-debugging-port=...` argument and setting an empty `--user-data-dir` (and some other optional OS/env-compatibility and initialization arguments). This is how some of the other browser-automation tools (still) do it, and that's also how I've implemented the test communication-bridge in Streamlink Twitch GUI years ago. All you need is a websocket connection for issuing CDP commands and listening to events. There are of course libs available with high-level APIs for that.

----

Long story short, at least in my tests so far, if you launch Chromium on your own with the remote debugging port and if you then connect to the CDP session and open Twitch's embedded player with a running stream, it'll perform the proof-of-work challenge and **it will set a `Client-Integrity` token which is NOT considered a "bad bot" as long as you DON'T run in headless mode**.

Headless mode still gets detected, which is a shame. This means is that if we want to run headlessly, we need to find out how to circumvent Twitch's bot detection and implement/add different/more scripts for fooling it.

As an alternative to the headless mode, launching the browser with a minimized window should work without causing too much disturbance. That however means that a desktop environment is required. I have no idea if an X virtual framebuffer works on Linux or if it also gets detected because some browser features are unavailable (if it can even run). In that sense, Twitch at least had achieved a proper bot-prevention while massively annoying regular users.

----

Final comments about a potential implementation in the Twitch plugin:

Since we've already figured out how to interpret the `KPSDK.message` format in order to set the headers on the `/integrity` endpoint (at least I think we do) in order to get the `Client-Integrity` header value, we could make launching the web browser only a requirement when we don't have the cookies from (2) or when they have expired. Once retrieved, Streamlink could cache them, so we don't have to launch the web browser next time. Whether that'll work depends on the correctness and stability of the `KPSDK.message` Python-implementation. But that's just a thought for an efficiency improvement once we have a working implementation.
Would having a browser extension be possible? Would it be able to support more browsers? And would it allow us to just use your normal browser and session?
Hm I think we could have users to open a link a html file (served by a very minimal webserver by streamlink) with a iframe of the `/fp` exactly like how it supposed to be within the twitch html source.

A sample log with that in mind could works like this:

```
[cli][info] Found matching plugin twitch for URL https://twitch.tv/gamesdonequick
[cli][info] Waiting for http://192.168.1.135:4545 to be opened in a browser due to missing cookies...
[cli][info] User opened the link and cookies are successfully retrieved.
[cli][info] Available streams: audio_only, 160p (worst), 360p, 480p, 720p60, 1080p60 (best)
[cli][info] Opening stream: 1080p60 (hls)
[cli][info] Starting player: "C:\Program Files\VideoLAN\VLC\vlc.exe"
```

The benefit of this method is we don't have to automate everything and still allow any browsers to be used (including while running streamlink in headless env like a VPS or a server which don't have an actual browser)
> Would having a browser extension be possible? Would it be able to support more browsers? And would it allow us to just use your normal browser and session?

Client-Integrity extraction from web browser should be the easiest possible way because so far we can use Userscript to grab the header and then send to streamlink to cache. As demonstrated here: https://github.com/streamlink/streamlink/issues/5370#issuecomment-1572768132
> Would having a browser extension be possible?
> extraction from web browser should be the easiest possible way

No, I've already commented on the webserver-bridge + browser extension idea. This is pretty bad, usability-wise and implementation-wise. It would require significant changes to Streamlink and its CLI, and how everything works. The session instance would have to spin up a web server on a plugin's request, indefinitely wait for a request being made by the browser extension until a certain time threshold is reached and then continue with the data. All while needing to have a pre-defined web server port known by the web extension, which is pretty bad if it's unavailable on the system or if multiple plugin instances need to wait for an HTTP client request. There can even be more than one session instance when implementing the Streamlink Python API into an application (that is not Streamlink CLI). Inter-process communication with the web browser is a tricky beast if you want a generic solution where the application doesn't control the web browser itself.

On top of all that, it would require the user to launch the browser manually, have the extension installed and updated, open the stream URL there and then close the tab/browser again without properly knowing when the data was found and transmitted. This all is ridiculously stupid for a single plugin. The extension would also need to work in each browser and would need to be actively maintained and properly deployed. This idea might make sense as a separate project for fetching generic stream URLs and launching Streamlink with the right parameters.

> Would it be able to support more browsers?

If you worry about support for non-Chromium-based browsers, then you'll have to wait, because getting the basic thing to work is the bigger priority. FF for example does have another communication protocol and on top of that, every browser is moving towards the W3C bidi webdriver protocol anyway which replaces CDP.

I don't want to discuss this further because this really doesn't belong into this thread. If you believe that this is an easy solution, then go ahead and submit a pull request or open another issue/discussion.
Proof of concept for minimal page to acquire tokens: https://hakkin.github.io/twitchintegrity/
https://github.com/Hakkin/twitchintegrity/blob/main/script.js

`client_ip`, `device_id` and `user_id` are signed/verified in the token, so they need to be the same in all requests using the token (`device_id` being whatever is specified in `X-Device-Id` header when fetching the token and `user_id` is tied to specified OAuth token). None of the other headers seem to be verified across requests (`Client-Session-Id`/`Client-Version`/`User-Agent`, etc).
Thanks, @Hakkin.

What I'm thinking though is, how much sense does it make re-implementing the acquirement of the integrity token when you already have to use a browser in order to make it pass the bad-bot validation anyway? This solution avoids having to access the (embedded) player directly, which isn't a bad idea, because it's obviously much faster and you're going straight for the results, without having to block access to all unwanted HTTP requests (if you decide to do that). However, there are lots of potential issues and ways Twitch can prevent scripts like this.

First, this script embeds the `p.js` script with its hardcoded URL (that URL and the device token could be passed as query string parameters) onto a different site, so we'd either
- need to host this on a local webserver and be bound to a local implementation on each Streamlink release, making it difficult to deploy patches
- or create a repo like you just did and let it get hosted by GitHub, making it a hard dependency of the plugin that could get updated and fixed much quicker

The big problem though is that a different `Referer` header will always get sent and Twitch can also easily prevent execution of remote scripts like this by adding a [`Content-Security-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src) header like they already do on their embedded web player when an incorrect `parent` URL parameter is set. Easy fix for them. Then we're back to overriding HTTP responses again via the CDP (or other means). Fetching the script sources instead of loading+executing them via a `script` tag could work though to circumvent the CSP header. But since the whole point of the integrity token is doing some fingerprinting, they could easily check `/^https:\/\/(.+\.)*twitch\.tv/.test(top.location.href)` or do some random DOM checks and we're screwed. Not trying to give them some ideas though...

You're also relying heavily on the setup of the script and how it's done by their web player, by attaching specific event listeners to the document body and passing specific objects to the main configure call. That can change easily when the motivation is to prevent tools/bots to get access tokens.

So the question is once again API stability. We've already been relying on their non-stable GQL API for a while, so this whole idea actually might not be a big problem and it'd allow us to get the token much faster.
Is RemoteWebDriver an option? Would hosting the Browser in a container (docker.io/selenium/standalone-firefox) work?

As explained, using the webdriver interface is not an option.

See the progress here: #5380 (already referenced above)

Resolving patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -8,23 +8,27 @@
 """
 
 import argparse
+import base64
 import logging
 import re
 import sys
 from datetime import datetime, timedelta
+from json import dumps as json_dumps
 from random import random
-from typing import List, NamedTuple, Optional
+from typing import List, Mapping, NamedTuple, Optional, Tuple
 from urllib.parse import urlparse
 
 from streamlink.exceptions import NoStreamsError, PluginError
 from streamlink.plugin import Plugin, pluginargument, pluginmatcher
 from streamlink.plugin.api import validate
+from streamlink.session import Streamlink
 from streamlink.stream.hls import HLSStream, HLSStreamReader, HLSStreamWorker, HLSStreamWriter
 from streamlink.stream.hls_playlist import M3U8, ByteRange, DateRange, ExtInf, Key, M3U8Parser, Map, load as load_hls_playlist
 from streamlink.stream.http import HTTPStream
 from streamlink.utils.args import keyvalue
 from streamlink.utils.parse import parse_json, parse_qsd
-from streamlink.utils.times import hours_minutes_seconds
+from streamlink.utils.random import CHOICES_ALPHA_NUM, random_token
+from streamlink.utils.times import fromtimestamp, hours_minutes_seconds
 from streamlink.utils.url import update_qsd
 
 
@@ -406,7 +410,7 @@ def metadata_clips(self, clipname):
             ),
         ))
 
-    def access_token(self, is_live, channel_or_vod):
+    def access_token(self, is_live, channel_or_vod, client_integrity: Optional[Tuple[str, str]] = None):
         query = self._gql_persisted_query(
             "PlaybackAccessToken",
             "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
@@ -424,7 +428,11 @@ def access_token(self, is_live, channel_or_vod):
             validate.union_get("signature", "value"),
         )
 
-        return self.call(query, acceptable_status=(200, 400, 401, 403), schema=validate.Schema(
+        headers = {}
+        if client_integrity:
+            headers["Device-Id"], headers["Client-Integrity"] = client_integrity
+
+        return self.call(query, acceptable_status=(200, 400, 401, 403), headers=headers, schema=validate.Schema(
             validate.any(
                 validate.all(
                     {"errors": [{"message": str}]},
@@ -450,7 +458,7 @@ def access_token(self, is_live, channel_or_vod):
                         ),
                     },
                     validate.get("data"),
-                    validate.transform(lambda data: ("token", *data)),
+                    validate.transform(lambda data: ("token", *data) if data is not None else ("token", None, None)),
                 ),
             ),
         ))
@@ -501,6 +509,117 @@ def stream_metadata(self, channel):
         ))
 
 
+class TwitchClientIntegrity:
+    URL_P_SCRIPT = "https://k.twitchcdn.net/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/p.js"
+
+    # language=javascript
+    JS_INTEGRITY_TOKEN = """
+    // noinspection JSIgnoredPromiseFromCall
+    new Promise((resolve, reject) => {
+        function configureKPSDK() {
+            // noinspection JSUnresolvedVariable,JSUnresolvedFunction
+            window.KPSDK.configure([{
+                "protocol": "https:",
+                "method": "POST",
+                "domain": "gql.twitch.tv",
+                "path": "/integrity"
+            }]);
+        }
+
+        async function fetchIntegrity() {
+            // noinspection JSUnresolvedReference
+            const headers = Object.assign(HEADERS, {"x-device-id": "DEVICE_ID"});
+            // window.fetch gets overridden and the patched function needs to be used
+            const resp = await window.fetch("https://gql.twitch.tv/integrity", {
+                "headers": headers,
+                "body": null,
+                "method": "POST",
+                "mode": "cors",
+                "credentials": "omit"
+            });
+
+            if (resp.status !== 200) {
+                throw new Error(`Unexpected integrity response status code ${resp.status}`);
+            }
+
+            return JSON.stringify(await resp.json());
+        }
+
+        document.addEventListener("kpsdk-load", configureKPSDK, {once: true});
+        document.addEventListener("kpsdk-ready", () => fetchIntegrity().then(resolve, reject), {once: true});
+
+        const script = document.createElement("script");
+        script.addEventListener("error", reject);
+        script.src = "SCRIPT_SOURCE";
+        document.body.appendChild(script);
+    });
+    """
+
+    @classmethod
+    def acquire(
+        cls,
+        session: Streamlink,
+        channel: str,
+        headers: Mapping[str, str],
+        device_id: str,
+    ) -> Optional[Tuple[str, int]]:
+        from streamlink.webbrowser.cdp import CDPClient, CDPClientSession, devtools
+        from streamlink.webbrowser.exceptions import WebbrowserError
+
+        url = f"https://www.twitch.tv/{channel}"
+        js_get_integrity_token = cls.JS_INTEGRITY_TOKEN \
+            .replace("SCRIPT_SOURCE", cls.URL_P_SCRIPT) \
+            .replace("HEADERS", json_dumps(headers)) \
+            .replace("DEVICE_ID", device_id)
+        eval_timeout = session.get_option("webbrowser-timeout")
+
+        async def on_main(client_session: CDPClientSession, request: devtools.fetch.RequestPaused):
+            async with client_session.alter_request(request) as cm:
+                cm.body = "<!doctype html>"
+
+        async def acquire_client_integrity_token(client: CDPClient):
+            client_session: CDPClientSession
+            async with client.session() as client_session:
+                client_session.add_request_handler(on_main, url_pattern=url, on_request=True)
+                async with client_session.navigate(url) as frame_id:
+                    await client_session.loaded(frame_id)
+                    return await client_session.evaluate(js_get_integrity_token, timeout=eval_timeout)
+
+        try:
+            client_integrity: Optional[str] = CDPClient.launch(session, acquire_client_integrity_token)
+        except WebbrowserError as err:
+            log.error(f"{type(err).__name__}: {err}")
+            return None
+        if not client_integrity:
+            return None
+
+        token, expiration = parse_json(client_integrity, schema=validate.Schema(
+            {"token": str, "expiration": int},
+            validate.union_get("token", "expiration"),
+        ))
+        is_bad_bot = cls.decode_client_integrity_token(token, schema=validate.Schema(
+            {"is_bad_bot": str},
+            validate.get("is_bad_bot"),
+            validate.transform(lambda val: val.lower() != "false"),
+        ))
+        log.info(f"Is bad bot? {is_bad_bot}")
+        if is_bad_bot:
+            return None
+
+        return token, expiration / 1000
+
+    @staticmethod
+    def decode_client_integrity_token(data: str, schema: Optional[validate.Schema] = None):
+        if not data.startswith("v4.public."):
+            raise PluginError("Invalid client-integrity token format")
+        token = data[len("v4.public."):].replace("-", "+").replace("_", "/")
+        token += "=" * ((4 - (len(token) % 4)) % 4)
+        token = base64.b64decode(token.encode())[:-64].decode()
+        log.debug(f"Client-Integrity token: {token}")
+
+        return parse_json(token, exception=PluginError, schema=schema)
+
+
 @pluginmatcher(re.compile(r"""
     https?://(?:(?P<subdomain>[\w-]+)\.)?twitch\.tv/
     (?:
@@ -573,7 +692,14 @@ def stream_metadata(self, channel):
         Can be repeated to add multiple parameters.
     """,
 )
+@pluginargument(
+    "purge-client-integrity",
+    action="store_true",
+    help="Purge cached Twitch client-integrity token and acquire a new one.",
+)
 class Twitch(Plugin):
+    _CACHE_KEY_CLIENT_INTEGRITY = "client-integrity"
+
     @classmethod
     def stream_weight(cls, stream):
         if stream == "source":
@@ -638,17 +764,52 @@ def _get_metadata(self):
         except (PluginError, TypeError):
             pass
 
+    def _client_integrity_token(self, channel: str) -> Optional[Tuple[str, str]]:
+        if self.options.get("purge-client-integrity"):
+            log.info("Removing cached client-integrity token...")
+            self.cache.set(self._CACHE_KEY_CLIENT_INTEGRITY, None, 0)
+
+        client_integrity = self.cache.get(self._CACHE_KEY_CLIENT_INTEGRITY)
+        if client_integrity and isinstance(client_integrity, list) and len(client_integrity) == 2:
+            log.info("Using cached client-integrity token")
+            device_id, token = client_integrity
+        else:
+            log.info("Acquiring new client-integrity token...")
+            device_id = random_token(32, CHOICES_ALPHA_NUM)
+            client_integrity = TwitchClientIntegrity.acquire(
+                self.session,
+                channel,
+                self.api.headers,
+                device_id,
+            )
+            if not client_integrity:
+                log.warning("No client-integrity token acquired")
+                return None
+
+            token, expiration = client_integrity
+            self.cache.set(self._CACHE_KEY_CLIENT_INTEGRITY, [device_id, token], expires_at=fromtimestamp(expiration))
+
+        return device_id, token
+
     def _access_token(self, is_live, channel_or_vod):
-        try:
-            response, *data = self.api.access_token(is_live, channel_or_vod)
+        # try without a client-integrity token first (the web player did the same on 2023-05-31)
+        response, *data = self.api.access_token(is_live, channel_or_vod)
+
+        # try again with a client-integrity token if the API response was erroneous
+        if response != "token":
+            client_integrity = self._client_integrity_token(channel_or_vod) if is_live else None
+            response, *data = self.api.access_token(is_live, channel_or_vod, client_integrity)
+
+            # unknown API response error: abort
             if response != "token":
                 error, message = data
-                log.error(f"{error or 'Error'}: {message or 'Unknown error'}")
-                raise PluginError
-            sig, token = data
-        except (PluginError, TypeError):
-            raise NoStreamsError  # noqa: B904
+                raise PluginError(f"{error or 'Error'}: {message or 'Unknown error'}")
+
+        # access token response was empty: stream is offline or channel doesn't exist
+        if response == "token" and data[0] is None:
+            raise NoStreamsError
 
+        sig, token = data
         try:
             restricted_bitrates = self.api.parse_token(token)
         except PluginError:
diff --git a/src/streamlink/utils/random.py b/src/streamlink/utils/random.py
new file mode 100644
--- /dev/null
+++ b/src/streamlink/utils/random.py
@@ -0,0 +1,14 @@
+from random import choice
+
+
+CHOICES_NUM = "0123456789"
+CHOICES_ALPHA_LOWER = "abcdefghijklmnopqrstuvwxyz"
+CHOICES_ALPHA_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+CHOICES_ALPHA = f"{CHOICES_ALPHA_LOWER}{CHOICES_ALPHA_UPPER}"
+CHOICES_ALPHA_NUM = f"{CHOICES_NUM}{CHOICES_ALPHA}"
+CHOICES_HEX_LOWER = f"{CHOICES_NUM}{CHOICES_ALPHA_LOWER[:6]}"
+CHOICES_HEX_UPPER = f"{CHOICES_NUM}{CHOICES_ALPHA_UPPER[:6]}"
+
+
+def random_token(length: int = 32, choices: str = CHOICES_ALPHA_NUM) -> str:
+    return "".join(choice(choices) for _ in range(length))


Test patch:
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -6,7 +6,7 @@
 import requests_mock as rm
 
 from streamlink import Streamlink
-from streamlink.exceptions import NoStreamsError
+from streamlink.exceptions import NoStreamsError, PluginError
 from streamlink.options import Options
 from streamlink.plugins.twitch import Twitch, TwitchAPI, TwitchHLSStream, TwitchHLSStreamReader, TwitchHLSStreamWriter
 from tests.mixins.stream_hls import EventedHLSStreamWriter, Playlist, Segment as _Segment, Tag, TestMixinStreamHLS
@@ -396,6 +396,11 @@ def test_hls_low_latency_no_ads_reload_time(self):
 
 
 class TestTwitchAPIAccessToken:
+    @pytest.fixture(autouse=True)
+    def _client_integrity_token(self, monkeypatch: pytest.MonkeyPatch):
+        mock_client_integrity_token = Mock(return_value=("device-id", "client-integrity-token"))
+        monkeypatch.setattr(Twitch, "_client_integrity_token", mock_client_integrity_token)
+
     @pytest.fixture()
     def plugin(self, request: pytest.FixtureRequest):
         session = Streamlink()
@@ -409,7 +414,7 @@ def plugin(self, request: pytest.FixtureRequest):
     def mock(self, request: pytest.FixtureRequest, requests_mock: rm.Mocker):
         mock = requests_mock.post("https://gql.twitch.tv/gql", **getattr(request, "param", {"json": {}}))
         yield mock
-        assert mock.call_count == 1
+        assert mock.call_count > 0
         payload = mock.last_request.json()  # type: ignore[union-attr]
         assert tuple(sorted(payload.keys())) == ("extensions", "operationName", "variables")
         assert payload.get("operationName") == "PlaybackAccessToken"
@@ -480,7 +485,7 @@ def _assert_vod(self, mock: rm.Mocker):
         ),
     ], indirect=["plugin"])
     def test_plugin_options(self, plugin: Twitch, mock: rm.Mocker, exp_headers: dict, exp_variables: dict):
-        with pytest.raises(NoStreamsError):
+        with pytest.raises(PluginError):
             plugin._access_token(True, "channelname")
         requestheaders = dict(mock.last_request._request.headers)  # type: ignore[union-attr]
         for header in plugin.session.http.headers.keys():
@@ -505,6 +510,7 @@ def test_live_success(self, plugin: Twitch, mock: rm.Mocker):
     def test_live_failure(self, plugin: Twitch, mock: rm.Mocker):
         with pytest.raises(NoStreamsError):
             plugin._access_token(True, "channelname")
+        assert len(mock.request_history) == 1, "Only gets the access token once when the channel is offline"
 
     @pytest.mark.usefixtures("_assert_vod")
     @pytest.mark.parametrize("mock", [{
@@ -521,6 +527,7 @@ def test_vod_success(self, plugin: Twitch, mock: rm.Mocker):
     def test_vod_failure(self, plugin: Twitch, mock: rm.Mocker):
         with pytest.raises(NoStreamsError):
             plugin._access_token(False, "vodid")
+        assert len(mock.request_history) == 1, "Only gets the access token once when the VOD doesn't exist"
 
     @pytest.mark.usefixtures("_assert_live")
     @pytest.mark.parametrize(("plugin", "mock"), [
@@ -532,13 +539,50 @@ def test_vod_failure(self, plugin: Twitch, mock: rm.Mocker):
             },
         ),
     ], indirect=True)
-    def test_auth_failure(self, caplog: pytest.LogCaptureFixture, plugin: Twitch, mock: rm.Mocker):
-        with pytest.raises(NoStreamsError):
+    def test_auth_failure(self, plugin: Twitch, mock: rm.Mocker):
+        with pytest.raises(PluginError, match="^Unauthorized: The \"Authorization\" token is invalid\\.$"):
             plugin._access_token(True, "channelname")
-        assert mock.last_request._request.headers["Authorization"] == "OAuth invalid-token"  # type: ignore[union-attr]
-        assert [(record.levelname, record.module, record.message) for record in caplog.records] == [
-            ("error", "twitch", "Unauthorized: The \"Authorization\" token is invalid."),
-        ]
+        assert len(mock.request_history) == 2, "Always tries again on error, with integrity-token on second attempt"
+
+        headers: dict = mock.request_history[0]._request.headers
+        assert headers["Authorization"] == "OAuth invalid-token"
+        assert "Device-Id" not in headers
+        assert "Client-Integrity" not in headers
+
+        headers = mock.request_history[1]._request.headers
+        assert headers["Authorization"] == "OAuth invalid-token"
+        assert headers["Device-Id"] == "device-id"
+        assert headers["Client-Integrity"] == "client-integrity-token"
+
+    @pytest.mark.usefixtures("_assert_live")
+    @pytest.mark.parametrize(("plugin", "mock"), [
+        (
+            [("api-header", [("Authorization", "OAuth invalid-token")])],
+            {"response_list": [
+                {
+                    "status_code": 401,
+                    "json": {"errors": [{"message": "failed integrity check"}]},
+                },
+                {
+                    "json": {"data": {"streamPlaybackAccessToken": {"value": '{"channel":"foo"}', "signature": "sig"}}},
+                },
+            ]},
+        ),
+    ], indirect=True)
+    def test_failed_integrity_check(self, plugin: Twitch, mock: rm.Mocker):
+        data = plugin._access_token(True, "channelname")
+        assert data == ("sig", '{"channel":"foo"}', [])
+        assert len(mock.request_history) == 2, "Always tries again on error, with integrity-token on second attempt"
+
+        headers: dict = mock.request_history[0]._request.headers
+        assert headers["Authorization"] == "OAuth invalid-token"
+        assert "Device-Id" not in headers
+        assert "Client-Integrity" not in headers
+
+        headers = mock.request_history[1]._request.headers
+        assert headers["Authorization"] == "OAuth invalid-token"
+        assert headers["Device-Id"] == "device-id"
+        assert headers["Client-Integrity"] == "client-integrity-token"
 
 
 class TestTwitchMetadata:
diff --git a/tests/utils/test_random.py b/tests/utils/test_random.py
new file mode 100644
--- /dev/null
+++ b/tests/utils/test_random.py
@@ -0,0 +1,69 @@
+from collections import defaultdict
+from itertools import count
+from typing import Dict, Iterator, Sequence
+
+import pytest
+
+from streamlink.utils.random import (
+    CHOICES_ALPHA,
+    CHOICES_ALPHA_LOWER,
+    CHOICES_ALPHA_NUM,
+    CHOICES_ALPHA_UPPER,
+    CHOICES_HEX_LOWER,
+    CHOICES_HEX_UPPER,
+    CHOICES_NUM,
+    random_token,
+)
+
+
+@pytest.fixture()
+def _iterated_choice(monkeypatch: pytest.MonkeyPatch):
+    choices: Dict[Sequence, Iterator[int]] = defaultdict(lambda: count())
+
+    def fake_choice(seq):
+        return seq[next(choices[seq]) % len(seq)]
+
+    monkeypatch.setattr("streamlink.utils.random.choice", fake_choice)
+
+
+@pytest.mark.usefixtures("_iterated_choice")
+@pytest.mark.parametrize(("args", "expected"), [
+    (
+        dict(),
+        "0123456789abcdefghijklmnopqrstuv",
+    ),
+    (
+        dict(length=62),
+        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
+    ),
+    (
+        dict(length=20, choices=CHOICES_NUM),
+        "01234567890123456789",
+    ),
+    (
+        dict(length=52, choices=CHOICES_ALPHA_LOWER),
+        "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
+    ),
+    (
+        dict(length=52, choices=CHOICES_ALPHA_UPPER),
+        "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ",
+    ),
+    (
+        dict(length=104, choices=CHOICES_ALPHA),
+        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
+    ),
+    (
+        dict(length=100, choices=CHOICES_ALPHA_NUM),
+        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB",
+    ),
+    (
+        dict(length=32, choices=CHOICES_HEX_LOWER),
+        "0123456789abcdef0123456789abcdef",
+    ),
+    (
+        dict(length=32, choices=CHOICES_HEX_UPPER),
+        "0123456789ABCDEF0123456789ABCDEF",
+    ),
+])
+def test_random_token(args, expected):
+    assert random_token(**args) == expected