librespot icon indicating copy to clipboard operation
librespot copied to clipboard

[Notice] open.spotify.com/get_access_token No longer works

Open yodaluca23 opened this issue 8 months ago • 21 comments

Look for similar bugs

Please check if there's already an issue for your problem. If you've only a "me too" comment to make, consider if a :+1: reaction will suffice.

Description

A clear and concise description of what the problem is. I don't know what Spotify changed but open.spotify.com/get_access_token no longer gives an accessToken, giving the error

{
  "error": {
    "code": 400,
    "message": "Invalid TOTP"
  }
}

Version

What version(s) of librespot does this problem exist in? All

How to reproduce

Steps to reproduce the behavior in librespot e.g.

  1. Navigate to open.spotify.com/get_access_token as described by https://github.com/librespot-org/librespot/wiki/Options#access-token
  2. View error.

Log

Error Spotify gives:

{
  "error": {
    "code": 400,
    "message": "Invalid TOTP"
  }
}

Host (what you are running librespot on):

NA

Additional context

This really sucks for a lot of small OSS projects that depend on this for getting anonymous tokens aswell...

yodaluca23 avatar Mar 14 '25 19:03 yodaluca23

That does suck. It was very useful for those projects that got screwed over by the recent API changes. Let's see if we can circumvent this check. It's always going to be cat and mouse with these guys.

kingosticks avatar Mar 14 '25 20:03 kingosticks

I don't use this feature. But the error message "Invalid TOTP" makes me wonder whether it's failing for you because you were using MFA, and whether it was simply an auth failure lack of a "time based one time password" (TOTP)?

michaelherger avatar Mar 15 '25 09:03 michaelherger

No, they've changed something on their end to prevent public access. It was previously a super easy way to get an access token with lots (full?) permissions. It only required you have a couple of browser cookies set (done for you when you login to Spotify.com). It was particularly useful since that included access to all the recently deprecated endpoints. They've now closed that loophole as part of their ongoing effort to lose as many customers as possible.

I assume this endpoint is being used by their developer console, worth having a look there to see if we can undo this new protection.

kingosticks avatar Mar 15 '25 09:03 kingosticks

This error also happened at Spotube, a custom Spotify player, and they found a solution.

It seems Spotify changed how the token thingy works. Now, it is required to have a new request to get the final access token. From the current request, you have to create a totp with a timestamp given by Spotify and request this access_token to then use it on the new clienttoken api request.

See:

  • generateTotp from Spotube: https://github.com/KRTirtho/spotube/blob/59f298a935c87077a6abd50656f8a4ead44bd979/lib/provider/authentication/authentication.dart#L135
  • generate credentials cookie: https://github.com/KRTirtho/spotube/blob/59f298a935c87077a6abd50656f8a4ead44bd979/lib/provider/authentication/authentication.dart#L184

Something similar like this would need to be implemented here in order to make Spotify integration work again.

Wikijito7 avatar Mar 15 '25 11:03 Wikijito7

Great! For the record, this is just one way to get an access token. We list 2 others on our wiki

kingosticks avatar Mar 15 '25 14:03 kingosticks

As others have noticed in the related issues on other projects, Spotify is changing this a lot, when I first made this issue, it returned the error in my original post. Then SpotAPI cracked the TOTP:

https://github.com/Aran404/SpotAPI/commit/9061bdd53bbfc4b983394593bad6b7d4464245ed

And we were able to get in and it gave this:

{
  "clientId": "CLIENTID",
  "accessToken": "ACCESSTOKEN",
  "accessTokenExpirationTimestampMs": TIMESTAMP,
  "isAnonymous": true,
  "totpValidity": -1,
  "_notes": "Usage of this endpoint is not permitted under the Spotify Developer Terms and Developer Policy, and applicable law"
}

Then a few hours after that commit they openned it up completely and with nothing needed, no params, just like originally it would give this:

{
  "clientId": "CLIENTID",
  "accessToken": "ACCESSTOKEN",
  "accessTokenExpirationTimestampMs": TIMESTAMP,
  "isAnonymous": true,
  "totpValidity": false,
  "_notes": "Usage of this endpoint is not permitted under the Spotify Developer Terms and Developer Policy, and applicable law"
}

