Why Is My Fitness API Refresh Token Not Working?
Updated July 9, 2026
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
- You're not persisting the rotated refresh token (by far the most common). The provider returned a new
refresh_tokenin the last refresh response; you ignored it and kept the original. Symptom: refresh succeeds once, then400 invalid_granton the next attempt. - 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.
- You never got a real refresh token because a scope was missing. WHOOP, for example, only issues a refresh token when you request the
offlinescope. Without the right scope you get an access token but nothing to refresh with. - Wrong client credentials. You're sending a
client_id/client_secretthat doesn't match the app that minted the token, or using the wrong client-auth method. This surfaces asinvalid_client(400 or 401), notinvalid_grant. - Clock skew. Your server clock is far enough off that freshly issued tokens look already-expired or not-yet-valid. Keep servers NTP-synced.
- 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_grantat 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:
| 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).
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_descriptionfrom the token endpoint —invalid_grantis 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_grantand 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