Skip to content
Docs menu

Authentication

Authentication

Login follows Garmin's SSO flow: GarminClient.login(email, password) exchanges credentials for an OAuth1 token, then exchanges that for a short-lived OAuth2 access token. Both are handed to your TokenStore.

  • Rate-limited sign-in. Garmin rate limits the mobile sign-in route, per client id and source IP, often after one or two sign-ins. When the mobile sign-in page or /mobile/api/login answers HTTP 429, or /mobile/api/login reports a 429 inside a 200 JSON reply, login() signs in once more through Garmin's SSO web widget (/sso/embed + /sso/signin), which sends no client id, and returns the same tokens. If the widget is rate limited too, GarminRateLimitError names the widget URL; other widget failures start with Mobile login rate limited; , and a rejected password keeps its SSO error: prefix. Tokens refresh for about 30 days without signing in, so keep them in a TokenStore rather than signing in again.
  • Where tokens are stored: wherever your TokenStore puts them. FileTokenStore writes oauth1_token.json and oauth2_token.json to a directory you choose (./tokens in the examples above), using garth's on-disk format — tokens produced by Python garth load here unchanged. For serverless, implement the same three-method interface against your own database or cache; see TokenStore example below.
  • Auto-refresh: the OAuth2 access token refreshes automatically, using the OAuth1 token (not an OAuth2 refresh token — Garmin's flow doesn't have one), before it expires. You never call refresh yourself; connectapi() calls do it transparently and persist the refreshed token back to your store.
  • Observed lifetimes: OAuth2 access tokens have lasted roughly 27 hours, and the OAuth1 token about 30 days. In practice a session keeps rolling forward as long as you use it — call any method — at least once every 30 days. Beyond that window, GarminAuthError is thrown and you need to log in again.
  • Cached tokens: once tokens exist in your store, client.loadTokens() reads them back and no further password prompt is needed until the refresh window above lapses.
  • A third-party request at login. The OAuth consumer key and secret are not bundled; like garth, the library fetches them from https://thegarth.s3.amazonaws.com/oauth_consumer.json, a bucket run by garth's author. That happens once per process, on the first login or token refresh, and the result is cached. If that URL is unreachable — an outage, or a server whose egress is firewalled — login and refresh fail with a GarminConnectionError. Allow-list it if you restrict outbound traffic.

MFA across two HTTP requests

login() returns { state: "mfa_required", mfaState } instead of throwing. mfaState is plain JSON with no password in it, so it survives a round trip through a session store — which is what makes MFA work on serverless, where the code arrives in a different request than the one that started the login:

// app/api/garmin/login/route.ts
export const runtime = "nodejs";

export async function POST(req: Request) {
  const { email, password } = await req.json();
  const client = new GarminClient({ tokenStore: myStore });
  const result = await client.login(email, password);

  if (result.state === "mfa_required") {
    await session.set("garminMfa", result.mfaState); // encrypted session
    return Response.json({ mfaRequired: true, method: result.mfaState.mfaMethod });
  }
  return Response.json({ mfaRequired: false });
}

// app/api/garmin/mfa/route.ts
export const runtime = "nodejs";

export async function POST(req: Request) {
  const { code } = await req.json();
  const mfaState = await session.get("garminMfa");
  const client = new GarminClient({ tokenStore: myStore });
  await client.resumeLogin(mfaState, code);
  await session.delete("garminMfa");
  return Response.json({ ok: true });
}

Treat mfaState as a short-lived secret: mfaState.cookies is a live, partially authenticated SSO session, so anyone holding it can finish the login with the code. Encrypt it at rest, scope it to the session that started the login, and delete it once used. The MFA path is exercised live by npm run login (see See it run) as well as unit-tested.

mfaState.flow says which sign-in route produced it: "mobile" (the default; states saved by 0.1.0 or 0.2.0 have no flow and count as mobile) or "widget". A widget state also holds the page's CSRF token and form parameters. Pass either kind straight back to resumeLogin; read loginParams only after checking flow !== "widget".