What Are OAuth Scopes (and How Health APIs Grant Them)?
Last verified August 12, 2026 · 7 min read
Covered here:FitbitStravaHealthKitWHOOPGarminHealth Connect
The set you requested is not the set you received#
The single most consequential thing about health-API scopes is that the user picks them individually, and most builders write code as though the request were the grant.
Fitbit is explicit about it: the platform does not let you force a user to grant every scope, because consent is per data collection. Someone can approve activity and decline heartrate on the same screen. Fitbit's documented collections include activity, heartrate, sleep and profile, with the wider set covering location, nutrition, oxygen_saturation, respiratory_rate, settings, social, temperature and weight — verify the current list before you build a picker around it.
Strava behaves the same way and says so in the redirect: the athlete can deselect scopes on the consent screen, so a granted set narrower than the requested set is normal rather than an error. Oura documents eight scopes — email, personal, daily, heartrate, workout, tag, session and spo2 — and the user can toggle individual ones at consent too.
The design consequence is not subtle. If a missing collection throws your onboarding into a generic failure state, you have converted a partial grant into a lost user. Degrade per feature instead: hide the sleep card, keep the activity card, and tell the person exactly which toggle to flip if they want the rest.
Read the grant, don't infer it#
Every provider hands the granted scope back; the annoyance is that they hand it back in different places and different formats.
| Provider | Where the granted scope appears | Format gotcha |
|---|---|---|
| Fitbit | A scope field in the token-exchange response, next to access_token, refresh_token, expires_in and user_id | Space-delimited |
| Strava | The scope query parameter on the redirect back to your callback, and in the exchange | Comma-delimited, unlike most OAuth providers |
| WHOOP | The scopes you requested at authorize time, including the non-data one covered below | Space-delimited in the authorize URL |
A parser written against one delimiter and reused for the next provider is a genuinely common bug, and it fails in the worst possible direction: your code decides a scope is absent, disables a working feature, and nobody files a ticket because the app looks intentional. Store the granted set per connection at exchange time, compare it against what each feature needs, and re-check it after any re-authorization. The step-by-step version of that exchange lives in the Fitbit integration guide.
Scope, permission, entitlement: three different words that fail three different ways#
"Permission" gets used for all of these in conversation, which is how teams end up debugging the wrong layer. They are separate mechanisms with separate grantors.
| Layer | Who grants it | Where it bites | Typical failure |
|---|---|---|---|
| OAuth scope | The end user, on the provider's consent screen | The provider's API, per request | 403 with insufficient_scope on an otherwise valid token |
| OS permission | The end user, in a system sheet, per data type | The device | HealthKit tells you nothing about a denied read — it looks identical to an empty store. Health Connect needs a manifest declaration, a runtime grant, and separate permissions again for background reads and for history |
| Platform entitlement / capability | Apple or Google, to your signed build | Build and runtime, before any user is involved | Without the HealthKit capability, HKHealthStore calls fail outright; without the background-delivery entitlement, enableBackgroundDelivery fails with errorAuthorizationDenied |
| Partner or program approval | The vendor, to your organisation | Before you hold credentials at all | Garmin's Connect Developer Program is partner-approval-only, and which scopes even exist for you depends on which programs your partnership covers. Fitbit gates minute-level intraday data by app type and separate approval |
| Store declaration | You declare, a reviewer accepts | Release | A Play Console health-data declaration must list every type you read or write, and asking for types you cannot justify can get the release rejected |
Read down that table and the diagnostic value is obvious: a 403 is a scope problem, an empty HealthKit read may be nothing at all, errorAuthorizationDenied is a signing problem, and "we never got credentials" is a business-development problem. None of them is fixed by the others' fix.
There is a fifth layer that is not technical. An OS permission grant is a device-level access control, not automatically a lawful basis for what you then do with the data, and an OAuth scope is a clean, auditable consent to access rather than consent for downstream purposes like marketing. Consent for health data covers where the legal instrument has to sit separately from the technical one.
The failure signature worth memorising: 401 is not 403#
This distinction wastes more afternoons than any other part of the topic.
401 Unauthorizedmeans the credential itself was rejected — missing, malformed, expired, revoked, or the wrong type. A spec-compliant server maps it to the Bearer errorinvalid_token.403 Forbiddenmeans the credential is authentic and active but is not permitted to do this. That maps toinsufficient_scope.
The load-bearing part: refreshing will never fix a 403, because a refresh mints a new token carrying the same scopes the user already granted. If you retry-with-refresh on a scope error you will loop forever against a provider that is answering correctly. The only fix is to send the user back through authorization asking for the missing scope. Read the WWW-Authenticate response header before deciding which situation you are in; the ranked causes on the credential side are in fitness API 401 Unauthorized.
One honest caveat: providers overload 403. On an Oura usercollection endpoint it usually means the token is missing a required scope — but a lapsed Oura membership produces the same status. So a 403 narrows the problem to "authentic token, not allowed", not specifically to "you forgot a scope".
Some scopes are not about data at all#
Two examples from the corpus are worth knowing because both present as something other than a scope bug.
WHOOP's offline scope controls whether you receive a refresh token, not what you can read. Skip it and the authorization flow succeeds, the first API calls succeed, and then background sync dies silently when the access token expires — with no refresh token to renew it and no obvious cause in your logs. It shows up in triage as a token problem, which is why it sits in refresh token not working rather than in a scope guide.
Strava's activity:read_all is usually described as the private-activity scope, and it is, but it also governs your push stream: plain activity:read only ever sees activities the athlete shared beyond "Only You", and the same restriction applies to the webhook events you receive. So a scope decision made during onboarding quietly determines what arrives on your callback endpoint months later.
Choosing a scope set you won't regret#
Least privilege is the standing rule, and it has three separate payoffs rather than one. A shorter consent screen converts better. A narrower token has a smaller blast radius if it leaks. And a request you can justify is a request that survives an app-store health-data review.
Practically: map each scope to the specific feature that needs it, and if you cannot name the feature, drop the scope. Design every feature to degrade on its own when its collection is missing. Keep your Android manifest declarations, runtime permission set and Play Console declaration in sync with each other, because they are three lists of the same thing that drift independently. And re-read the granted scope after every re-authorization, not just the first — the user who declined heart rate in January may grant it in March, and nothing will tell you unless you look.
For the surrounding flow — the authorization-code exchange, access versus refresh tokens, PKCE, and the on-device exception where none of this applies — see what OAuth is for health data.
Frequently asked questions
- Can a user grant only some of the scopes my app requested?
- Yes, and on health APIs you should assume they will. Fitbit documents that you cannot force a user to grant every scope because consent is per data collection, so someone can approve activity and decline heart rate. Strava athletes can deselect scopes on the consent screen, and Oura users can toggle individual scopes from its eight documented ones. Design each feature to degrade on its own when its collection is missing, rather than failing onboarding as a whole.
- How do I find out which scopes were actually granted?
- Read them back from the provider rather than assuming. Fitbit returns a scope field in the token-exchange response alongside the access token, refresh token, expiry and user id. Strava returns the granted scope as a query parameter on the redirect to your callback as well as in the exchange. Watch the delimiter: Fitbit uses spaces and Strava uses commas, unlike most OAuth providers, so a parser reused across providers will silently decide a granted scope is absent.
- Is an Android or iOS health permission the same thing as an OAuth scope?
- No. An OAuth scope is granted by the user at a cloud provider's consent screen and enforced by that provider's API. An OS permission is a device-level access control granted in a system sheet per data type, and it behaves differently: HealthKit never tells you a read was denied, and Health Connect needs a manifest declaration, a runtime grant, and separate permissions again for background reads and for history. They are also different instruments legally, since a device permission is not automatically a lawful basis for what you do with the data afterwards.
- Which error code means a scope is missing?
- A 403 Forbidden, mapping to the Bearer error insufficient_scope, means the token is authentic but not permitted to do that. A 401 Unauthorized, mapping to invalid_token, means the credential itself was rejected as missing, malformed, expired or revoked. Refreshing fixes many 401s and never fixes a 403, because the new token carries the same scopes. One caveat: providers overload 403, so on an Oura usercollection endpoint it can mean a missing scope or a lapsed membership.
- Why did I get no refresh token even though the login worked?
- Usually because you omitted the scope that unlocks offline access. WHOOP documents offline as a required scope to receive a refresh token at all, and it controls capability rather than data. Without it the authorization succeeds, your first API calls succeed, and background sync then dies silently when the access token expires with nothing to renew it. Re-run the authorization flow with the correct scope; no amount of retrying the token endpoint helps.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Independent comparison, last reviewed August 12, 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 concepts · by AIFitnessAPI