Skip to content

Add SSO web widget login strategy to bypass 429 rate limiting #344

@diegoscarabelli

Description

@diegoscarabelli

Problem

All four current login strategies (portal+cffi, portal+requests, mobile+cffi, mobile+requests) are subject to aggressive 429 rate limiting on Garmin's SSO endpoints. Once triggered, the rate limit is per-account (not per-IP or per-User-Agent), making it impossible to recover by changing network or headers. This affects issues #332 and #337.

The rate limiting appears to be keyed on the clientId parameter (GarminConnect for portal, GCM_ANDROID_DARK for mobile) combined with the account email. The official Garmin Connect mobile app is unaffected because it uses a different rate limit bucket.

Root cause analysis

We tested systematically by isolating variables:

  1. Changed IP (mobile hotspot): still 429. Rules out IP-only rate limiting.
  2. Changed User-Agent (randomized via ua_generator): still 429. UA is not the trigger.
  3. Changed client ID to GCM_IOS_DARK: bypassed rate limit for one account but not the other (which had accumulated more failed attempts). Confirms rate limiting is per clientId + account.
  4. Used the garth-style web widget flow (/sso/embed + /sso/signin): bypassed rate limit completely for both accounts, even ones that were actively throttled on portal and mobile endpoints.

The web widget flow works because it uses a completely different SSO path: an HTML form-based login with CSRF tokens, no clientId parameter. Garmin's rate limiting doesn't apply to this endpoint, and curl_cffi with Chrome TLS impersonation passes Cloudflare's fingerprint check.

Proposed fix

Add a fifth login strategy, widget+cffi, that uses the SSO embed widget flow with curl_cffi. No new dependencies needed.

The flow:

# 1. Set cookies
sess.get("https://sso.garmin.com/sso/embed", params={
    "id": "gauth-widget", "embedWidget": "true",
    "gauthHost": "https://sso.garmin.com/sso"
})

# 2. Get CSRF token
r = sess.get("https://sso.garmin.com/sso/signin", params=SIGNIN_PARAMS)
csrf = re.search(r'name="_csrf"\s+value="(.+?)"', r.text).group(1)

# 3. POST login (HTML form, no clientId)
r = sess.post("https://sso.garmin.com/sso/signin", params=SIGNIN_PARAMS, data={
    "username": email, "password": password,
    "embed": "true", "_csrf": csrf
})

# 4. Handle MFA if needed (title == "Enter MFA code for login")
# POST to /sso/verifyMFA/loginEnterMfaCode with mfa-code field

# 5. Extract service ticket from success page
ticket = re.search(r'embed\?ticket=([^"]+)"', r.text).group(1)

The service ticket then feeds into the existing DI token exchange (_exchange_service_ticket), so no changes needed downstream.

Strategy order suggestion

widget+cffi should be tried first (or second after portal+cffi), since it's the only strategy that reliably bypasses both Cloudflare TLS fingerprinting and client ID rate limiting.

Trade-offs

The web widget flow parses HTML (CSRF token from a hidden input, success/MFA status from the <title> tag). This is more fragile than the JSON-based portal/mobile APIs. However, it's worth noting:

  • The garth library used this exact flow successfully for years
  • The JSON endpoints are currently unusable for many users due to rate limiting
  • Having it as one strategy in the fallback chain means if Garmin changes the HTML, the other strategies still exist as fallback

Test results

Tested locally on 2026-04-04 against two accounts (one with MFA, one without). The web widget flow was run twice consecutively for both accounts, succeeding both times:

  • Both accounts were actively 429-blocked on all four existing strategies
  • Both accounts authenticated successfully via the web widget flow on both runs
  • Service tickets exchanged for DI tokens without issues
  • MFA (TOTP) handled end-to-end for the MFA-enabled account
  • API calls (social profile, activity data) worked with the resulting tokens

Implementation: #345

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions