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

PlatformAPIThe viewer sharesHow sure it is
iOS 26 and laterApple's Declared Age Range Which side of your age gates they're on, from their Apple AccountSelf-declared, set by a guardian, or confirmed with a credit card or government ID
AndroidGoogle's Play Age Signals An age range from their Google accountFrom 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 "Over 18" from a government ID in their phone's walletSigned 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.

Which platform is your app on?

Expo or React Native

expo-age-range wraps both platforms' age signals. Calmsnap, 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

npx expo install expo-age-range

Add the Declared Age Range entitlement for iOS to app.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:

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" };
  }
};
ResultWhat to do
adultShow the content. Where the law requires a verified age, also require confirmed
minorKeep the content hidden
declinedKeep it hidden and let the viewer ask again
verification-requiredGoogle Play needs the viewer to verify their age in the Play Store first
unavailableThe device can't answer: an older OS, no signed-in account or a simulator. Use a fallback

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.

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  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:

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>
  );
}

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. Show sensitive media adds a reveal control for viewers who choose to see it.

Signing in

Signing out

Cookies on CalmLens

We use essential cookies for sign-in and security. With your permission, functional cookies remember your preferences and Google Analytics measures website use and signups. Optional ad measurement cookies link Google ad clicks to signups. Personalised advertising stays off. Manage cookies