DEV Community

Cover image for A Next.js app with Garmin sign-in, done properly
Ivan Ficko
Ivan Ficko

Posted on

A Next.js app with Garmin sign-in, done properly

For developers. Just want to ask Claude about your training, with no code? Start with Your Garmin data in Claude instead.

In Part 1 we signed in to Garmin from a script and printed last night's sleep. A script has it easy: one user, one machine, and a terminal to type the MFA code into.

A web app has none of that. Your users sign in through a form, the MFA code arrives in a second request, and many users' Garmin sessions have to live somewhere safer than a folder. This post builds that properly with garminconnect-js and the Next.js App Router:

  1. Storing each user's Garmin tokens, encrypted, in your own database
  2. Signing in with MFA across two requests, using Server Actions
  3. Reading Garmin data in a Server Component
  4. The security details that are easy to get wrong

Written against garminconnect-js 0.10.0 and Next.js 16. Every snippet here type-checks against those versions. For a complete, runnable app, see the Next.js starter template, which this post's code is based on.

The ground rules

Two rules shape everything below.

Server only. garminconnect-js uses node:crypto and Buffer, so it runs in Node, never in the browser and never on the Edge runtime. More importantly, a user's Garmin password and tokens must never reach the browser. So:

  • Import it only from server code: Server Components, Server Actions and route handlers. Never from a file with "use client".
  • In any route handler that uses it, add export const runtime = "nodejs";.
  • Put import "server-only"; at the top of your Garmin helper module. If someone imports it from a Client Component by mistake, the build fails instead of shipping your secrets to the browser.

Treat Garmin tokens like passwords. A user's tokens give full access to their Garmin account for about 30 days. Encrypt them at rest, and delete them when the user disconnects.

Step 1: a token store per user

In Part 1, FileTokenStore kept one person's session in a folder. In a web app, each of your users connects their Garmin account, so tokens belong in your database, next to the user.

garminconnect-js asks for very little: a TokenStore with three methods.

