[GH-ISSUE #38] Spotify /api/token endpoint returns 400 - Unauthorized request (TOTP change) #24

Closed
opened 2026-02-27 19:06:29 +03:00 by kerem · 10 comments
Owner

Originally created by @wassim01110111 on GitHub (Jun 30, 2025).
Original GitHub issue: https://github.com/Aran404/SpotAPI/issues/38

Version

  • Python 3.11.4
  • spotapi==1.1.9

Code trigggering the error

# client.py
    def _get_auth_vars(self) -> None:
        if self.access_token is _Undefined or self.client_id is _Undefined:
            totp, timestamp = generate_totp()
            query = {
                "reason": "init",
                "productType": "web-player",
                "totp": totp,
                "totpVer": 5,
                "ts": timestamp,
            }
            resp = self.client.get(
                "https://open.spotify.com/api/token", params=query
            )

Response from Spotify

{
  "error": {
    "code": 400,
    "message": "Unauthorized request",
    "extra": {
      "_notes": "Usage of this endpoint is not permitted under the Spotify Developer Terms and Developer Policy, and applicable law"
    }
  }
}

Notes

It appears Spotify has updated their TOTP flow. The new format seems to be:

https://open.spotify.com/api/token?reason=init&productType=web-player&totp={totp}&totpServer={totp}&totpVer=8

It looks like the totpVer has changed (from 5 to 8) and a new parameter totpServer was added (equal to totp).

Originally created by @wassim01110111 on GitHub (Jun 30, 2025). Original GitHub issue: https://github.com/Aran404/SpotAPI/issues/38 ### Version - Python 3.11.4 - spotapi==1.1.9 ### Code trigggering the error ```python # client.py def _get_auth_vars(self) -> None: if self.access_token is _Undefined or self.client_id is _Undefined: totp, timestamp = generate_totp() query = { "reason": "init", "productType": "web-player", "totp": totp, "totpVer": 5, "ts": timestamp, } resp = self.client.get( "https://open.spotify.com/api/token", params=query ) ``` ### Response from Spotify ```json { "error": { "code": 400, "message": "Unauthorized request", "extra": { "_notes": "Usage of this endpoint is not permitted under the Spotify Developer Terms and Developer Policy, and applicable law" } } } ``` ### Notes It appears Spotify has updated their TOTP flow. The new format seems to be: ``` https://open.spotify.com/api/token?reason=init&productType=web-player&totp={totp}&totpServer={totp}&totpVer=8 ``` It looks like the `totpVer` has changed (from `5` to `8`) and a new parameter `totpServer` was added (equal to `totp`).
kerem closed this issue 2026-02-27 19:06:29 +03:00
Author
Owner

@wassim01110111 commented on GitHub (Jun 30, 2025):

I have tried to do some digging and managed to find this

const Pe = JSON.parse('{"validUntil":"2025-07-02T12:00:00.000Z","secrets":[{"secret":[37,84,32,76,87,90,87,47,13,75,48,54,44,28,19,21,22],"version":8},{"secret":[59,91,66,74,30,66,74,38,46,50,72,61,44,71,86,39,89],"version":7},{"secret":[21,24,85,46,48,35,33,8,11,63,76,12,55,77,14,7,54],"version":6}]}');

I haven't tested if any of those arrays work yet

<!-- gh-comment-id:3020062816 --> @wassim01110111 commented on GitHub (Jun 30, 2025): I have tried to do some digging and managed to find this ```js const Pe = JSON.parse('{"validUntil":"2025-07-02T12:00:00.000Z","secrets":[{"secret":[37,84,32,76,87,90,87,47,13,75,48,54,44,28,19,21,22],"version":8},{"secret":[59,91,66,74,30,66,74,38,46,50,72,61,44,71,86,39,89],"version":7},{"secret":[21,24,85,46,48,35,33,8,11,63,76,12,55,77,14,7,54],"version":6}]}'); ``` I haven't tested if any of those arrays work yet
Author
Owner

@Aran404 commented on GitHub (Jun 30, 2025):

What code did you use? Need to reproduce the error

<!-- gh-comment-id:3021126472 --> @Aran404 commented on GitHub (Jun 30, 2025): What code did you use? Need to reproduce the error
Author
Owner

@wassim01110111 commented on GitHub (Jun 30, 2025):

I used the code available in the client.py file with the updated endpoint, the old one throws out this error:

{"totpVerExpired":"error","totpValidUntil":"2025-07-02T12:00:00.000Z","error":{"code":400,"message":"Unauthorized request","extra":{"_notes":"Usage of this endpoint is not permitted under the Spotify Developer Terms and Developer Policy, and applicable law"}}}

Like I said, they switched to totpVer 8 so the previous method doesn't work anymore.

<!-- gh-comment-id:3021207962 --> @wassim01110111 commented on GitHub (Jun 30, 2025): I used the code available in the client.py file with the updated endpoint, the old one throws out this error: ```json {"totpVerExpired":"error","totpValidUntil":"2025-07-02T12:00:00.000Z","error":{"code":400,"message":"Unauthorized request","extra":{"_notes":"Usage of this endpoint is not permitted under the Spotify Developer Terms and Developer Policy, and applicable law"}}} ``` Like I said, they switched to totpVer 8 so the previous method doesn't work anymore.
Author
Owner

@WeiChaoZheng commented on GitHub (Jul 2, 2025):

I think I've found a solution
Update the spotapi/client.py file

_TOTP_SECRET = bytearray([53,53,48,55,49,52,53,56,53,51,52,56,55,52,57,57,53,57,50,50,52,56,54,51,48,51,50,57,51,52,55])
===change===>
_TOTP_SECRET = bytearray([52, 52, 57, 52, 52, 51, 54, 52, 57, 48, 56, 52, 56, 56, 54, 51, 50, 56, 56, 57, 51, 53, 51, 52, 53, 55, 49, 48, 52, 49, 51, 49, 53])
====OR ====>
_TOTP_SECRET = bytearray(b'449443649084886328893534571041315')

And:

query = {
                "reason": "init",
                "productType": "web-player",
                "totp": totp,
                "totpVer": 5,
                "ts": timestamp,
            }
===change==>
query = {
                "reason": "init",
                "productType": "web-player",
                "totp": totp,
                "totpServer": totp,
                "totpVer": 8
            }
<!-- gh-comment-id:3026218686 --> @WeiChaoZheng commented on GitHub (Jul 2, 2025): I think I've found a solution Update the spotapi/client.py file ``` _TOTP_SECRET = bytearray([53,53,48,55,49,52,53,56,53,51,52,56,55,52,57,57,53,57,50,50,52,56,54,51,48,51,50,57,51,52,55]) ===change===> _TOTP_SECRET = bytearray([52, 52, 57, 52, 52, 51, 54, 52, 57, 48, 56, 52, 56, 56, 54, 51, 50, 56, 56, 57, 51, 53, 51, 52, 53, 55, 49, 48, 52, 49, 51, 49, 53]) ====OR ====> _TOTP_SECRET = bytearray(b'449443649084886328893534571041315') ``` And: ``` query = { "reason": "init", "productType": "web-player", "totp": totp, "totpVer": 5, "ts": timestamp, } ===change==> query = { "reason": "init", "productType": "web-player", "totp": totp, "totpServer": totp, "totpVer": 8 } ```
Author
Owner

@wassim01110111 commented on GitHub (Jul 2, 2025):

I'm sorry to break it to you, but they've updated it to totpVer 9 and the TOTP secret you have shared is no longer valid.

<!-- gh-comment-id:3026255932 --> @wassim01110111 commented on GitHub (Jul 2, 2025): I'm sorry to break it to you, but they've updated it to totpVer 9 and the TOTP secret you have shared is no longer valid.
Author
Owner

@wassim01110111 commented on GitHub (Jul 2, 2025):

On the bright side, they don't seem to have changed much compared to the 8th version, the main difference is that totp and totpServer have different values. As long as you can figure out this function, you can pretty much reverse engineer the whole thing.

            function We(e, t) {
                const n = Ke();
                return We = function(t, r) {
                    let i = n[t -= 364];
                    if (void 0 === We.BOPDLm) {
                        var o = function(e) {
                            let t = ""
                              , n = ""
                              , r = t + o;
                            for (let i, o, s = 0, a = 0; o = e.charAt(a++); ~o && (i = s % 4 ? 64 * i + o : o,
                            s++ % 4) ? t += r.charCodeAt(a + 10) - 10 != 0 ? String.fromCharCode(255 & i >> (-2 * s & 6)) : s : 0)
                                o = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=".indexOf(o);
                            for (let i = 0, o = t.length; i < o; i++)
                                n += "%" + ("00" + t.charCodeAt(i).toString(16)).slice(-2);
                            return decodeURIComponent(n)
                        };
                        We.qFjLrj = o,
                        e = arguments,
                        We.BOPDLm = !0
                    }
                    const s = t + n[0]
                      , a = e[s];
                    if (a)
                        i = a;
                    else {
                        const t = function(e) {
                            this.zUlPjS = e,
                            this.RkkPiJ = [1, 0, 0],
                            this.ptTBfu = function() {
                                return "newState"
                            }
                            ,
                            this.ktDGOn = "\\w+ *\\(\\) *{\\w+ *",
                            this.BVhVsz = "['|\"].+['|\"];? *}"
                        };
                        t.prototype.zrenck = function() {
                            const e = new RegExp(this.ktDGOn + this.BVhVsz).test(this.ptTBfu.toString()) ? --this.RkkPiJ[1] : --this.RkkPiJ[0];
                            return this.LOaFQY(e)
                        }
                        ,
                        t.prototype.LOaFQY = function(e) {
                            return Boolean(~e) ? this.anXBev(this.zUlPjS) : e
                        }
                        ,
                        t.prototype.anXBev = function(e) {
                            for (let t = 0, n = this.RkkPiJ.length; t < n; t++)
                                this.RkkPiJ.push(Math.round(Math.random())),
                                n = this.RkkPiJ.length;
                            return e(this.RkkPiJ[0])
                        }
                        ,
                        new t(We).zrenck(),
                        i = We.qFjLrj(i),
                        e[s] = i
                    }
                    return i
                }
                ,
                We(e, t)
            }
<!-- gh-comment-id:3026320432 --> @wassim01110111 commented on GitHub (Jul 2, 2025): On the bright side, they don't seem to have changed much compared to the 8th version, the main difference is that totp and totpServer have different values. As long as you can figure out this function, you can pretty much reverse engineer the whole thing. ```js function We(e, t) { const n = Ke(); return We = function(t, r) { let i = n[t -= 364]; if (void 0 === We.BOPDLm) { var o = function(e) { let t = "" , n = "" , r = t + o; for (let i, o, s = 0, a = 0; o = e.charAt(a++); ~o && (i = s % 4 ? 64 * i + o : o, s++ % 4) ? t += r.charCodeAt(a + 10) - 10 != 0 ? String.fromCharCode(255 & i >> (-2 * s & 6)) : s : 0) o = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=".indexOf(o); for (let i = 0, o = t.length; i < o; i++) n += "%" + ("00" + t.charCodeAt(i).toString(16)).slice(-2); return decodeURIComponent(n) }; We.qFjLrj = o, e = arguments, We.BOPDLm = !0 } const s = t + n[0] , a = e[s]; if (a) i = a; else { const t = function(e) { this.zUlPjS = e, this.RkkPiJ = [1, 0, 0], this.ptTBfu = function() { return "newState" } , this.ktDGOn = "\\w+ *\\(\\) *{\\w+ *", this.BVhVsz = "['|\"].+['|\"];? *}" }; t.prototype.zrenck = function() { const e = new RegExp(this.ktDGOn + this.BVhVsz).test(this.ptTBfu.toString()) ? --this.RkkPiJ[1] : --this.RkkPiJ[0]; return this.LOaFQY(e) } , t.prototype.LOaFQY = function(e) { return Boolean(~e) ? this.anXBev(this.zUlPjS) : e } , t.prototype.anXBev = function(e) { for (let t = 0, n = this.RkkPiJ.length; t < n; t++) this.RkkPiJ.push(Math.round(Math.random())), n = this.RkkPiJ.length; return e(this.RkkPiJ[0]) } , new t(We).zrenck(), i = We.qFjLrj(i), e[s] = i } return i } , We(e, t) } ```
Author
Owner

@WeiChaoZheng commented on GitHub (Jul 2, 2025):

version 9 only changes the SECRET; everything else remains the same
The safest way should be to use it
"seleniumwire" accesses and intercepts the acquisition.

<!-- gh-comment-id:3026819440 --> @WeiChaoZheng commented on GitHub (Jul 2, 2025): version 9 only changes the SECRET; everything else remains the same The safest way should be to use it "seleniumwire" accesses and intercepts the acquisition.
Author
Owner

@wassim01110111 commented on GitHub (Jul 2, 2025):

Hi @Aran404,
I’ve opened a pull request (#40) that should fix this issue.
Could you please check it out and see if it works on your end? Thanks!

<!-- gh-comment-id:3027444869 --> @wassim01110111 commented on GitHub (Jul 2, 2025): Hi @Aran404, I’ve opened a pull request (#40) that should fix this issue. Could you please check it out and see if it works on your end? Thanks!
Author
Owner

@Aran404 commented on GitHub (Jul 8, 2025):

Hey, sorry I'm back. I'll have a look now

<!-- gh-comment-id:3049851327 --> @Aran404 commented on GitHub (Jul 8, 2025): Hey, sorry I'm back. I'll have a look now
Author
Owner

@Aran404 commented on GitHub (Jul 8, 2025):

Wassim's pull request was sufficient to fix the issue

<!-- gh-comment-id:3049877130 --> @Aran404 commented on GitHub (Jul 8, 2025): Wassim's pull request was sufficient to fix the issue
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/SpotAPI#24
No description provided.