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/loginanswers HTTP 429, or/mobile/api/loginreports 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,GarminRateLimitErrornames the widget URL; other widget failures start withMobile login rate limited;, and a rejected password keeps itsSSO error:prefix. Tokens refresh for about 30 days without signing in, so keep them in aTokenStorerather than signing in again. - Where tokens are stored: wherever your
TokenStoreputs them.FileTokenStorewritesoauth1_token.jsonandoauth2_token.jsonto a directory you choose (./tokensin the examples above), using garth's on-disk format — tokens produced by Pythongarthload here unchanged. For serverless, implement the same three-method interface against your own database or cache; seeTokenStoreexample 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,
GarminAuthErroris 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 fromhttps://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 aGarminConnectionError. 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".