Most age gates start with a DatePicker. Someone scrolls back to their birth year, taps Continue, and your app now has a date of birth for as long as that account exists. It sits in a database. It ends up in backups. It’s one of the fields that makes a breach notification letter worse.
And the app never needed it. The question was always smaller than the data. Is this person 13 or older? 16? 18? Those are yes-or-no questions, and for years we’ve answered them by collecting one of the most identifying facts about a person.
Apple’s Declared Age Range framework answers the small question directly. You give the system up to three age gates. It gives you back a range. No birthday ever reaches your app.
That used to be a nice privacy story. Since June it’s also a compliance story. Texas SB 2420 now applies to new Apple Accounts in Texas, and more states have laws lined up for 2027. For every state Apple has addressed so far, this API is the tool it points developers to.
In Treat Your Data Like a Backstage Pass I spent a whole post on Keychain and CryptoKit, which is about locking down data you have to keep. This is the other half of that idea. The data that’s easiest to protect is the data you never collected.
Frantic — Where the Laws Stand This Week
Ground rule first. I build apps. I’m not a lawyer, and this section is my read of Apple’s developer news and public legal reporting as of November 11, 2026. It’ll be out of date by the time it reaches your legal team, so send them the statutes, not this post.
| Law | Date | Status today |
|---|---|---|
| Texas SB 2420 (App Store Accountability Act) | Jan 1, 2026 on paper. Apple turned it on for new Texas accounts June 4, 2026. | In effect. A federal judge blocked it on December 23, 2025. In early June the Fifth Circuit put that block on hold while the appeal plays out, and on July 6 the Supreme Court declined to step in. Arguments were August 4. I couldn’t find a ruling on the appeal yet. |
| Utah App Store Accountability Act | Originally May 6, 2026. HB 498 moved it to May 6, 2027. | Not yet in effect. HB 498 also took away the attorney general’s power to enforce it, so private lawsuits are the only way it gets enforced. CCIA dropped its challenge on April 21, 2026. |
| Louisiana HB 570 (Act 481 of 2025) | Originally July 1, 2026. HB 977 repealed and re-enacted it for July 1, 2027. | Not yet in effect. The reported reason for the delay is to see how the Texas case plays out. |
| Alabama HB 161 | Jan 1, 2027 | Pending. Signed in early 2026. CCIA sued in federal court in late October, arguing the law violates the First Amendment and burdens interstate commerce. |
| California AB 1043 (Digital Age Assurance Act) | Jan 1, 2027 | Pending. Signed October 13, 2025. AB 1856, signed September 10, 2026, amended it. It works differently: the operating system collects an age at setup and sends developers an age-bracket signal, and developers request that signal when the app is downloaded and launched. I didn’t find a court challenge. |
So right now, Texas is the only one that’s live, and only for new accounts. Apple’s age assurance Q&A says existing accounts on iOS 18 and earlier aren’t affected. Texas has already been blocked once and is still on appeal, so the ground under it could move again.
One wrinkle. Apple’s February developer post said age categories would start flowing for new accounts in Utah on May 6, 2026 and in Louisiana on July 1, 2026. Both states pushed their dates after that post went up, and I haven’t found an Apple update that reflects the change. Don’t assume Apple’s rollout and the legal deadline line up.
The same post covers a few countries outside the US. In Brazil, the API returns age categories. In Australia, Brazil, and Singapore, the App Store now blocks 18+ downloads unless the account is a confirmed adult.
The point of the table isn’t to memorize it. It’s that the list keeps changing. Build this once, in a way that doesn’t care which state passes what next.
The Shortest Straw — What the Declared Age Range API Hands You
You start with the Declared Age Range capability, which adds the com.apple.developer.declared-age-range entitlement to your target. Then you ask AgeRangeService.shared for a range. UIKit uses requestAgeRange(ageGates:_:_:in:) with a UIViewController, and AppKit has the same method with an NSWindow. SwiftUI gets an environment action, @Environment(\.requestAgeRange), which is a DeclaredAgeRangeAction that deals with the presentation context for you.
You can pass up to three age gates, which creates up to four ranges, and Apple’s docs say each range has to be at least two years wide. The response is an AgeRangeService.Response with two cases, .sharing(range:) and .declinedSharing. The AgeRange inside has:
lowerBound: Int?isnilwhen the person is below your lowest gate.upperBound: Int?isnilwhen they’re at or above your highest gate.ageRangeDeclarationis optional and tells you how the age was set.activeParentalControlsis an option set. Today the useful member is.communicationLimits.
The framework has grown in steps, and the steps matter if your floor is iOS 26.0:
- 26.0:
requestAgeRange, with.selfDeclaredand.guardianDeclareddeclarations. - 26.2:
isEligibleForAgeFeatures, sandbox testing, PermissionKit’sSignificantAppUpdateTopic, and StoreKit’sAppStore.ageRatingCode. - 26.4:
requiredRegulatoryFeatures, theRegulatoryFeatureenum, and the significant update acknowledgment sheet for adults. - 26.5: a single
.confirmeddeclaration for people whose age was checked with a credit card or government ID. It replaces six declaration cases from 26.2, which are now deprecated. - 27.0: three more error cases.
Errors come through as AgeRangeService.Error. On 26.x there are only two, notAvailable and invalidRequest. iOS 27 and macOS 27 added declinedOnboarding, invalidAccount, and network. You can still switch on all five with a 26.0 floor, because Swift doesn’t check availability on enum case patterns. On a 26.x device, those three branches just never run.
Two behaviors in the docs change how you design around this. First, in regulated regions the system can return ranges that override your gates, because the law decides the bands. In some of those regions the person can’t decline. Second, the system caches the answer. When someone’s age crosses into a new range, the API keeps returning the old one until the anniversary of their original declaration. They can update it sooner in Settings under their name, then Personal Information, then Age Range for Apps.
The Struggle Within — Turning a Range Into a Policy
The example is made up, but it’s shaped like VinylCrate, because a record app with a marketplace has exactly the tiers this API is good at. Anyone can browse their collection. Community notes on a release are 13 and up. Messaging a seller is 16 and up. Buying and selling is adults only.
I picked gates of 13, 16, and 18. That gives me under 13, 13 to 15, 16 to 17, and 18 and over, which happen to match the Texas categories and California’s brackets exactly. When the gates you choose match the bands the law cares about, a regional override mostly hands you the same answer you asked for.
The first type is the tier, plus everything I’m willing to keep:
import DeclaredAgeRange
enum AgeTier: Int, Codable, Comparable, Sendable {
case under13
case from13To15
case from16To17
case adult
init(lowerBound: Int?) {
guard let lowerBound else {
self = .under13
return
}
switch lowerBound {
case 18...: self = .adult
case 16...: self = .from16To17
case 13...: self = .from13To15
default: self = .under13
}
}
static func < (lhs: AgeTier, rhs: AgeTier) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
enum DeclarationSource: String, Codable, Sendable {
case selfDeclared
case guardianDeclared
case confirmed
case unknown
}
struct AgeSignal: Codable, Equatable, Sendable {
var tier: AgeTier
var source: DeclarationSource
var communicationLimited: Bool
}
enum AgePolicy: Codable, Equatable, Sendable {
case notAsked
case declined
case unavailable
case known(AgeSignal)
}
The tier only looks at lowerBound, and it compares with ranges instead of checking for my exact gate values. That’s on purpose. If a regulated region overrides my gates and hands back a range that starts at 14, the person still lands in from13To15 instead of falling through a == 13 check. Apple’s docs say to treat the response as a range and not infer an exact age. Using the lower bound also means I always apply the youngest age the range allows, which is the conservative reading.
Mapping the response is short:
extension AgePolicy {
init(_ response: AgeRangeService.Response) {
switch response {
case .declinedSharing:
self = .declined
case let .sharing(range):
self = .known(AgeSignal(
tier: AgeTier(lowerBound: range.lowerBound),
source: DeclarationSource(range.ageRangeDeclaration),
communicationLimited: range.activeParentalControls.contains(.communicationLimits)
))
@unknown default:
self = .unavailable
}
}
}
extension DeclarationSource {
init(_ declaration: AgeRangeService.AgeRangeDeclaration?) {
guard let declaration else {
self = .unknown
return
}
if #available(iOS 26.5, macOS 26.5, *) {
if case .confirmed = declaration {
self = .confirmed
return
}
}
switch declaration {
case .selfDeclared: self = .selfDeclared
case .guardianDeclared: self = .guardianDeclared
default: self = .unknown
}
}
}
The declaration mapping has a small judgment call in it. On devices running 26.2 through 26.4, a confirmed adult comes back as one of the six deprecated cases, like paymentChecked. I don’t name those. They fall into default and become .unknown, which is the weakest trust level. That costs this app nothing, because nothing in it requires a confirmed age. If something did, I’d raise the deployment target to 26.5 instead of writing a switch full of deprecated symbols.
Features declare what they need, and the policy answers:
enum GatedFeature: Sendable {
case collection
case communityNotes
case sellerMessages
case marketplace
var minimumTier: AgeTier? {
switch self {
case .collection: nil
case .communityNotes: .from13To15
case .sellerMessages: .from16To17
case .marketplace: .adult
}
}
}
extension AgePolicy {
func allows(_ feature: GatedFeature) -> Bool {
guard let minimum = feature.minimumTier else { return true }
guard case let .known(signal) = self else { return false }
if feature == .sellerMessages, signal.communicationLimited { return false }
return signal.tier >= minimum
}
var canAsk: Bool {
switch self {
case .notAsked, .declined: true
case .unavailable, .known: false
}
}
}
Apple says parental controls always win over your own settings. If Screen Time has communication limits on, a 16-year-old doesn’t get seller messages, even though their tier allows it. The right next step there is a PermissionKit request so a parent can approve it, not a quiet unlock.
Nothing in this file touches UI, and none of it needs a device. That’s what makes the next part testable.
No Leaf Clover — Declines, Errors, and Not Asking Twice
The model owns two decisions: whether this launch has to ask, and what to do with whatever comes back.
@preconcurrency import DeclaredAgeRange
import Foundation
import Observation
@MainActor
@Observable
final class AgeGate {
private(set) var policy: AgePolicy
private(set) var mustAsk = false
private(set) var isAsking = false
private let defaults: UserDefaults
private static let storageKey = "agePolicy.v1"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
policy = Self.loadPolicy(from: defaults) ?? .notAsked
}
func refreshRequirements() async {
if #available(iOS 26.4, macOS 26.4, *) {
let features = (try? await AgeRangeService.shared.requiredRegulatoryFeatures) ?? []
mustAsk = features.contains(.declaredAgeRangeRequired)
} else if #available(iOS 26.2, macOS 26.2, *) {
mustAsk = (try? await AgeRangeService.shared.isEligibleForAgeFeatures) ?? false
} else {
mustAsk = false
}
}
func resolve(_ ask: () async throws -> AgeRangeService.Response) async {
guard !isAsking else { return }
isAsking = true
defer { isAsking = false }
do {
commit(AgePolicy(try await ask()))
} catch let error as AgeRangeService.Error {
switch error {
case .declinedOnboarding:
commit(.declined)
case .network:
break
case .invalidRequest:
assertionFailure("Each age range must span at least two years")
markUnavailable()
case .notAvailable, .invalidAccount:
markUnavailable()
@unknown default:
markUnavailable()
}
} catch {
markUnavailable()
}
}
private func markUnavailable() {
if case .known = policy { return }
policy = .unavailable
}
private func commit(_ newPolicy: AgePolicy) {
policy = newPolicy
guard let data = try? JSONEncoder().encode(newPolicy) else { return }
defaults.set(data, forKey: Self.storageKey)
}
private static func loadPolicy(from defaults: UserDefaults) -> AgePolicy? {
guard let data = defaults.data(forKey: storageKey) else { return nil }
return try? JSONDecoder().decode(AgePolicy.self, from: data)
}
}
refreshRequirements() is version layering in four lines. On 26.4 and later, requiredRegulatoryFeatures tells me directly whether .declaredAgeRangeRequired applies to this person. On 26.2 and 26.3, isEligibleForAgeFeatures is the closest signal. Below that there’s no regulatory signal at all, so the app never asks at launch.
The @preconcurrency on the import matters. DeclaredAgeRange is built in Swift 5 mode, and neither AgeRangeService nor the SwiftUI action is Sendable. Their async members are nonisolated, so calling one from a @MainActor type sends a non-Sendable value off the main actor, and Swift 6 rejects it. That includes the plain try await requestAgeRange(ageGates: 13, 16, 18) from Apple’s own docs. The compiler suggests @preconcurrency itself, and it stays until Apple annotates the module. The policy file keeps a plain import, because nothing there crosses an isolation boundary.
The error handling is where I spent the most time:
declinedOnboardingmeans the person backed out of the system flow. I treat it the same as a decline and remember it.networkis temporary. I keep whatever policy I already had, persist nothing, and let the next natural moment try again.invalidRequestis my bug. The gates don’t make two-year ranges, or the configuration is off. It should never reach production, so it trips an assertion in debug builds.notAvailableandinvalidAccountmean the system can’t give me an answer for this account. I don’t persist that, so the app checks again next launch.
Everything else lands in markUnavailable(), and it never overwrites a known answer. In a regulated region the app asks at every launch, and a failed check shouldn’t lock an adult out of something they used yesterday. If there’s no known answer yet, gated features stay locked for the session. On 26.x a network failure ends up here too, because there’s no network case before 27.
The asking happens in views, because the environment action lives there. One extension keeps the gates in a single place, since AgeTier depends on them:
@preconcurrency import DeclaredAgeRange
import SwiftUI
@MainActor
extension DeclaredAgeRangeAction {
func requestTiers() async throws -> AgeRangeService.Response {
try await self(ageGates: 13, 16, 18)
}
}
@main
struct CrateApp: App {
@State private var ageGate = AgeGate()
var body: some Scene {
WindowGroup {
RootView()
.environment(ageGate)
}
}
}
struct RootView: View {
@Environment(AgeGate.self) private var ageGate
@Environment(\.requestAgeRange) private var requestAgeRange
var body: some View {
TabView {
Tab("Collection", systemImage: "square.stack") {
CollectionView()
}
Tab("Market", systemImage: "tag") {
AgeGated(.marketplace) {
MarketplaceView()
}
}
}
.task {
await ageGate.refreshRequirements()
guard ageGate.mustAsk else { return }
await ageGate.resolve { try await requestAgeRange.requestTiers() }
}
}
}
And the gate itself:
struct AgeGated<Content: View>: View {
private let feature: GatedFeature
private let content: Content
@Environment(AgeGate.self) private var ageGate
@Environment(\.requestAgeRange) private var requestAgeRange
init(_ feature: GatedFeature, @ViewBuilder content: () -> Content) {
self.feature = feature
self.content = content()
}
var body: some View {
if ageGate.policy.allows(feature) {
content
} else {
ContentUnavailableView {
Label("Not Available", systemImage: "lock")
} description: {
Text(message)
} actions: {
if ageGate.policy.canAsk {
Button("Share Age Range") {
Task {
await ageGate.resolve { try await requestAgeRange.requestTiers() }
}
}
.disabled(ageGate.isAsking)
}
}
}
}
private var message: String {
switch ageGate.policy {
case .notAsked:
"Share your age range to use this. The app gets a range, never your birthday."
case .declined:
"You chose not to share your age range. You can share it whenever you like."
case .unavailable:
"Age range sharing isn't available for this account right now."
case .known:
"This part of the app isn't available for your age range."
}
}
}
That’s the whole re-prompt strategy, and most of it is about when the app doesn’t ask.
If the system says this person is in a regulated region, the app asks at launch, because that’s when the law expects the check. Outside of that, nothing happens at launch. The first time someone taps the Market tab, they see why it’s locked and a button they can press. If they decline, the button stays, but the app never brings the sheet up again on its own. No launch prompts, no timers, no “are you sure?” on the third visit. A decline is an answer.
When the policy is .known and the tier is too young, there’s no button at all. Asking again would just return the cached range, so offering the button would be a small lie.
There’s one thing I’d watch. A developer forums thread has several reports, through August, of isEligibleForAgeFeatures returning false in production for users in Brazil and Texas. As of this writing, nobody from Apple has answered it. I don’t know if that’s a rollout detail or a bug. It’s one more reason the regulatory flag in this design only decides whether to ask at launch. It doesn’t decide whether gated features work. If the flag is wrong, a gated feature still asks the first time someone taps it.
Nothing Else Matters — What I Persist
Look at what goes into UserDefaults: a tier, a declaration source, and one boolean. No bounds and no timestamps. Nothing leaves the device. It doesn’t go to analytics, and it isn’t attached to crash reports.
That isn’t paranoia. The system already caches the real answer, so my copy is a cache of a cache. All it does is let the app draw the right screen before the async check finishes. The authority is the next call to the API, not my stored value.
The tier is also all the policy needs. If the marketplace only cares whether someone is an adult, keeping upperBound doesn’t make it any safer. It’s just one more field about a person that you now have to protect.
Getting the answer has consequences, too. California’s law, even after this year’s amendments, treats a developer who receives the signal as having actual knowledge of that user’s age range. That’s the same actual-knowledge standard COPPA has used for a long time. Once the API tells you someone is under 13, you can’t act like you didn’t hear it. That’s a reason to keep the answer small, not a reason to avoid asking. The kid is using your app either way.
The when-to-ask rule should also stay easy to change. Alabama’s law, if it survives its challenge, limits developers to requesting age data about once every 12 months, with exceptions for special circumstances. California expects a request at download and launch. Those two pull in different directions. Right now “ask at launch when required” is one guard in RootView. That’s where I want it.
Holier Than Thou — What Self-Declared Is Worth
.selfDeclared means an adult picked their own age range when they set up their account. That’s the same trust level as the DatePicker you were going to build, minus the birthday. It’s useful for shaping the experience. It isn’t proof of anything.
.guardianDeclared means a parent or guardian in Family Sharing set it, which is a much stronger signal for kids’ accounts. .confirmed, on 26.5 and later, means the age was checked with a credit card or government ID.
Apple’s own sample project shows how much that difference can matter. In its significant-change flow, an adult without a confirmed declaration is blocked until they verify in Settings. Most apps won’t need to go that far. But when something really depends on a person being an adult, like payouts, age-restricted purchases, or anything a regulator would ask about, you should know which kind of “adult” you’re trusting.
This is the same lesson as App Attest and DeviceCheck, from a different direction. App Attest tells your server the request came from your real app. It says nothing about who’s holding the phone. Declared Age Range tells you what the account says about the person and how that was established. Neither one is identity, and you shouldn’t use either one as if it were.
Fade to Black — Significant Changes and Revoked Consent
Texas doesn’t stop at the age check. Minors need parental consent for downloads and purchases, which the App Store handles. They also need consent again when the app makes a significant change, and that part is your job. Apple is clear that you decide what counts as significant. A change in age rating counts under Texas law.
On 26.4 and later, requiredRegulatoryFeatures includes .significantAppChangeRequiresParentalConsent when a parent has to approve the change. PermissionKit gets the request to that parent. Everything in the next two snippets needs 26.2, and so does AppStore.ageRatingCode further down. On a 26.0 floor, that means if #available(iOS 26.2, *):
import PermissionKit
let topic = SignificantAppUpdateTopic(
description: "This update adds direct messages between collectors and sellers."
)
let question = PermissionQuestion(significantAppUpdateTopic: topic)
Apple’s sample sends that question with a PermissionButton and listens for the answer wherever it arrives:
for await response in AskCenter.shared.responses(for: SignificantAppUpdateTopic.self) {
guard response.choice.answer == .approval else { continue }
versionManager.handleAllChanges()
}
Write that description for a parent who has never opened your app. “We’ve made improvements” is the exact example Apple’s docs call too vague.
For adults, 26.4 added a system acknowledgment sheet. Call showSignificantUpdateAcknowledgment(in:updateDescription:) on the service, or use the SignificantUpdateAction environment action in SwiftUI. Only show it when requiredRegulatoryFeatures contains .significantAppChangeRequiresAdultNotification.
To catch rating changes, await AppStore.ageRatingCode gives you the current rating code. Store the last one you saw and compare.
Revoked consent is the case your app never sees. A parent in Texas can withdraw consent for an app they already approved, and the system then blocks the app from launching on the kid’s device. Your server finds out through a RESCIND_CONSENT App Store Server Notification. If your backend keeps anything for that account, that notification is your cue to act on it. The app won’t get a chance to tell you itself.
Wherever I May Roam — Other Regions and Older OS Versions
The API works worldwide on iOS 26, iPadOS 26, and macOS 26. Outside regulated regions it’s opt-in. People share an age range they or a parent already registered, and they can decline. Plan for .declinedSharing to be common there, and make sure the decline path still gives people a good app, not a lobby.
On macOS, isEligibleForAgeFeatures always returns false because macOS doesn’t require age assurance, but requestAgeRange still works. The design above handles that without special cases. The Mac never asks at launch and still asks on tap.
If your floor is below iOS 26, the framework isn’t there for those users, and as I said up top, existing accounts on iOS 18 and earlier aren’t covered anyway. Wrap the import and the calls in availability checks and give older devices your fallback policy.
The fallback is where it’s tempting to bring the DatePicker back. Don’t. If a feature outside any legal requirement needs an adult for product reasons, ask “Are you 18 or older?” and store the answer as .selfDeclared. You get the same yes-or-no answer without the birthday.
The Frayed Ends of Sanity — Testing and Shipping It
The pure parts get regular unit tests, and that’s most of the logic:
import Testing
@testable import Crate
struct AgePolicyTests {
@Test(arguments: zip(
[nil, 12, 13, 14, 15, 16, 17, 18, 40] as [Int?],
[AgeTier.under13, .under13, .from13To15, .from13To15, .from13To15,
.from16To17, .from16To17, .adult, .adult]
))
func tierComesFromLowerBound(lowerBound: Int?, expected: AgeTier) {
#expect(AgeTier(lowerBound: lowerBound) == expected)
}
@Test func declinedGetsOnlyTheCollection() {
let policy = AgePolicy.declined
#expect(policy.allows(.collection))
#expect(!policy.allows(.communityNotes))
#expect(!policy.allows(.marketplace))
}
@Test func communicationLimitsBlockSellerMessages() {
let policy = AgePolicy.known(AgeSignal(
tier: .from16To17,
source: .guardianDeclared,
communicationLimited: true
))
#expect(policy.allows(.communityNotes))
#expect(!policy.allows(.sellerMessages))
}
}
The 14 and 12 rows are there for regional overrides. They’re bounds my gates would never produce, and the tier still needs to make sense when the system hands them back anyway.
The system side needs Apple’s sandbox, available since 26.2. Turn on Developer Mode, then go to Settings, then Developer, then Sandbox Apple Account. Tap Manage and pick Age Assurance. The guardian-declared scenarios cover under 13, 13 to 15, and 16 to 17. The 18-and-over scenarios come self-declared or confirmed. There are also variants with different approval states for significant changes. The API returns matching bounds and declarations.
The same screen has Revoke App Consent. Give it your bundle ID, and if you have App Store Server Notifications V2 set up, it fires a RESCIND_CONSENT notification at your server. I couldn’t find anything from Apple about Simulator support for these scenarios, so plan on a real device.
On the App Store side, Apple’s Q&A says App Review doesn’t change for this. Your age rating is a separate thing. It describes your content, and you set it in the App Store Connect questionnaire, which picked up 13+, 16+, and 18+ tiers last year. An 18+ rating doesn’t get you out of using the API where the law requires it.
Turn on the Declared Age Range capability for your App ID before you archive, or you’ll find out at signing time. If the policy stays on the device like it does here, it isn’t data you collect as far as the privacy label is concerned. If you sync it to a server, run that past whoever owns your privacy label.
The Thing That Should Not Be
The birthday field was always the thing that should not be. We collected a date because the platform didn’t give us a better way to ask a yes-or-no question. Now it does.
The laws will keep changing. Texas could lose on appeal, and Alabama could get blocked before January. California could amend its law again. None of that changes the shape of the code. Ask for a range at the moments that matter, turn it into the smallest policy your features need, respect a decline, and keep the answer on the device.
Ask for the range. Forget the birthday.