# Check age natively

CalmLens marks content `age-restricted` but doesn't know your viewers, so your app decides who is old enough to see it. iOS, Android and browsers can answer that themselves: the viewer agrees to share, and your app learns only whether they're over your age gate, never a birth date. Pick your platform, ask when the viewer opens age-restricted content, and serve it only to viewers who pass.

## What each platform provides

| Platform                     | API                                                                                                    | The viewer shares                                                 | How sure it is                                                                            |
| ---------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| iOS 26 and later             | Apple's [Declared Age Range](https://developer.apple.com/documentation/declaredagerange)               | Which side of your age gates they're on, from their Apple Account | Self-declared, set by a guardian, or confirmed with a credit card or government ID        |
| Android                      | Google's [Play Age Signals](https://developer.android.com/google/play/age-signals/use-age-signals-api) | An age range from their Google account                            | From self-declared (`TIER_A`) to checked with an ID and selfie or a digital ID (`TIER_D`) |
| Web: Chrome 141+, Safari 26+ | The W3C [Digital Credentials API](https://www.w3.org/TR/digital-credentials/)                          | "Over 18" from a government ID in their phone's wallet            | Signed by the ID's issuer                                                                 |

Some US states' app store laws require apps to use the iOS and Android signals for minors, so add them even if you also check age another way.

## Ask for the viewer's age

Ask only when the viewer tries to open age-restricted content, and keep the answer rather than asking for every item.

### Expo or React Native

[`expo-age-range`](https://docs.expo.dev/versions/latest/sdk/age-range/) wraps both platforms' age signals. [Calmsnap](/docs/examples#calmsnap-mobile-photo-uploads), the Expo example, uses it to hide an age-restricted photo until the viewer shares an age range of 18 or older.

[View the source code](https://github.com/CalmLens/sdks/tree/main/examples/mobile-uploads-expo)

```bash
npx expo install expo-age-range
```

Add the Declared Age Range entitlement for iOS to `app.json`:

```json
{
  "expo": {
    "ios": {
      "entitlements": {
        "com.apple.developer.declared-age-range": true
      }
    }
  }
}
```

On Android, Play shows its consent screen first; on iOS, the consent sheet is part of the age range request. Below iOS 26 and on the web, `expo-age-range` answers as if the viewer were an adult, so only ask where the OS can really answer:

```ts
import * as AgeRange from "expo-age-range";
import { Platform } from "react-native";

const ADULT_AGE = 18;

export type AgeCheck =
  | { result: "adult"; confirmed: boolean }
  | { result: "minor" | "declined" | "verification-required" | "unavailable" };

const canAskTheOs = () =>
  Platform.OS === "android" ||
  (Platform.OS === "ios" && Number.parseInt(String(Platform.Version), 10) >= 26);

export const requestAgeCheck = async (): Promise<AgeCheck> => {
  if (!canAskTheOs()) return { result: "unavailable" };
  try {
    // Android only: Play's consent screen. Resolves null on iOS.
    const access = await AgeRange.requestAgeSignalsAccessAsync();
    if (access === "NOT_SHARED") return { result: "declined" };
    if (access === "VERIFICATION_REQUIRED") return { result: "verification-required" };

    const range = await AgeRange.requestAgeRangeAsync({ threshold1: ADULT_AGE });
    if (range.lowerBound === null && range.upperBound === null) return { result: "unavailable" };
    if ((range.lowerBound ?? 0) < ADULT_AGE) return { result: "minor" };
    return {
      result: "adult",
      confirmed:
        range.ageRangeDeclaration === "confirmed" ||
        range.ageRangeSource === "TIER_C" ||
        range.ageRangeSource === "TIER_D",
    };
  } catch (error) {
    const code = (error as { code?: string }).code;
    if (code === "ERR_AGE_RANGE_USER_DECLINED" || code === "ERR_AGE_RANGE_TASK_CANCELLED") {
      return { result: "declined" };
    }
    return { result: "unavailable" };
  }
};
```

| Result                  | What to do                                                                                                                  |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `adult`                 | Show the content. Where the law requires a verified age, also require `confirmed`                                           |
| `minor`                 | Keep the content hidden                                                                                                     |
| `declined`              | Keep it hidden and let the viewer ask again                                                                                 |
| `verification-required` | Google Play needs the viewer to verify their age in the Play Store first                                                    |
| `unavailable`           | The device can't answer: an older OS, no signed-in account or a simulator. Use a [fallback](#when-the-platform-cant-answer) |

Test on a real device signed in to an Apple or Google account. In a debug build on Android, `AgeRange.setFakeAgeSignals` reports the signals you choose instead of asking Play.

### iOS (Swift)

Enable the **Declared Age Range** capability on your app target, which adds the `com.apple.developer.declared-age-range` entitlement. Then request the age range with your gate. The system shows its own sheet, and the response is either the shared range or a refusal:

```swift
import DeclaredAgeRange
import SwiftUI

enum AgeCheck {
  case adult(confirmed: Bool)
  case minor, declined, unavailable
}

struct AgeRestrictedPhoto: View {
  @Environment(\.requestAgeRange) private var requestAgeRange
  @State private var ageCheck: AgeCheck?

  var body: some View {
    // Show the photo for .adult, and a "Share my age range" button otherwise.
    Button("Share my age range") { Task { ageCheck = await checkAge() } }
  }

  private func checkAge() async -> AgeCheck {
    do {
      guard case let .sharing(range) = try await requestAgeRange(ageGates: 18) else {
        return .declined
      }
      // A nil lower bound means the viewer is under the lowest gate.
      guard let lowerBound = range.lowerBound, lowerBound >= 18 else { return .minor }
      var confirmed = false
      if #available(iOS 26.2, *) {
        // Confirmed with a credit card or government ID rather than declared.
        confirmed = range.ageRangeDeclaration == .confirmed
      }
      return .adult(confirmed: confirmed)
    } catch {
      // For example AgeRangeService.Error.notAvailable when no Apple Account is signed in.
      return .unavailable
    }
  }
}
```

In UIKit, call `AgeRangeService.shared.requestAgeRange(ageGates: 18, in: viewController)` instead. The system can raise your gates to match local regulations, so read the bounds rather than assuming the range you asked for. Test on a real device signed in to an Apple Account; the simulator may not answer.

### Android (Kotlin)

Add the Play Age Signals library:

```kotlin
dependencies {
  implementation("com.google.android.play:age-signals:0.0.4")
}
```

`requestAgeSignalsAccess` shows Play's consent screen when the viewer's settings ask for one. Only once the status is `SHARED` does `checkAgeSignals` return a range:

```kotlin
import android.app.Activity
import com.google.android.play.agesignals.AgeSignalsAccessRequest
import com.google.android.play.agesignals.AgeSignalsManagerFactory
import com.google.android.play.agesignals.AgeSignalsRequest
import com.google.android.play.agesignals.model.AgeRangeSource
import com.google.android.play.agesignals.model.AgeSignalsStatus

sealed interface AgeCheck {
  data class Adult(val confirmed: Boolean) : AgeCheck
  data object Minor : AgeCheck
  data object Declined : AgeCheck
  data object VerificationRequired : AgeCheck
  data object Unavailable : AgeCheck
}

fun checkAge(activity: Activity, onResult: (AgeCheck) -> Unit) {
  val manager = AgeSignalsManagerFactory.create(activity.applicationContext)
  val access = AgeSignalsAccessRequest.builder().setActivity(activity).build()

  manager.requestAgeSignalsAccess(access)
    .addOnSuccessListener { accessResult ->
      when (accessResult.ageSignalsStatus()) {
        AgeSignalsStatus.SHARED -> Unit
        AgeSignalsStatus.VERIFICATION_REQUIRED -> return@addOnSuccessListener onResult(AgeCheck.VerificationRequired)
        else -> return@addOnSuccessListener onResult(AgeCheck.Declined)
      }
      manager.checkAgeSignals(AgeSignalsRequest.builder().build())
        .addOnSuccessListener { result ->
          val lower = result.ageLower()
          onResult(
            when {
              lower == null -> AgeCheck.Unavailable
              lower < 18 -> AgeCheck.Minor
              else -> AgeCheck.Adult(
                confirmed = result.ageRangeSource() == AgeRangeSource.TIER_C ||
                  result.ageRangeSource() == AgeRangeSource.TIER_D,
              )
            },
          )
        }
        .addOnFailureListener { onResult(AgeCheck.Unavailable) }
    }
    .addOnFailureListener { onResult(AgeCheck.Unavailable) }
}
```

`VERIFICATION_REQUIRED` means the viewer is in a region that requires a verified age and hasn't verified it yet: send them to the Play Store. Google Play reports age signals only where it provides them; elsewhere the bounds are empty. Test with `FakeAgeSignalsManager` from the library's `testing` package.

### Web

Browsers ask the viewer's phone wallet for a government ID, such as a mobile driver's licence, and the request can name a single field: `age_over_18`. The viewer sees what's requested and approves it on their phone, and your server receives an encrypted answer signed by the ID's issuer. Chrome 141 and later supports it on Android and, through a QR code scanned with a phone, on desktop; Safari 26 and later reads IDs from Apple Wallet.

Your server builds each request, because it holds the nonce and the key that decrypts the answer. The browser checks which protocols it supports, asks your server for a request, then hands the wallet's answer back:

```ts
const checkAgeWithWallet = async (): Promise<boolean> => {
  if (typeof DigitalCredential === "undefined") return false; // Use a fallback.
  // Safari speaks org-iso-mdoc; Chrome also speaks OpenID4VP.
  const protocols = ["openid4vp-v1-unsigned", "org-iso-mdoc"].filter((protocol) =>
    DigitalCredential.userAgentAllowsProtocol(protocol),
  );
  const { requests } = await fetch("/age-check/request", {
    method: "POST",
    body: JSON.stringify({ protocols }),
  }).then((response) => response.json());

  // Call this from a click: browsers only show the wallet after a user gesture.
  const credential = await navigator.credentials.get({ mediation: "required", digital: { requests } });
  const { adult } = await fetch("/age-check/verify", {
    method: "POST",
    body: JSON.stringify({ protocol: credential.protocol, data: credential.data }),
  }).then((response) => response.json());
  return adult;
};
```

For OpenID4VP, the request's `dcql_query` asks a mobile driver's licence for only the age field:

```json
{
  "credentials": [
    {
      "id": "mdl",
      "format": "mso_mdoc",
      "meta": { "doctype_value": "org.iso.18013.5.1.mDL" },
      "claims": [{ "path": ["org.iso.18013.5.1", "age_over_18"] }]
    }
  ]
}
```

To verify the answer, your server decrypts it, checks the issuer's signature and that the issuer is one you trust, and checks the nonce. Use an OpenID4VP or ISO 18013-5 verifier library or service rather than writing this yourself. Digital IDs are only available in the countries and US states that issue them, so keep a [fallback](#when-the-platform-cant-answer) for everyone else.

## When the platform can't answer

The viewer may be on an older OS, have no digital ID, or decline. Give them another way to prove their age:

- **Age assurance providers** run the check and send your server the result. The UK regulator Ofcom lists photo ID matching, facial age estimation, credit card checks, open banking, mobile network operator checks, email-based age estimation and digital identity services as capable of being [highly effective age assurance](https://www.ofcom.org.uk/online-safety/protecting-children/age-assurance) under the Online Safety Act.
- **Self-declaration**, a birth date field or an "I'm 18 or older" button, is enough for a content warning that adults dismiss. Ofcom doesn't accept it, or a debit card payment, where a highly effective check is required.

The platform signals can be self-declared too. Where the law asks for a verified age, accept only a confirmed iOS range, a `TIER_C` or `TIER_D` Android range, a wallet ID, or a provider's check.

## Record the result on your server

iOS and Android report to the app, so your server only learns what the app tells it. Save the outcome on the viewer's account, with the method and whether it was confirmed, so every device and your backend use the same answer. Where your server must be able to verify the check itself, use a wallet ID or a provider that sends the result to your backend.

Re-check when the answer can change: a minor's age range moves on with their birthday, and a viewer can stop sharing their range in their account settings.

## Serve age-restricted content to viewers who passed

Age-restricted assets aren't served on plain public URLs. For viewers who passed, build the CDN URL with the `allowAgeRestricted` opt-in, and show everyone else the placeholder. Pass the check's result as `canView`:

**React**

```tsx
import { getAssetUrl } from "@calmlens/sdk/app";

type SensitiveImageProps = {
  projectId: string;
  assetId: string;
  status: "allowed" | "limited" | "age-restricted";
  canView: boolean;
  alt: string;
  /** Width divided by height, which you store when the image is uploaded. */
  aspectRatio: number;
  onReveal: () => void;
};

const fill = { width: "100%", height: "100%", objectFit: "cover" } as const;

export function SensitiveImage({
  projectId,
  assetId,
  status,
  canView,
  alt,
  aspectRatio,
  onReveal,
}: SensitiveImageProps) {
  const restricted = status === "age-restricted";
  const asset = { projectId, assetIdentifier: assetId };
  return (
    <figure style={{ aspectRatio, margin: 0, position: "relative" }}>
      {!restricted || canView ? (
        <img
          src={getAssetUrl({ ...asset, allowAgeRestricted: restricted })}
          alt={alt}
          style={fill}
        />
      ) : (
        <>
          <img src={getAssetUrl({ ...asset, resource: "placeholder" })} alt="" style={fill} />
          <figcaption
            style={{ position: "absolute", inset: 0, display: "grid", placeContent: "center" }}
          >
            Sensitive content{" "}
            <button type="button" onClick={onReveal}>
              View
            </button>
          </figcaption>
        </>
      )}
    </figure>
  );
}
```

**Vue**

```vue
<script setup lang="ts">
import { getAssetUrl } from "@calmlens/sdk/app";
import { computed } from "vue";

const props = defineProps<{
  projectId: string;
  assetId: string;
  status: "allowed" | "limited" | "age-restricted";
  canView: boolean;
  alt: string;
  /** Width divided by height, which you store when the image is uploaded. */
  aspectRatio: number;
}>();
const emit = defineEmits<{ reveal: [] }>();

const restricted = computed(() => props.status === "age-restricted");
const asset = computed(() => ({ projectId: props.projectId, assetIdentifier: props.assetId }));
const contentUrl = computed(() => getAssetUrl({ ...asset.value, allowAgeRestricted: restricted.value }));
const placeholderUrl = computed(() => getAssetUrl({ ...asset.value, resource: "placeholder" }));
</script>

<template>
  <figure class="sensitive" :style="{ aspectRatio }">
    <img v-if="!restricted || canView" :src="contentUrl" :alt="alt" />
    <template v-else>
      <img :src="placeholderUrl" alt="" />
      <figcaption>Sensitive content <button type="button" @click="emit('reveal')">View</button></figcaption>
    </template>
  </figure>
</template>

<style scoped>
.sensitive { margin: 0; position: relative; }
img { width: 100%; height: 100%; object-fit: cover; }
figcaption { position: absolute; inset: 0; display: grid; place-content: center; }
</style>
```

**Svelte**

```svelte
<script lang="ts">
  import { getAssetUrl } from "@calmlens/sdk/app";

  type Props = {
    projectId: string;
    assetId: string;
    status: "allowed" | "limited" | "age-restricted";
    canView: boolean;
    alt: string;
    /** Width divided by height, which you store when the image is uploaded. */
    aspectRatio: number;
    onReveal: () => void;
  };
  let { projectId, assetId, status, canView, alt, aspectRatio, onReveal }: Props = $props();

  const restricted = $derived(status === "age-restricted");
  const asset = $derived({ projectId, assetIdentifier: assetId });
</script>

<figure class="sensitive" style:aspect-ratio={aspectRatio}>
  {#if !restricted || canView}
    <img src={getAssetUrl({ ...asset, allowAgeRestricted: restricted })} {alt} />
  {:else}
    <img src={getAssetUrl({ ...asset, resource: "placeholder" })} alt="" />
    <figcaption>Sensitive content <button type="button" onclick={onReveal}>View</button></figcaption>
  {/if}
</figure>

<style>
  .sensitive { margin: 0; position: relative; }
  img { width: 100%; height: 100%; object-fit: cover; }
  figcaption { position: absolute; inset: 0; display: grid; place-content: center; }
</style>
```

**Plain JavaScript**

```javascript
import { getAssetUrl } from "@calmlens/sdk/app";

// aspectRatio is width divided by height, which you store when the image is uploaded.
export function renderSensitiveImage(
  container,
  { projectId, assetId, status, canView, alt, aspectRatio, onReveal },
) {
  const restricted = status === "age-restricted";
  const asset = { projectId, assetIdentifier: assetId };
  const figure = document.createElement("figure");
  figure.style.cssText = `aspect-ratio: ${aspectRatio}; margin: 0; position: relative`;
  const img = document.createElement("img");
  img.style.cssText = "width: 100%; height: 100%; object-fit: cover";
  figure.append(img);

  if (!restricted || canView) {
    img.src = getAssetUrl({ ...asset, allowAgeRestricted: restricted });
    img.alt = alt;
  } else {
    img.src = getAssetUrl({ ...asset, resource: "placeholder" });
    img.alt = "";
    const button = Object.assign(document.createElement("button"), {
      type: "button",
      textContent: "View",
    });
    button.addEventListener("click", onReveal);
    const caption = document.createElement("figcaption");
    caption.style.cssText = "position: absolute; inset: 0; display: grid; place-content: center";
    caption.append("Sensitive content ", button);
    figure.append(caption);
  }
  container.replaceChildren(figure);
}
```

CalmLens doesn't check the viewer on an opted-in URL, so build it only after the check passes. If CalmLens itself must refuse everyone your backend hasn't approved, your server can issue short-lived signed URLs instead; see [Age-restricted assets](/docs/guides/delivering-assets#age-restricted-assets). [Show sensitive media](/docs/guides/sensitive-media) adds a reveal control for viewers who choose to see it.
