---
title: "Why Is My Fitness API Refresh Token Not Working?"
canonical: "https://aifitnessapi.com/fix/refresh-token-not-working"
cluster: "Troubleshooting"
primary_query: "fitness api refresh token not working"
last_reviewed: "2026-07-09"
description: "Your fitness API refresh token works once then returns invalid_grant? You're not saving the rotated refresh token. Here's the correct refresh flow."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Why Is My Fitness API Refresh Token Not Working?\", AIFitnessAPI, https://aifitnessapi.com/fix/refresh-token-not-working"
---

# Why Is My Fitness API Refresh Token Not Working?

> If your refresh works once and then every later attempt returns 400 invalid_grant, you almost certainly failed to persist a rotated refresh token. Strava, WHOOP, Oura, Garmin, and Fitbit return a NEW refresh token in the refresh response and invalidate the old one immediately. The fix is to read the refresh_token out of every refresh response and save it, overwriting the stored value. Other causes: an expired or revoked token, a missing offline scope, wrong client credentials, or clock skew.

- Canonical: https://aifitnessapi.com/fix/refresh-token-not-working
- Last reviewed: 2026-07-09
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Why Is My Fitness API Refresh Token Not Working?", AIFitnessAPI, https://aifitnessapi.com/fix/refresh-token-not-working

---

Your refresh works exactly once, then every later refresh returns `400 invalid_grant`. In the fitness-API world this is almost always one cause: the provider rotated your refresh token and you didn't save the new one. Strava, WHOOP, Oura, and Garmin all hand back a *new* refresh token in the refresh response and invalidate the old one immediately — so if you keep sending the original, the first call succeeds and the next one fails.

This page ranks the causes from most to least common and shows the correct refresh flow, including the one line most broken integrations are missing: persist the returned `refresh_token` every single time, overwriting the old value.

## Most likely causes, ranked

1. **You're not persisting the rotated refresh token** (by far the most common). The provider returned a new `refresh_token` in the last refresh response; you ignored it and kept the original. Symptom: refresh succeeds once, then `400 invalid_grant` on the next attempt.
2. **The refresh token is expired or revoked.** The user disconnected your app, changed their password, or the token simply aged out. No amount of retrying helps — the user must re-authorize.
3. **You never got a real refresh token because a scope was missing.** WHOOP, for example, only issues a refresh token when you request the `offline` scope. Without the right scope you get an access token but nothing to refresh with.
4. **Wrong client credentials.** You're sending a `client_id` / `client_secret` that doesn't match the app that minted the token, or using the wrong client-auth method. This surfaces as `invalid_client` (400 or 401), not `invalid_grant`.
5. **Clock skew.** Your server clock is far enough off that freshly issued tokens look already-expired or not-yet-valid. Keep servers NTP-synced.
6. **You're confusing the single-use auth code with the refresh token.** The authorization code from the redirect is single-use and very short-lived. Reusing it (or a double POST) throws `invalid_grant` at initial exchange — that's a code problem, not a refresh problem.

If you're getting a `401` on your *API calls* (not the token endpoint), that's a different problem — see [Fixing fitness API 401 Unauthorized](/fix/fitness-api-401-unauthorized).

## How to fix it

### 1. Confirm which provider rotates (most do)

Strava's own docs say it plainly: *"the refresh token may or may not be the same refresh token used to make the request. Applications should persist the refresh token contained in the response and always use the most recent refresh token."* Once a new one is issued, the old one dies immediately.

As of 2026 (verify against live docs), the rotation picture looks like this:

| Provider | Rotates refresh token? | Note |
|---|---|---|
| Strava | Yes | May return the same or a new token — always take the returned one |
| WHOOP | Yes | Requires `offline` scope to get a refresh token at all |
| Oura | Yes | Single-use rotating refresh token |
| Garmin | Yes | New refresh token on every access-token refresh |
| Fitbit | Yes | Old refresh token invalidated after each successful refresh |

The safe rule that works for all of them: **treat every refresh token as single-use and always save the one you just received.**

### 2. Run the refresh call and read the response body

Reproduce the refresh directly so you can see exactly what comes back. This is Strava; the shape is the same for the others (swap the token endpoint).

```bash
curl -s -X POST https://www.strava.com/oauth/token \
  -d client_id=$CID -d client_secret=$SECRET \
  -d grant_type=refresh_token \
  -d refresh_token=$STORED_REFRESH_TOKEN
```

A successful response contains a fresh access token AND a refresh token that may differ from the one you sent:

```json
{
  "access_token": "...",
  "expires_at": 1720540800,
  "expires_in": 21600,
  "refresh_token": "NEW_VALUE_MAYBE_DIFFERENT"
}
```

That last field is the whole ballgame. Persist it, overwriting the old stored value.

### 3. Persist the returned token every time — atomically

The fix is one discipline: after every refresh, write back BOTH the new access token and the returned refresh token, even if the refresh token looks unchanged.

```python
# Correct pattern: overwrite BOTH tokens after every refresh.
resp = post_token_refresh(stored_refresh_token)   # may 400 invalid_grant if stale
store.save(
    access_token  = resp["access_token"],
    refresh_token = resp["refresh_token"],  # ALWAYS take the returned one
    expires_at    = resp.get("expires_at") or now() + resp["expires_in"],
)
```

Write both fields in a single atomic update. If your code saves the access token but the refresh-token write is skipped, conditional, or lost to a crash, you've recreated the original bug.

### 4. Serialize refreshes per user to avoid a rotation race

Two concurrent refresh calls for the same user will fight: the first rotates the token, and the second then sends the now-invalid one and gets `invalid_grant`. Wrap refreshes in a per-user lock (single-flight) so only one runs at a time. This is a common cause of intermittent `invalid_grant` in high-traffic backends where the token was actually saved correctly.

### 5. Refresh proactively, not reactively

Don't wait for a `401` to trigger a refresh — refresh on a buffer, for example when fewer than 5 minutes of access-token life remain. This avoids a burst of in-flight failures and reduces the window where a race can happen. (Note: Strava only mints a *new* access token when the current one has roughly an hour or less left — verify.)

### 6. Check scope and credentials if you never had a refresh token

If the token response never contained a `refresh_token` in the first place, you're missing the scope that unlocks offline access (WHOOP needs `offline`; others require you to request refresh-capable consent). Re-run the authorization flow with the correct scope. If instead you see `invalid_client`, your `client_id` / `client_secret` or client-auth method is wrong — fix the credentials before touching anything else.

## Still stuck? Diagnostic checklist

- Log `error_description` from the token endpoint — `invalid_grant` is overloaded and the provider puts the real reason there.
- Diff the refresh token you're sending against the one from the *last* successful response. If they differ, you're not persisting the rotation.
- Confirm the token write actually committed (no swallowed exception, no rolled-back transaction).
- Check for concurrent refreshes on the same user — add a per-user lock if you find any.
- Verify server time is NTP-synced.
- If refresh returns `invalid_grant` and the token was definitely current, the grant is dead (revoked or expired) — route the user back through authorization. Retrying won't help.

For the full happy-path OAuth setup on the provider most people hit this with, see the [Strava API integration guide](/integrate/strava-api). The same rotation discipline applies to [WHOOP](/integrate/whoop-api) and [Oura](/integrate/oura-api).

## FAQ

### Why does my refresh token work the first time but fail after that?

Because the provider rotated it. Strava, WHOOP, Oura, Garmin, and Fitbit return a new refresh token in the refresh response and invalidate the old one. If you keep sending the original, the first refresh succeeds and the next returns 400 invalid_grant. Save the returned token every time.

[Permalink](https://aifitnessapi.com/fix/refresh-token-not-working#faq-1)

### What does invalid_grant mean on the token endpoint?

It is an overloaded OAuth 2.0 error meaning the grant you sent is invalid, expired, revoked, or does not match. For refreshes it usually means a stale or already-rotated refresh token. Always log error_description, since providers put the specific reason there.

[Permalink](https://aifitnessapi.com/fix/refresh-token-not-working#faq-2)

### Do I need to save the refresh token even when it looks unchanged?

Yes. Providers that usually return the same token can still rotate without warning. The safe rule is to always overwrite your stored refresh token with the exact value from the latest response.

[Permalink](https://aifitnessapi.com/fix/refresh-token-not-working#faq-3)

### My refresh keeps failing but I am saving the token. What else could it be?

Check for concurrent refreshes on the same user, which race and invalidate each other, and verify your server clock is NTP-synced. If the token was genuinely current and still fails, the grant is likely revoked or expired and the user must re-authorize.

[Permalink](https://aifitnessapi.com/fix/refresh-token-not-working#faq-4)

### Why did I never receive a refresh token at all?

You probably did not request the scope that grants offline access. WHOOP requires the offline scope, and other providers need refresh-capable consent. Re-run the authorization flow with the correct scope.

[Permalink](https://aifitnessapi.com/fix/refresh-token-not-working#faq-5)
