gpsoauth returns BadAuthentication on a 2FA Google account (app passwords do not help)
Symptom
Getting a master token for a library that speaks Google’s mobile auth protocol (in my case gkeepapi for
Google Keep) starts with gpsoauth. On an account with 2FA enabled it fails:
>>> import gpsoauth
>>> gpsoauth.perform_master_login("[email protected]", "…", android_id)
{'Error': 'BadAuthentication', 'ErrorDetail': ''}
The reflex is to generate an app password and retry. It fails the same way. App passwords work for IMAP/SMTP, not for this endpoint, so no amount of retrying the password path will produce a token.
The path that works: the oauth_token cookie
- Sign in to
https://accounts.google.com/EmbeddedSetupin a browser. - Click “I agree”.
- The page appears to hang on
.../o/android/auth— that is expected. That navigation is exactly when the cookie you need is set. - Read the cookie
oauth_token(value starts withoauth2_4/…). It is HttpOnly, sodocument.cookiein the console will not show it. - Exchange it:
import gpsoauth
res = gpsoauth.exchange_token("[email protected]", oauth_token, android_id)
master = res["Token"] # aas_et/…
The oauth_token is single-use and short-lived — exchange it immediately. If you sit on it while you
figure out the next step, you get to repeat the whole dance.
Capturing an HttpOnly cookie without hunting in DevTools
Manually copying it out of the Application tab works, but it is fiddly and you often lose the race. Drive
a throwaway Chrome profile over CDP instead. From WSL2 with mirrored networking, 127.0.0.1:9222 reaches
Chrome started on the Windows host:
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" \
--remote-debugging-port=9222 \
--user-data-dir='C:\keepdbg' \
--new-window "https://accounts.google.com/EmbeddedSetup"
Sign in and click “I agree” in that window, then ask the browser for all cookies — Network.getAllCookies
returns HttpOnly ones too, which is the entire reason for going through CDP:
import json, urllib.request, websockets, asyncio
ws_url = json.load(urllib.request.urlopen("http://127.0.0.1:9222/json/version"))["webSocketDebuggerUrl"]
async def grab():
async with websockets.connect(ws_url, max_size=None) as ws:
await ws.send(json.dumps({"id": 1, "method": "Network.getAllCookies"}))
while True:
msg = json.loads(await ws.recv())
if msg.get("id") == 1:
return [c for c in msg["result"]["cookies"] if c["name"] == "oauth_token"]
print(asyncio.run(grab()))
Clean up, because that profile is a logged-in session
Delete C:\keepdbg when you are done. It holds an authenticated Google session with no profile password
in front of it, and it is trivial to forget about a debug profile sitting in the root of a drive.
Store the resulting aas_et/… master token like any other long-lived credential — file mode 600 outside
your repository. It is not a password, but it is equivalent to one for the APIs it unlocks, and unlike a
password it does not get invalidated by a routine password change.