Though in my testing Python Requests didn't work code 429? Idk why but http.client worked.

Now it looks like they've changed it once again, opening it with no params returns:

{
  "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"
    }
  }
}

While SpotAPIs workaround still works the same, just wanted to document this here, it's very interesting to see what Spotify will decide on.

yodaluca23 avatar Mar 15 '25 14:03 yodaluca23

Welp, that note is something to take seriously.

roderickvd avatar Mar 15 '25 14:03 roderickvd

Also, sometime between all that, (I'm assuming this is related, at least evidence of Spotify changing stuff) Spotify added TOTP login even for non MFA accounts on the web player, if you try logging in now, it'll send a code to your email, to login. It still gives a password login option though.

yodaluca23 avatar Mar 15 '25 14:03 yodaluca23

Thank god for y'all. Needed this badly to fix user search functionality in my app that became broke by their changes :)

speedx77 avatar Apr 29 '25 18:04 speedx77

Great! For the record, this is just one way to get an access token. We list 2 others on our wiki

Is there a link for it?

greffgreff avatar May 05 '25 12:05 greffgreff

If you can't find it: https://github.com/librespot-org/librespot/wiki/Options#access-token

kingosticks avatar May 05 '25 12:05 kingosticks

looks like Spotify switched over to HMAC-based One-Time Password for authentication in their latest web player

~~there is no point retrieving server time anymore~~

Image

sam9116 avatar Jun 10 '25 06:06 sam9116

@sam9116 I do not know how cryptography works. Does this mean we should hold any hope in being able to work around Spotify's new login process? Or should we give up? I really appreciate you checking into how their web player works, they broke my app and I'm in desperate need of a fix :-)

aviwad avatar Jun 10 '25 06:06 aviwad

@sam9116 I do not know how cryptography works. Does this mean we should hold any hope in being able to work around Spotify's new login process? Or should we give up? I really appreciate you checking into how their web player works, they broke my app and I'm in desperate need of a fix :-)

Image

as far as I can see, the secrets are still being generated the same way. but you will need to implement a persistent counter that keeps track of how many times the OTP have been requested, since Spotify will keep track of that on their server, if the two numbers doesn't match => OTP incorrect => no token

I'm not sure how sTime and cTime are generated or why they are needed as part of the token request, will have to do more testing around that

Image

sam9116 avatar Jun 10 '25 06:06 sam9116

interesting, it looks like the counter is actually implemented on the client side

Image

sam9116 avatar Jun 11 '25 02:06 sam9116

ok, I think I figured out how to get a legit access token from spotify again the new spotify token url parameter is completed as follows

https://open.spotify.com/api/token?reason=init&productType=web-player&totp={ hotp }&totpServer={ hotp }&totpVer=5&sTime={ serverTimeStamp }&cTime={ timestamp }&buildVer={"web-player_2025-06-10_1749524883369_eef30f4"}&buildDate={"2025-06-10"}

otp is computed using the same secret byte array as discussed earlier in this issue

but instead of a totp, it is now an hotp

and you will need to supply a counter

counter is computed using your current time (unix timestamp, in seconds) divided by 30, and then floored

it is still SHA1 hash algorithm, 6 digits,

totp and server totp will be the same value

severTimeStamp will be obtained from https://open.spotify.com/api/server-time, no credential or cookie required, you can reach this from an incognito browser

timestamp will be your current timestamp

buildVer and buildDate will be included in the spotify web player javascript file, you just need to search for buildVer and buildDate in the code

sam9116 avatar Jun 11 '25 03:06 sam9116

@sam9116 Thank you for trying to figure this out. I still got a 400 error with that ominous note, but the note itself is also present in the response received by spotify web so I guess it's fine. However, I noticed another parameter totpValidUntil which pointed to a date in the past. Any clue on how that works/ if it's required? My current code looks like so :