interface TokenStore {
  load(): Promise<Tokens | null>;
  save(tokens: Tokens): Promise<void>;
  clear(): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The library calls save() after a sign-in and after every automatic token refresh, and load() whenever you call loadTokens().

First, a small helper that encrypts a value with AES-256-GCM, keyed from a secret in your environment:

// lib/seal.ts
import "server-only";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";

function key(): Buffer {
  const secret = process.env.SESSION_SECRET;
  if (!secret || secret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters");
  return createHash("sha256").update(secret).digest();
}

export function seal(value: unknown): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", key(), iv);
  const body = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
  return Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url");
}

/** The original value, or null if it was tampered with or sealed with another key. */
export function unseal<T>(sealed: string): T | null {
  try {
    const raw = Buffer.from(sealed, "base64url");
    const decipher = createDecipheriv("aes-256-gcm", key(), raw.subarray(0, 12));
    decipher.setAuthTag(raw.subarray(12, 28));
    const body = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
    return JSON.parse(body.toString("utf8")) as T;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Then the store. It works with any database. Here it talks to a minimal garminTokens table through an interface you'd back with Prisma, Drizzle or plain SQL:

// lib/garmin-token-store.ts
import "server-only";
import type { TokenStore, Tokens } from "garminconnect-js";
import { seal, unseal } from "./seal";

/** One row per user: their sealed Garmin tokens. Back this with your ORM of choice. */
export interface GarminTokenTable {
  find(userId: string): Promise<string | null>;
  upsert(userId: string, sealed: string): Promise<void>;
  remove(userId: string): Promise<void>;
}

export function garminTokenStore(table: GarminTokenTable, userId: string): TokenStore {
  return {
    async load() {
      const sealed = await table.find(userId);
      const tokens = sealed ? unseal<Tokens>(sealed) : null;
      // The expiry stamps drive every refresh decision. If they didn't survive, treat it as signed out.
      if (typeof tokens?.oauth2?.expires_at !== "number" || typeof tokens.oauth2.refresh_token_expires_at !== "number") {
        return null;
      }
      return tokens;
    },
    async save(tokens) {
      await table.upsert(userId, seal(tokens));
    },
    async clear() {
      await table.remove(userId);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

The one thing to get right here: store the whole Tokens object. It carries expires_at and refresh_token_expires_at as numbers, and the library reads them to decide when to refresh. A schema with a column per field, or a serializer that keeps only the "important" fields, quietly drops them. Without them, the library treats the token as expired (it fails closed) and refreshes on every call. Sealing the whole object as one string, as above, keeps everything. Write one test that saves and loads tokens through your store and checks both numbers survive.

Now a helper that gives you a ready Garmin for a user, or null if they haven't connected yet:

// lib/garmin.ts
import "server-only";
import { Garmin, GarminClient } from "garminconnect-js";
import { garminTokenStore, type GarminTokenTable } from "./garmin-token-store";

declare const garminTokens: GarminTokenTable; // your database table

export function clientFor(userId: string): GarminClient {
  return new GarminClient({ tokenStore: garminTokenStore(garminTokens, userId) });
}

export async function garminFor(userId: string): Promise<Garmin | null> {
  const client = clientFor(userId);
  return (await client.loadTokens()) ? new Garmin(client) : null;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: sign-in with MFA, across two requests

Here's the problem MFA creates on the web. The user submits their email and password. Garmin says "now send me the code". The code arrives in the user's inbox, and they submit it in a second request, possibly handled by a different server instance.

garminconnect-js was built for this. login() doesn't wait for the code. It returns { state: "mfa_required", mfaState }, where mfaState is plain JSON with no password in it. You keep it between the two requests, then hand it to resumeLogin(mfaState, code).

There's a catch: mfaState is still sensitive. It contains a live, half-finished Garmin sign-in, so anyone holding it plus the code can finish the login. So it goes into an encrypted, httpOnly, short-lived cookie, and is deleted the moment it's used.

// app/connect/actions.ts
"use server";

import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { GarminAuthError, GarminRateLimitError, type MfaState } from "garminconnect-js";
import { clientFor } from "@/lib/garmin";
import { seal, unseal } from "@/lib/seal";

declare function currentUserId(): Promise<string>; // from your own auth

export type ConnectState = { step: "credentials" | "code"; error?: string };

const MFA_COOKIE = "garmin_mfa";

function explain(e: unknown, step: ConnectState["step"]): string {
  if (e instanceof GarminRateLimitError) return "Garmin is limiting sign-ins right now. Wait a while and try again.";
  if (e instanceof GarminAuthError && e.message.startsWith("SSO error:")) {
    return step === "code" ? "Garmin rejected that code." : "Garmin rejected that email or password.";
  }
  return "Garmin sign-in failed. Try again later.";
}

/** One action for both steps: the second form posts a `code`. */
export async function connectGarmin(_prev: ConnectState, form: FormData): Promise<ConnectState> {
  const userId = await currentUserId();
  const jar = await cookies();

  if (!form.has("code")) {
    const email = String(form.get("email") ?? "").trim();
    const password = String(form.get("password") ?? "");
    let result;
    try {
      result = await clientFor(userId).login(email, password);
    } catch (e) {
      return { step: "credentials", error: explain(e, "credentials") };
    }
    if (result.state === "mfa_required") {
      jar.set(MFA_COOKIE, seal(result.mfaState), {
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "lax",
        maxAge: 600, // ten minutes to enter the code
        path: "/",
      });
      return { step: "code" };
    }
    redirect("/dashboard"); // signed in without MFA; tokens are already saved
  }

  const sealed = jar.get(MFA_COOKIE)?.value;
  const mfaState = sealed ? unseal<MfaState>(sealed) : null;
  if (!mfaState) return { step: "credentials", error: "That sign-in expired. Start again." };

  try {
    await clientFor(userId).resumeLogin(mfaState, String(form.get("code") ?? "").trim());
  } catch (e) {
    return { step: "code", error: explain(e, "code") };
  }
  jar.delete(MFA_COOKIE);
  redirect("/dashboard");
}

export async function disconnectGarmin(): Promise<void> {
  await clientFor(await currentUserId()).tokenStore.clear();
  redirect("/dashboard");
}
Enter fullscreen mode Exit fullscreen mode

A few details worth calling out:

  • The tokens are saved for you. When login() or resumeLogin() succeeds, the library calls your store's save(). There's no extra step.
  • redirect() sits outside the try. Next.js implements redirect() by throwing, so a catch around it would swallow the redirect.
  • Only an SSO error: means a wrong password or code. A 403, or a Cloudflare challenge page, means Garmin is refusing the request itself, often because it's rate-limiting your server's IP. Telling users "wrong password" in that case sends them chasing the wrong problem.
  • The MFA cookie can get large. If Garmin's mobile sign-in is rate-limited, the library falls back to its web sign-in, whose state is bigger. Browsers cap a cookie at about 4 KB, so the starter template compresses the state before sealing it. Copy that if you see sign-ins fail at the code step.

The form itself is an ordinary Client Component calling the Server Action through useActionState. It only ever sees { step, error }, never a token:

// app/connect/connect-form.tsx
"use client";

import { useActionState } from "react";
import { connectGarmin, type ConnectState } from "./actions";

export function ConnectForm() {
  const [state, action, pending] = useActionState<ConnectState, FormData>(connectGarmin, { step: "credentials" });
  return (
    <form action={action}>
      {state.step === "credentials" ? (
        <>
          <input name="email" type="email" autoComplete="username" required />
          <input name="password" type="password" autoComplete="current-password" required />
        </>
      ) : (
        <input name="code" inputMode="numeric" autoComplete="one-time-code" placeholder="Code from Garmin" required />
      )}
      {state.error && <p role="alert">{state.error}</p>}
      <button disabled={pending}>{pending ? "Signing in…" : "Connect Garmin"}</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Tell users plainly where their password goes before they type it. It passes through your server to Garmin's sign-in and is never stored, so say so.

Step 3: read data in a Server Component

With tokens in the database, reading data is short:

// app/dashboard/page.tsx
import Link from "next/link";
import { GarminAuthError, type PersonalApi } from "garminconnect-js";
import { garminFor } from "@/lib/garmin";

declare function currentUserId(): Promise<string>;

export default async function Dashboard() {
  const garmin = await garminFor(await currentUserId());
  if (!garmin) return <Link href="/connect">Connect your Garmin account</Link>;

  const today = new Date().toISOString().slice(0, 10);
  try {
    const sleep = await garmin.getSleepData(today);
    const night = sleep?.dailySleepDTO as PersonalApi.DailySleepDTO | undefined;
    return (
      <p>
        {night?.sleepTimeSeconds
          ? `Last night: ${(night.sleepTimeSeconds / 3600).toFixed(1)} h, score ${night.sleepScores?.overall?.value ?? "n/a"}`
          : "No sleep recorded last night."}
      </p>
    );
  } catch (e) {
    if (e instanceof GarminAuthError) return <Link href="/connect">Your Garmin connection expired. Connect again.</Link>;
    throw e;
  }
}
Enter fullscreen mode Exit fullscreen mode

Three things happen here without you asking:

  • Tokens refresh themselves. The access token lasts about a day. When it's close to expiring, the library renews it before the request and calls your store's save() with the new one. Because the store writes to a database, this works anywhere, including a Server Component. (A cookie-based store can't do that: a Server Component may read cookies but not set them. The starter template's public mode lives with that limitation; a database store doesn't have it.)
  • The session lasts as long as it's used. As long as each user's connection is used at least once every 30 days, it keeps rolling forward. After that you get GarminAuthError, and the user connects again, as handled above.
  • One Garmin per request is fine here. Garmin caches the user's profile, which date-based methods need, so a page making several calls should reuse one instance, as this one does. In a long-running worker, keep one per user rather than one per call.

Step 4: the security details

Rate-limit your sign-in action. A public form that forwards passwords to Garmin is attractive for credential-stuffing. And Garmin rate-limits sign-ins per server IP: if one abuser triggers it, every one of your users is locked out of connecting. The starter template allows 5 attempts per IP and 60 overall per 15 minutes. That counter lives in memory, so share it through Redis before you run more than one instance.

Ids from the URL are safe to pass, but still check access. It's tempting to write garmin.getActivity(params.id) with an id straight from the URL. garminconnect-js encodes every id it puts into a Garmin URL, so a crafted id like ../../weight-service/... can't redirect the request to a different Garmin endpoint. Earlier versions had exactly that bug, and it's now covered by tests. But encoding only makes the id harmless to Garmin. It doesn't make it yours. Always look up which user is asking, and use that user's Garmin connection, never one chosen by the request.

Let users disconnect. disconnectGarmin above deletes the stored tokens. Be honest in your UI about what that does: Garmin has no endpoint to revoke a session, so the tokens stop working on your side immediately, but to end the session on Garmin's side the user has to change their Garmin password.

Garmin may block your server's IP. Garmin rate-limits sign-ins, sometimes after only one or two from the same IP. When the mobile sign-in is blocked, the library tries Garmin's web sign-in once instead, pausing a few seconds first, so a sign-in can take ten seconds or so. Show a spinner, and don't let users hammer the button. If sign-ins still fail with a 403 or a "Just a moment..." page, Cloudflare is challenging your server, and no code change on your side fixes that. Waiting does.

What's next

You now have a web app where each user connects their own Garmin account, with MFA, encrypted storage and the failure modes handled. In Part 3 we'll make it useful: searching activities, digging into one activity's details and splits, downloading the original files, and pulling training readiness and race predictions into a weekly report.

Building something with Garmin data? Tell me in the comments. I'd love to see what people make.

Top comments (0)