garmin · Article
Fixing Garmin MCP auth: extracting OAuth tokens with Node
Garmin login cannot be scripted reliably, so I stopped trying. A Playwright window, one service ticket and two OAuth exchanges produce tokens that every garth-based tool already knows how to read, and no password ends up in a config file.

Every Garmin MCP server starts the same way. Put your Connect email and password in the environment, and it will log in for you.
That works until it does not, which for me was immediately. Garmin's login is a CAS single sign-on flow behind bot protection, with MFA on top, and a headless client posting a form to it gets a challenge page rather than a session. The failure is not even honest: you get a 401 with an HTML body, or a redirect loop, or a success that turns into a 403 on the first real request.
The second problem is worse and quieter. Storing the password at all is the wrong shape for this.
services: garmin-mcp: environment: - GARMIN_EMAIL=me@example.com - GARMIN_PASSWORD=hunter2 - GARMIN_MFA_CODE=123456Three things there are broken. The password is in plaintext on disk. Compose
environment: values are readable by anyone who can run docker inspect, so it
is worse than a file with tight permissions. And an MFA code is a six-digit
number that expires in thirty seconds, so hardcoding one means it is wrong by
the time the container restarts.
Stop automating the login
The insight that fixed this: I do not need to automate the login. I need what the login produces.
Garmin's SSO issues a service ticket, a short string beginning ST-, and that
ticket is all the OAuth exchange needs. A human can get one in five seconds by
typing a password into a real browser, including any MFA prompt, and the browser
handles every bot check for free because it is not a bot.
So the script opens a browser, waits for me to log in, harvests the ticket, and does the rest in code. Two dependencies.
{ "type": "module", "dependencies": { "oauth-1.0a": "^2.2.6", "playwright": "^1.58.2" }}Consumer credentials
The OAuth1 exchange has to be signed with the consumer key and secret belonging to Garmin's mobile app. Those are not published by Garmin, but the garth project keeps a copy in a public bucket, and every tool in this ecosystem reads it from there.
const OAUTH_CONSUMER_URL = "https://thegarth.s3.amazonaws.com/oauth_consumer.json";const ANDROID_UA = "com.garmin.android.apps.connectmobile";function createOAuthClient(consumer) { return new OAuth({ consumer: { key: consumer.consumer_key, secret: consumer.consumer_secret, }, signature_method: "HMAC-SHA1", hash_function(baseString, key) { return crypto.createHmac("sha1", key).update(baseString).digest("base64"); }, });}HMAC-SHA1 is not a choice here. It is what the endpoint accepts, and the
oauth-1.0a package wants the hash function supplied rather than built in.
Harvesting the ticket
Playwright opens the SSO widget with headless: false, because the entire point
is that a person is looking at it.
const ssoUrl = "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";await page.goto(ssoUrl);const maxWaitMs = 300000;const start = Date.now();while (Date.now() - start < maxWaitMs) { try { const content = await page.content(); let m = content.match(/ticket=(ST-[A-Za-z0-9-]+)/); if (m) { ticket = m[1]; break; } const url = page.url(); if (url.includes("ticket=")) { m = url.match(/ticket=(ST-[A-Za-z0-9-]+)/); if (m) { ticket = m[1]; break; } } } catch { // ignore transient page errors } await page.waitForTimeout(500);}The polling loop checks two places, and it has to. Depending on how the widget resolves, the ticket lands either in the page body or in the query string, and which one you get is not stable. Checking only the URL is the version of this script that works on your machine and fails on someone else's.
The try with an empty catch is deliberate too. Calling page.content() while
the widget is mid-navigation throws, and that is a normal event in a 500ms poll
loop, not an error worth handling.
Five minutes of patience is the right timeout. Long enough to find your phone, open the authenticator and type six digits without the script giving up on you.
Ticket to OAuth1
With the ticket in hand this is a signed GET, and the query string carries three things that all matter.
const url = "https://connectapi.garmin.com/oauth-service/oauth/preauthorized" + `?ticket=${encodeURIComponent(ticket)}` + "&login-url=https://sso.garmin.com/sso/embed" + "&accepts-mfa-tokens=true";const requestData = { url, method: "GET" };const oauthHeader = oauth.toHeader(oauth.authorize(requestData));const resp = await fetchWithTimeout(url, { method: "GET", headers: { ...oauthHeader, "User-Agent": ANDROID_UA },}, 15000);const text = await resp.text();if (!resp.ok) throw new Error(`OAuth1 exchange failed: ${resp.status} ${text}`);const parsed = Object.fromEntries(new URLSearchParams(text).entries());parsed.domain = "garmin.com";return parsed;accepts-mfa-tokens=true is the one that took longest to find. Without it, an
account with MFA enabled gets a token that fails on the next exchange. With it,
the response carries an extra mfa_token that has to be forwarded.
The response is form-encoded, not JSON, which is why it goes through
URLSearchParams rather than JSON.parse. And domain is added by hand because
garth expects that field to exist in the file it reads later.
OAuth1 to OAuth2
const token = { key: oauth1.oauth_token, secret: oauth1.oauth_token_secret,};const data = {};if (oauth1.mfa_token) data.mfa_token = oauth1.mfa_token;const requestData = { url, method: "POST", data };const oauthHeader = oauth.toHeader(oauth.authorize(requestData, token));const body = new URLSearchParams(data).toString();The signature covers the body, so data is passed into authorize() and then
serialised for the request. Sign one thing and send another and you get a 401
with no explanation of which half was wrong.
Garmin returns expires_in and refresh_token_expires_in, both relative. Those
get turned into absolute timestamps, because a duration is meaningless once it
has been sitting in a file for a week:
const out = JSON.parse(text);out.expires_at = Math.floor(Date.now() / 1000) + out.expires_in;out.refresh_token_expires_at = Math.floor(Date.now() / 1000) + out.refresh_token_expires_in;Verify before you trust it
A token that parses is not a token that works. One request settles it.
const verifyResp = await fetchWithTimeout( "https://connectapi.garmin.com/userprofile-service/socialProfile", { headers: { "User-Agent": "GCM-iOS-5.7.2.1", Authorization: `Bearer ${oauth2.access_token}`, }, }, 15000);Note the user agent changed. The OAuth exchanges are signed as the Android app;
this call presents as iOS. Both are accepted, and mismatched agents are a
plausible source of a 403 that looks like an auth failure, so it is worth keeping
them explicit rather than defaulting to whatever fetch sends.
Where the tokens go
This is the part that makes it useful beyond one script.
const garthDir = path.join(os.homedir(), ".garth");await fs.mkdir(garthDir, { recursive: true });await fs.writeFile(path.join(garthDir, "oauth1_token.json"), JSON.stringify(oauth1, null, 2), "utf8");await fs.writeFile(path.join(garthDir, "oauth2_token.json"), JSON.stringify(oauth2, null, 2), "utf8");~/.garth with those two filenames is the convention garth resumes from, and
garth is what python-garminconnect uses, which is what most Garmin MCP servers
wrap. Writing the files in that shape means every tool in the chain is
authenticated without knowing this script exists. No plugin, no patch.
For CI, the same two objects go out as one base64 blob, which is a single secret to paste rather than two files to mount:
const bundle = { oauth1, oauth2 };const b64 = Buffer.from(JSON.stringify(bundle), "utf8").toString("base64");The compose file afterwards
services: garmin-mcp: image: garmin-mcp:latest restart: unless-stopped ports: - "127.0.0.1:8000:8000" environment: - GARMIN_MCP_TRANSPORT=streamable-http - HOST=0.0.0.0 - GARMIN_MCP_PORT=8000 volumes: - ~/.garth:/root/.garth:roThe email, the password and the MFA code are gone. The container gets a read-only mount of two token files and nothing else, and the port binds to loopback so the MCP server is reachable from n8n on the same host but not from the network.
If your password has ever been in a compose file, an .env, or a shell
history, change it. Not because someone definitely read it, but because you
cannot prove they did not, and Garmin Connect holds your location history for
every run you have ever recorded.
What breaks, and when
Tokens expire. The script prints both lifetimes when it runs, the access token measured in hours and the refresh token in months, and garth refreshes the access token on its own until the refresh token dies. When that happens the answer is to run the script again and log in, which takes about ten seconds.
The larger caveat is that none of this is a supported interface. It signs with consumer credentials lifted from the mobile app and talks to endpoints Garmin never documented, so any of it can stop working without notice. That is the trade for a Connect Developer Program that individuals cannot join.
Worth it for reading your own data out of your own account. Not worth building a product on.
- garmin
- mcp
- oauth
- nodejs
- playwright
Like what you see?
Let's build something great together.