def get_spotify_access_token(sp_dc_cookie_value):
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
    }

    #raw secret byte array
    raw_secret = bytes([12, 56, 76, 33, 88, 44, 88, 33, 78, 78, 11, 66, 22, 22, 55, 69, 54])
    b32_secret = base64.b32encode(raw_secret).decode('utf-8')
    hotp = pyotp.HOTP(b32_secret)

    #HOTP code
    now_sec = int(time.time())
    counter = now_sec // 30
    code = hotp.at(counter)

    server_resp = requests.get("https://open.spotify.com/api/server-time", headers=headers)
    server_ts = server_resp.json()['serverTime']

    url = (
        "https://open.spotify.com/api/token?"
        f"reason=init&productType=web-player"
        f"&totp={code}&totpServer={code}"
        f"&totpVer=5"
        f"&sTime={server_ts}&cTime={now_sec*1000}"
        "&buildVer=unknown"
        "&buildDate=unknown"
        "&totpValidUntil=Wed Jun 16 2025 20:34:15 GMT+0530 (India Standard Time)"
    )

    session = requests.Session()
    session.headers.update(headers)
    session.cookies.set("sp_dc", sp_dc_cookie_value, domain=".spotify.com")

    resp = session.get(url)
    if resp.ok:
        token = resp.json().get("accessToken")
        print("Access Token:", token)
        return token
    else:
        print("❌ Failed:", resp.status_code, resp.text)
        return None

Optimuspime123 avatar Jun 16 '25 07:06 Optimuspime123

@Optimuspime123

I don't think the totpValidUntil matters, I just tried it with my old implementation and it still works

sam9116 avatar Jun 16 '25 07:06 sam9116

@sam9116 I see, would you mind sharing which cookies (other than sp_dc ) you are using, if any? I added an user agent, too - but still get a 400. Shouldn't really be a language specific thing.

Optimuspime123 avatar Jun 16 '25 07:06 Optimuspime123

@Optimuspime123 I use the entire cookie string provided by the spotify webclient

Image

sam9116 avatar Jun 16 '25 08:06 sam9116

Thanks, I got it working too :)

Optimuspime123 avatar Jun 16 '25 12:06 Optimuspime123

Spotify has updated their TOTP secret used for the open.spotify.com/get_access_token endpoint (now open.spotify.com/api/token). The "Invalid TOTP" error occurs because the hardcoded secret in most implementations is outdated.

What Changed?

Spotify rotates their TOTP secrets periodically for security. I've observed that their implementation maintains an array of versioned secrets, and version 5 has been removed from their current rotation. When everything was working previously, version 5 was still available in their secret array. Now they've moved to newer versions, with version 8 being the current active secret.

{
  "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
    }
  ]
}

The current secret array in Spotify's JavaScript contains versions 6, 7, and 8, but the old implementations were still using the old version 5 secret, which is why they suddenly stopped working.

Solution

The updated secret cipher bytes for version 8 are:

[37, 84, 32, 76, 87, 90, 87, 47, 13, 75, 48, 54, 44, 28, 19, 21, 22]

If you're using the old version 5 secret:

[12, 56, 76, 33, 88, 44, 88, 33, 78, 78, 11, 66, 22, 22, 55, 69, 54]

You need to update it to the new version 8 values above.

I've successfully tested this fix and can confirm it generates valid TOTP tokens that work with Spotify's current API.

Thereallo1026 avatar Jul 01 '25 04:07 Thereallo1026

They are going to keep doing this, right? And it's no problem that helpful people (thank you!) keep updating the fixes, but is there any thoughts about popping this in a separate, central project? Or a specific sub project under librespot with its own readme etc. I'm not concerned with extra noise here, it just seems like less people will find it in an issue here given it's only tangentially related to librespot. And it seems worth documenting what's going on with it also.

kingosticks avatar Jul 01 '25 08:07 kingosticks

They are going to keep doing this, right? And it's no problem that helpful people (thank you!) keep updating the fixes, but is there any thoughts about popping this in a separate, central project? Or a specific sub project under librespot with its own readme etc. I'm not concerned with extra noise here, it just seems like less people will find it in an issue here given it's only tangentially related to librespot. And it seems worth documenting what's going on with it also.

