---
title: "What Are OAuth Scopes (and How Health APIs Grant Them)?"
canonical: "https://aifitnessapi.com/learn/what-are-oauth-scopes"
cluster: "Concepts"
primary_query: "what are oauth scopes"
last_reviewed: "2026-08-12"
description: "An OAuth scope caps what a token can read. Why the granted set differs from what you asked for, how scope differs from an OS permission, and the 403 tell."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"What Are OAuth Scopes (and How Health APIs Grant Them)?\", AIFitnessAPI, https://aifitnessapi.com/learn/what-are-oauth-scopes"
---

# What Are OAuth Scopes (and How Health APIs Grant Them)?

> An OAuth scope is a named string attached to an access token that caps what that token may read or write — Fitbit's activity or heartrate, Strava's activity:read_all, WHOOP's read:sleep. In health APIs the user grants them per collection rather than all-or-nothing, so the set you requested and the set you received routinely differ and you have to read the granted scope back out of the response. A scope is also only one layer of permission: an OS permission like HealthKit or Health Connect is a device access control, and a platform entitlement like Apple's HealthKit capability or Garmin's partner-level program grant is a build- or business-level gate, and each fails differently. The signature worth memorising is that a missing scope is a 403 with insufficient_scope rather than a 401, and refreshing never fixes it, because a refresh mints a token carrying the same scopes the user already granted.

- Canonical: https://aifitnessapi.com/learn/what-are-oauth-scopes
- Last reviewed: 2026-08-12
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "What Are OAuth Scopes (and How Health APIs Grant Them)?", AIFitnessAPI, https://aifitnessapi.com/learn/what-are-oauth-scopes

---

## 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](/integrate/fitbit-api).

## 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](/compliance/health-data-user-consent) 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 Unauthorized`** means the credential itself was rejected — missing, malformed, expired, revoked, or the wrong type. A spec-compliant server maps it to the Bearer error `invalid_token`.
- **`403 Forbidden`** means the credential is authentic and active but is not permitted to do this. That maps to `insufficient_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](/fix/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](/fix/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](/learn/what-is-oauth-for-health-data).

## FAQ

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

[Permalink](https://aifitnessapi.com/learn/what-are-oauth-scopes#faq-1)

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

[Permalink](https://aifitnessapi.com/learn/what-are-oauth-scopes#faq-2)

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

[Permalink](https://aifitnessapi.com/learn/what-are-oauth-scopes#faq-3)

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

[Permalink](https://aifitnessapi.com/learn/what-are-oauth-scopes#faq-4)

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

[Permalink](https://aifitnessapi.com/learn/what-are-oauth-scopes#faq-5)
