Skip to content
AFAIFitnessAPI
Troubleshooting

Why Is My Fitness API Refresh Token Not Working?

Updated July 9, 2026

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.

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.

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:

ProviderRotates refresh token?Note
StravaYesMay return the same or a new token — always take the returned one
WHOOPYesRequires offline scope to get a refresh token at all
OuraYesSingle-use rotating refresh token
GarminYesNew refresh token on every access-token refresh
FitbitYesOld 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).

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:

{
  "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.

# 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. The same rotation discipline applies to WHOOP and Oura.

Frequently asked questions

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.
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.
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.
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.
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.

Keep reading

Independent comparison, last reviewed July 9, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All troubleshooting · by AIFitnessAPI