sure, if you are willing to create a new project/discussion for this and redirect-people over there.

I've setup a cron job to monitor the Spotify token acquisition process nightly, if it encounters problems, right now an email will be sent out to me, if a new point of discussion is set up I can set up some sort of automation via GitHub and notify everyone subscribed to it

sam9116 avatar Jul 01 '25 22:07 sam9116

Initially, the process relied on a v8 secret stored in a hardcoded JSON object. As we saw, that JSON had a validUntil field, and right on schedule, Spotify updated the logic.

The next version I analyzed, v9, moved away from the JSON object to a heavily obfuscated string value for its secret. The derivation logic was also new, requiring a multi step process: it took the initial string, performed an XOR cipher on its character codes, converted those values back into a new intermediate string, and then UTF8 encoded that string to get the final bytes for the Base32 key.

The biggest challenge is that halfway through my reverse engineering of that v9 system, they appear to have pushed another significant update. The code has been completely refactored, making the old function names and structures obsolete.

Here are my latest findings from the newest code, based on tracing the live network requests:

  1. New Function Names: The call stack now points to a master function called it(e), which in turn calls a parameter generator function, nt(...), to get the values for the API call.

  2. Dual TOTP Generation Confirmed: The most important discovery is that there are two separate TOTP values required: totp and totpServer.

  3. Timestamp Logic: My debugging shows totp is generated using the client's local clock (Date.now()), while totpServer is generated using a timestamp fetched from the https://open.spotify.com/api/server-time endpoint. There is also a fallback mechanism: if the call to get the server time fails, the code just uses the totp value for both parameters.

Despite reimplementing this exact logic (deriving the secret and generating two distinct TOTPs with their respective timestamps), my requests are still being rejected with an "Unauthorized request" error. This suggests the secret derivation is even more complex than I've mapped out, or there's another subtle detail I am missing.

Posting my findings here in case it helps anyone else looking into this or if a fresh pair of eyes can spot something I've overlooked. I'll keep at it.

Thereallo1026 avatar Jul 02 '25 00:07 Thereallo1026

I managed to find the new secret definitions. They've gotten clever and have split the version 9 secret string into three parts to prevent searching for it directly in the source. The string is only reconstructed at runtime.

Here is a snippet showing the definitions for the three most recent versions that I found in the latest code:

// This is where they define the new v9 secret object (obfuscated as 'Ue').
// Notice the secret is concatenated from three parts.
Ue[Le(794, 0, 0, 785)] = Me(585, 569, 584, 578) + Me(569, 586, 573, 580) + "9+$QaH5)N8",
Ue[Le(788, 0, 0, 790)] = 9;

// The v8 secret object for comparison (obfuscated as 'Be').
const Be = {};
Be[Me(0, 586, 0, 591)] = [37, 84, 32, 76, 87, 90, 87, 47, 13, 75, 48, 54, 44, 28, 19, 21, 22],
Be.version = 8;

// The v7 secret is now hardcoded directly in an object literal.
const Fe = {
    secret: [59, 91, 66, 74, 30, 66, 74, 38, 46, 50, 72, 61, 44, 71, 86, 39, 89],
    version: 7
};

I was able to inspect the final Ue object and can confirm the secret string is the same as the initial v9 version that I was reversing (before they pushed another update):

{
    "secret": "meZcB\\tlUFV1D6W2Hy4@9+$QaH5)N8",
    "version": 9
}

So, the good news is that the secret string and the derivation logic (XOR -> new string -> UTF8 bytes -> Base32) seem to be confirmed.

The bad news is that my implementation using this key and the dual timestamp logic (local time for totp, server time for totpServer) is still resulting in an "Unauthorized request" error.

I wanted to share this in case it helps anyone else, or if someone spots a flaw in my logic. I'm going to try comparing the final TOTP values generated by my code directly against the ones generated by the browser's JS to see if they differ. I will post another update if I find anything.

Thereallo1026 avatar Jul 02 '25 01:07 Thereallo1026

