Skip to content

Instantly share code, notes, and snippets.

@coleman8er
Created March 21, 2026 14:53
Show Gist options
  • Select an option

  • Save coleman8er/5c8e192d2aa3c8a3a6220c5702e8a5e6 to your computer and use it in GitHub Desktop.

Select an option

Save coleman8er/5c8e192d2aa3c8a3a6220c5702e8a5e6 to your computer and use it in GitHub Desktop.
Get Garmin OAuth tokens via Playwright browser login — bypasses the 429-blocked SSO endpoint
#!/usr/bin/env python3
"""
Get Garmin OAuth tokens via real browser login (Playwright).
Bypasses the 429-blocked SSO programmatic login endpoint.
Usage:
uv run --with playwright --with requests-oauthlib scripts/garmin-browser-auth.py
First time setup (installs Chromium):
uv run --with playwright python -m playwright install chromium
"""
import base64
import json
import re
import time
from pathlib import Path
from urllib.parse import parse_qs
import requests
from requests_oauthlib import OAuth1Session
from playwright.sync_api import sync_playwright
OAUTH_CONSUMER_URL = "https://thegarth.s3.amazonaws.com/oauth_consumer.json"
ANDROID_UA = "com.garmin.android.apps.connectmobile"
def get_oauth_consumer():
"""Fetch the shared OAuth consumer key/secret from garth's S3 bucket."""
resp = requests.get(OAUTH_CONSUMER_URL, timeout=10)
resp.raise_for_status()
return resp.json()
def get_oauth1_token(ticket: str, consumer: dict) -> dict:
"""Exchange an SSO ticket for an OAuth1 token."""
sess = OAuth1Session(
consumer["consumer_key"],
consumer["consumer_secret"],
)
url = (
f"https://connectapi.garmin.com/oauth-service/oauth/"
f"preauthorized?ticket={ticket}"
f"&login-url=https://sso.garmin.com/sso/embed"
f"&accepts-mfa-tokens=true"
)
resp = sess.get(url, headers={"User-Agent": ANDROID_UA}, timeout=15)
resp.raise_for_status()
parsed = parse_qs(resp.text)
token = {k: v[0] for k, v in parsed.items()}
token["domain"] = "garmin.com"
return token
def exchange_oauth2(oauth1: dict, consumer: dict) -> dict:
"""Exchange OAuth1 token for OAuth2 token."""
sess = OAuth1Session(
consumer["consumer_key"],
consumer["consumer_secret"],
resource_owner_key=oauth1["oauth_token"],
resource_owner_secret=oauth1["oauth_token_secret"],
)
url = "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0"
data = {}
if oauth1.get("mfa_token"):
data["mfa_token"] = oauth1["mfa_token"]
resp = sess.post(
url,
headers={
"User-Agent": ANDROID_UA,
"Content-Type": "application/x-www-form-urlencoded",
},
data=data,
timeout=15,
)
resp.raise_for_status()
token = resp.json()
token["expires_at"] = int(time.time() + token["expires_in"])
token["refresh_token_expires_at"] = int(
time.time() + token["refresh_token_expires_in"]
)
return token
def browser_login() -> str:
"""Open a real browser, let user log in, capture the SSO ticket."""
ticket = None
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
# Navigate to SSO embed — same flow the Garmin web app uses
sso_url = (
"https://sso.garmin.com/sso/embed"
"?id=gauth-widget"
"&embedWidget=true"
"&gauthHost=https://sso.garmin.com/sso"
"&clientId=GarminConnect"
"&locale=en_US"
"&redirectAfterAccountLoginUrl=https://sso.garmin.com/sso/embed"
"&service=https://sso.garmin.com/sso/embed"
)
page.goto(sso_url)
print()
print("=" * 50)
print(" Browser opened — log in with your Garmin")
print(" credentials. The window will close")
print(" automatically when done.")
print("=" * 50)
print()
# Wait for the success redirect that contains the ticket
# The SSO flow ends with a page containing 'ticket=ST-...'
max_wait = 300 # 5 minutes
start = time.time()
while time.time() - start < max_wait:
try:
content = page.content()
# Look for ticket in page content (ST- prefix)
m = re.search(r'ticket=(ST-[A-Za-z0-9\-]+)', content)
if m:
ticket = m.group(1)
print(f"Got ticket: {ticket[:30]}...")
break
# Also check URL for ticket param
url = page.url
if "ticket=" in url:
m = re.search(r'ticket=(ST-[A-Za-z0-9\-]+)', url)
if m:
ticket = m.group(1)
print(f"Got ticket from URL: {ticket[:30]}...")
break
except Exception:
pass
page.wait_for_timeout(500)
browser.close()
if not ticket:
print("ERROR: Timed out waiting for login (5 min). Try again.")
raise SystemExit(1)
return ticket
def main():
print("Garmin Browser Auth")
print("=" * 50)
# Step 1: Get OAuth consumer credentials
print("Fetching OAuth consumer credentials...")
consumer = get_oauth_consumer()
# Step 2: Browser login to get SSO ticket
print("Launching browser...")
ticket = browser_login()
# Step 3: Exchange ticket for OAuth1
print("Exchanging ticket for OAuth1 token...")
oauth1 = get_oauth1_token(ticket, consumer)
print(f" OAuth1 token: {oauth1['oauth_token'][:20]}...")
# Step 4: Exchange OAuth1 for OAuth2
print("Exchanging OAuth1 for OAuth2 token...")
oauth2 = exchange_oauth2(oauth1, consumer)
print(f" OAuth2 access_token: {oauth2['access_token'][:20]}...")
print(f" Expires in: {oauth2['expires_in']}s")
print(f" Refresh expires in: {oauth2['refresh_token_expires_in']}s")
# Step 5: Verify tokens work
print("Verifying tokens...")
verify_resp = requests.get(
"https://connectapi.garmin.com/userprofile-service/socialProfile",
headers={
"User-Agent": "GCM-iOS-5.7.2.1",
"Authorization": f"Bearer {oauth2['access_token']}",
},
timeout=15,
)
verify_resp.raise_for_status()
profile = verify_resp.json()
print(f" Authenticated as: {profile.get('displayName', 'unknown')}")
# Step 6: Save tokens
garth_dir = Path.home() / ".garth"
garth_dir.mkdir(exist_ok=True)
(garth_dir / "oauth1_token.json").write_text(json.dumps(oauth1, indent=2))
(garth_dir / "oauth2_token.json").write_text(json.dumps(oauth2, indent=2))
print(f"\nTokens saved to {garth_dir}")
# Step 7: Output base64 bundle for GitHub secret
bundle = {"oauth1": oauth1, "oauth2": oauth2}
b64 = base64.b64encode(json.dumps(bundle).encode()).decode()
print("\n" + "=" * 50)
print("GARMIN_TOKEN_B64 (paste into GitHub secret):")
print("=" * 50)
print(b64)
print("=" * 50)
if __name__ == "__main__":
main()
@mjstewart
Copy link
Copy Markdown

mjstewart commented Mar 25, 2026

Thanks alot for whipping this up, got me unblocked for the time being.

I thought I would add a bit of a guide of how it can be used in practice. For my own situation I have a class that sets up the client, it looks like this in the minimal example, the main part is to replace the garth client to avoid the login and use the token populated by this script. I didn't think much about this, there might be better ways, I just did this quickly to quickly get unblocked as I have a super important app I made myself I want to keep working. Stupid garmin!

from pathlib import Path
import garminconnect
import garth

class MyClient:

  def __init__(self):
  
        garth_home = Path.home() / ".garth"
        if not garth_home.exists():
            raise FileNotFoundError(
                f"Garth tokens not found at {garth_home}. "
                "Run garmin-browser-auth.py to authenticate via browser login."
            )
        garth.resume(str(garth_home))
        
        # Initialize Garmin without credentials
        self.api = garminconnect.Garmin()
        # Replace its garth client with the one we just loaded
        self.api.garth = garth.client
        
    def get_devices(self):
        return self.api.get_devices()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment