---
title: How to Check HealthKit Read Permission Status in Capacitor
description: HealthKit never tells your app whether a read permission was granted. Learn what checkPermissions() reports on iOS, why, and how to build around it.
date:
  created: 2026-10-20
  updated: 2026-10-20
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor Health: sdks/capacitor/health.md
faq: true
---

# How to Check HealthKit Read Permission Status in Capacitor

You cannot check the HealthKit read permission status in a Capacitor app, because iOS does not expose it to any app. HealthKit tells an app whether it may write a data type, but a denied read permission looks exactly like an empty health store, by design. The [Capacitor Health plugin](../../sdks/capacitor/health.md) passes that rule through: on iOS, `checkPermissions(...)` returns `prompt` before the first request and `unknown` afterwards, never `granted` or `denied`. This guide explains what Apple hides and why, what the plugin reports on each platform, and the query-then-interpret pattern that replaces the permission check your code probably wants to write. The plugin is part of [Capawesome Insiders](../../insiders/index.md).

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Try Capawesome Cloud free for 14 days — iOS builds without a Mac" src="https://capawesome.io/assets/banners/cloud-free-trial-capacitor-apps.png" />
  </a>
</div>

**Key takeaways:**

- HealthKit hides whether a read permission was granted or denied; Apple's documentation says a denied read "simply appears as if there is no data of the requested type".
- On iOS, the Capacitor Health plugin reports read permissions as `prompt` before the first request and `unknown` afterwards; write permissions come back as `granted`, `denied` or `prompt`.
- Code that waits for `read === 'granted'` never runs on iOS, and code that treats `unknown` as denied hides data the user did share.
- Request the permission once, run the query, and treat an empty result as "no data or no access"; on Android the same read rejects with `PERMISSION_DENIED` instead.
- Since iOS 27, users can limit an app to a recent window of data, and that limited state is the only read authorization an app can positively identify.

## What Apple hides

HealthKit does not tell an app whether the user granted or denied read access to a data type. Apple states the rule in the documentation of [`authorizationStatus(for:)`](https://developer.apple.com/documentation/healthkit/hkhealthstore/authorizationstatus%28for:%29){:target="_blank"}: "your app cannot determine whether or not a user has granted permission to read data. If you are not given permission, it simply appears as if there is no data of the requested type in the HealthKit store." The method itself only answers whether the app may save, or in Apple's words share, a data type.

The reason is inference. If an app could learn that the user refused to share blood glucose readings, it could conclude the user is likely diabetic without ever reading a single value. The permission decision would leak the fact it protects. Hiding the decision closes that gap, and HealthKit applies it to every data type, from step counts to clinical records.

A denied read is not a fully empty store, though. Apple's authorization guide adds that after a denial, "attempts to read data from HealthKit return only samples that your app successfully saved to the HealthKit store". An app with write access to weight that also wrote a weight record will read that one record back, and nothing from the Health app, the Apple Watch or other apps. Keep that in mind when you later interpret a result that contains only your own data.

## What the plugin reports

The Capacitor Health plugin maps these rules to the four states of its `HealthPermissionState` type: `granted`, `denied`, `prompt` and `unknown`. Which state you get depends on the platform, on whether you ask about reading or writing, and on whether you call [`checkPermissions(...)`](../../sdks/capacitor/health.md#checkpermissions) or [`requestPermissions(...)`](../../sdks/capacitor/health.md#requestpermissions). The table lists every combination, as implemented in the plugin's native code (version 0.1.1, checked on 2026-10-19):

| Platform and method | Read permission | Write permission |
| --- | --- | --- |
| iOS `checkPermissions(...)` | `prompt` before the first request, `unknown` afterwards | `granted`, `denied` or `prompt` |
| iOS `requestPermissions(...)` | Always `unknown` | `granted`, `denied` or `prompt` |
| Android `checkPermissions(...)` | `granted` or `prompt` | `granted` or `prompt` |
| Android `requestPermissions(...)` | `granted` or `denied` | `granted` or `denied` |

On iOS, the plugin derives the read state from HealthKit's `getRequestStatusForAuthorization(toShare:read:)`. That method does not reveal a decision either; it only reports whether a permission sheet would appear if the app asked now. Apple defines the answer `shouldRequest` as "the application has not yet requested authorization for all the specified data types", which the plugin reports as `prompt`. Any other answer means the sheet was already shown, so the user has decided, and the plugin reports `unknown` because it cannot learn what they decided. Write states come from `authorizationStatus(for:)`, where HealthKit does distinguish `notDetermined`, `sharingDenied` and `sharingAuthorized`.

Android reveals more. Health Connect lists the permissions it currently grants, so `checkPermissions(...)` answers `granted` or `prompt` for reads and writes alike. It does not record a refusal, which is why a permission that was never requested and one the user denied both show up as `prompt`. The plugin reports `denied` only in the result of `requestPermissions(...)`, right after the user rejects the request. If the user denies twice, Health Connect ignores further requests, and only [`openSettings()`](../../sdks/capacitor/health.md#opensettings) leads to a screen where they can still grant access.

## The wrong fix

The natural permission gate fails on iOS. Most apps that integrate a permission API write a check like this before loading data:

```typescript
import { DataType, Health } from '@capawesome-team/capacitor-health';

const { permissions } = await Health.checkPermissions({ read: [DataType.HeartRate] });
const heartRate = permissions.find((permission) => permission.dataType === DataType.HeartRate);
if (heartRate?.read === 'granted') {
  await loadHeartRate();
}
```

On iOS, `heartRate.read` is `prompt` or `unknown`, never `granted`, so `loadHeartRate()` never runs. The app shows its "grant access" screen forever to a user who already tapped Allow. The same gate works on Android, which is why the bug tends to ship: it passes on the platform where the developer tested first.

The second wrong fix is treating `unknown` as `denied`. That hides the heart rate chart from every iOS user after the first request, including the ones who shared everything. There is no state to branch on, so the read itself has to become the check.

## Query, then interpret

The pattern that works on both platforms is to request the permission once, run the query, and interpret the result instead of a permission state. On iOS, a denied read returns no samples (or only your own), and on Android it rejects with the error code `PERMISSION_DENIED`, because Health Connect throws a `SecurityException` that the plugin translates. One function covers both:

```typescript
import { DataType, Health, HealthRecord } from '@capawesome-team/capacitor-health';

type HeartRateResult =
  | { state: 'data'; records: HealthRecord[] }
  | { state: 'empty' }
  | { state: 'denied' };

const loadHeartRate = async (): Promise<HeartRateResult> => {
  await Health.requestPermissions({ read: [DataType.HeartRate] });
  try {
    const { records } = await Health.readRecords({
      dataType: DataType.HeartRate,
      startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
      endDate: new Date().toISOString(),
    });
    return records.length > 0 ? { state: 'data', records } : { state: 'empty' };
  } catch (error) {
    if ((error as { code?: string }).code === 'PERMISSION_DENIED') {
      return { state: 'denied' };
    }
    throw error;
  }
};
```

Calling [`requestPermissions(...)`](../../sdks/capacitor/health.md#requestpermissions) every time is safe. Apple's documentation for `requestAuthorization(toShare:read:)` states that when "the user has already chosen to grant or prohibit access to all of the types specified, HealthKit calls the completion without prompting the user", so the sheet appears once per data type and later calls return immediately. On Android, the call is a no-op for granted permissions as well, and a second rejection triggers the two-denials rule, so request there only after your own onboarding screen.

The `empty` state needs its own empty state in the UI. On iOS, it means "no heart rate samples in the last 7 days, or no access", and your app cannot tell which. Say both: "No heart rate data found. If you expected data here, check the Health app under Profile, Privacy, Apps." That sentence replaces the permission check, and it also covers the user who granted access to a store that has no data yet. The plugin's `openSettings()` opens the Health app on iOS, which is as close as Apple lets you get, since there is no deep link to your app's privacy page.

With [`aggregate(...)`](../../sdks/capacitor/health.md#aggregate) the empty signal is `null` rather than an empty array. A bucket with no data carries `value: null` for each operation, so a daily step sum of `null` means the same "no data or no access" as an empty record list. [Reading Step Counts on iOS and Android with Capacitor](./how-to-read-step-counts-in-a-capacitor-app.md) builds the daily and weekly buckets that this check sits in front of.

## When to call checkPermissions

`checkPermissions(...)` still has one job on iOS: `prompt` tells you the permission sheet has never been shown for that type. That is the right moment for an onboarding screen that explains why the app wants heart rate data before iOS asks, because the system sheet appears once and your explanation has to come before it:

```typescript
import { DataType, Health } from '@capawesome-team/capacitor-health';

const needsOnboarding = async () => {
  const { permissions } = await Health.checkPermissions({ read: [DataType.HeartRate] });
  return permissions.some((permission) => permission.read === 'prompt');
};
```

Once the state flips to `unknown`, skip the onboarding and go straight to the query from the previous section. On Android, the same function returns `true` both before the first request and after a denial, and the state stays `prompt` after the second denial, when Health Connect already ignores further requests. Count your own requests: show the onboarding before the first one, and after two denials replace the request button with one that calls `openSettings()`.

Do not use `prompt` to decide whether to request again later. After the sheet was shown, `requestPermissions(...)` returns without a sheet on iOS, and a user who wants to change the decision does so in the Health app. Apple's documentation notes that the app appears in the Health app's Sources tab "even if the user didn't allow permission to read or share data", so the setting exists for every user who saw the sheet.

## Write permissions differ

Write permissions are checkable on iOS, because `authorizationStatus(for:)` exists for exactly that purpose. Apple's authorization guide asks apps to call it before saving and describes the two failure modes: a save without a prior request fails with `errorAuthorizationNotDetermined`, and a save after a denial fails with `errorAuthorizationDenied`. The plugin maps these to `prompt` and `denied` in `checkPermissions(...)`, and a [`writeRecord(...)`](../../sdks/capacitor/health.md#writerecord) call that hits either error rejects with the code `PERMISSION_DENIED`.

That makes the classic gate correct for writes:

```typescript
import { DataType, Health } from '@capawesome-team/capacitor-health';

const saveWeight = async (kilograms: number) => {
  const { permissions } = await Health.checkPermissions({ write: [DataType.Weight] });
  const weight = permissions.find((permission) => permission.dataType === DataType.Weight);
  if (weight?.write === 'denied') {
    await Health.openSettings();
    return;
  }
  if (weight?.write === 'prompt') {
    const { permissions: requested } = await Health.requestPermissions({ write: [DataType.Weight] });
    const granted = requested.some((permission) => permission.dataType === DataType.Weight && permission.write === 'granted');
    if (!granted) {
      return;
    }
  }
  await Health.writeRecord({
    dataType: DataType.Weight,
    startDate: new Date().toISOString(),
    value: kilograms,
  });
};
```

A type can have a `denied` write state and an unknowable read state at the same time, since the permission sheet has separate toggles for reading and sharing each type. Request both in one `requestPermissions(...)` call when your app does both, so the user sees a single sheet.

## Limited history on iOS

Since iOS 27, the permission sheet asks a second question after the data types: whether the app may read the full history or only a recent window. Apple's [authorization guide](https://developer.apple.com/documentation/healthkit/authorizing-access-to-health-data){:target="_blank"} (checked on 2026-10-19) describes this time-bound authorization and the new `getEarliestAuthorizedSampleDate(for:)` method that returns the earliest readable date per type. The guide also states that "limited authorization is the only authorization state your app can positively identify": full access and denied access both return no entry.

For the pattern in this post, the consequence is a third reading of an empty or short result. Samples before the authorized date are not absent, they are hidden, so a 90-day trend may only cover 14 days. The Capacitor Health plugin 0.1.1 does not wrap the new method yet, so you cannot read that date from JavaScript today; a history that ends abruptly on iOS 27 is the sign to look for, and the "check the Health app" hint covers it. Full access remains the behavior on iOS 26 and earlier.

## FAQ

### Why does checkPermissions() return unknown instead of denied for HealthKit reads?

Because HealthKit does not tell the plugin whether the user denied the read. On iOS, the plugin reports `prompt` while the permission sheet has not been shown for the type and `unknown` afterwards, for reads that were granted and reads that were denied alike. Reporting `denied` would be a guess, and reporting `granted` would be an invented value. Run the query and interpret the result instead.

### How do I know whether the user denied HealthKit read access?

You do not. Apple hides the decision so that a refusal cannot leak what the user is protecting. Your app sees no samples, or only the ones it wrote itself, and should show an empty state that mentions the Health app. Only a limited time window, available since iOS 27, is a read state an app can identify.

### Does Android have the same limitation?

Partly. Health Connect reports which permissions are granted, so `checkPermissions(...)` returns `granted` or `prompt` for reads, but it does not record a denial; `denied` only appears in the result of `requestPermissions(...)` right after the user rejects. A read without permission rejects with the error code `PERMISSION_DENIED`, where iOS returns an empty result.

### Is the Capacitor Health plugin free?

No. The plugin is part of the [Capawesome Insiders](../../insiders/index.md) subscription and is installed from the Capawesome npm registry with the license key that comes with it.

## Try Capawesome Cloud

The HealthKit capability and the `NSHealthShareUsageDescription` key live in the native iOS project, so every change to them needs a new build on a Mac. Capawesome Cloud builds and signs the iOS and Android binaries for you, and pushes the JavaScript side of a fix, such as a better empty state, as a live update without a store review.

[Try Capawesome Cloud Free](https://capawesome.io){ .md-button .md-button--primary }

## Conclusion

Branch on write permissions, never on read permissions. On iOS, call `checkPermissions(...)` only to decide whether to show your onboarding before the first request, then request once, query, and give an empty result an empty state that names the Health app. On Android, add a catch for `PERMISSION_DENIED` and a settings button after a denial, and the same function serves both platforms. [Announcing the Capacitor Health Plugin](./announcing-the-capacitor-health-plugin.md) covers the rest of the API, including the aggregation that most health features start with.

If you have any questions, join us on the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}. To stay updated on the latest news, subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"}.