They still condense everything into one object after all of the logic:

            Ve[Me(0, 597, 0, 594)] = "2025-07-04" + Le(772, 0, 0, 780) + Le(778, 0, 0, 773),
            Ve[Me(0, 585, 0, 587)] = [Ue, Be, Fe];
            const He = Ve;

The object Ve has the exact same format as the old hardcoded JSON string:

{
  "validUntil": "2025-07-04T13:00:00.000Z",
  "secrets": [
    {
      "secret": "meZcB\\tlUFV1D6W2Hy4@9+$QaH5)N8",
      "version": 9
    },
    {
      "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
    }
  ]
}

This proves that all the complex, obfuscated code: the string splitting, the function wrappers, is a sophisticated obfuscation layer designed only to build this final object at runtime.

Thereallo1026 avatar Jul 02 '25 01:07 Thereallo1026

I have successfully reverse-engineered the entire parameter generation flow for the /api/token endpoint in the latest version of the web player.

Here's what I can confirm:

  1. I can reliably find the master function that calls the parameter generator.
  2. I can prove with the debugger that the function generates two distinct TOTP values: totp (from Date.now()) and totpServer (from the /api/server-time endpoint), and it correctly falls back to using the totp value for both if the server time call fails.
  3. By calling their internal function from the console, I can get a valid parameter object that works perfectly in a curl request. This proves the entire logical flow is understood.

I was able to intercept the secret object right before it's used by their internal TOTP generator. This is the final state of the data after all the string splitting, XORing, and other derivations have been completed. The object contains an array of bytes.

{"secret":{"bytes":{"0":49,"1":48,"2":48,"3":49,"4":49,"5":49,"6":56,"7":49,"8":49,"9":49,"10":49,"11":55,"12":57,"13":56,"14":50,"15":49,"16":50,"17":51,"18":49,"19":50,"20":52,"21":54,"22":56,"23":56,"24":52,"25":54,"26":57,"27":51,"28":55,"29":56,"30":49,"31":51,"32":50,"33":54,"34":52,"35":52,"36":50,"37":56,"38":49,"39":57,"40":57,"41":52,"42":55,"43":57,"44":50,"45":51,"46":54,"47":53,"48":51,"49":53,"50":57,"51":49,"52":49,"53":51,"54":54,"55":52,"56":49,"57":48,"58":54,"59":50,"60":50,"61":49,"62":51,"63":49,"64":48,"65":55,"66":51,"67":48}},"version":9}

Thereallo1026 avatar Jul 02 '25 01:07 Thereallo1026

that's the secret for totp version 10:

byte_list = 0: 53, 1: 50, 2: 49, 3: 48, 4: 48, 5: 52, 6: 57, 7: 49, 8: 49, 9: 48, 10: 52, 11: 54, 12: 54, 13: 53, 14: 49, 15: 50, 16: 50, 17: 56, 18: 53, 19: 51, 20: 49, 21: 57, 22: 57, 23: 48, 24: 55, 25: 57, 26: 49, 27: 49, 28: 52, 29: 56, 30: 48, 31: 55, 32: 53, 33: 54, 34: 50, 35: 49, 36: 50, 37: 53, 38: 53, 39: 49, 40: 56, 41: 49

as a python variable: secret=b"535049484852574949485254545349505056534949575748555749495256485553545049505353495649"

Micg25 avatar Jul 04 '25 15:07 Micg25

{
    "validUntil": "2025-07-07T09:00:00.000Z",
    "secrets": [
        {
            "secret": "=n:b#OuEfH\\fE])e*K",
            "version": 10
        },
        {
            "secret": "meZcB\\tlUFV1D6W2Hy4@9+$QaH5)N8",
            "version": 9
        },
        {
            "secret": [
                37,
                84,
                32,
                76,
                87,
                90,
                87,
                47,
                13,
                75,
                48,
                54,
                44,
                28,
                19,
                21,
                22
            ],
            "version": 8
        }
    ]
}

Thereallo1026 avatar Jul 05 '25 11:07 Thereallo1026