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 | 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 | 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 | "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 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.
npx expo install expo-age-rangeAdd 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" };
}
};| 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 |
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.