Guidance on implementing Declared Age Range API in response to Texas SB2420

I've spent the last few days researching the upcoming laws in Texas and other US states, and how these laws will impact on developers around the world. I want to share what I've learned so far with the community and get feedback on my current understanding. This post is not so much focused on a single API, but more of the bigger picture.

Background

  1. The law essentially mandates that: (1) app store platforms implement age categorization and verification mechanisms, and (2) developers implement logic to listen to age categorization signals provided by the platform and respond accordingly. You can read the law itself here: https://capitol.texas.gov/tlodocs/89R/billtext/html/SB02420S.HTM
  2. Most people seem to be interpreting the law as follows: All developers who distribute apps in the USA are effectively required to implement the new APIs (required by Texas, not by Apple). The penalties are heavy, but it's unclear whether developers would actually be pursued and by whom (e.g. would someone seriously pursue an alarm clock app because it could be accessed by a minor?).
  3. Putting aside the ethical, privacy, and legal issues (and the damaging precedents this law sets), most people seem to agree that, from a technical perspective, this is a very silly way to implement age blocking (app store collects the info and passes it to dev, dev is responsible for blocking access). It would make way more sense for the platform to block the app directly for affected users (with optional API support for developers who wish to use it). However, I believe the law has specifically mandated that this is how they expect the system to work, so Apple's hands have been tied.
  4. Apple has basically complied with their obligations by providing the relevant APIs to developers.
  5. Because the law is vague and open-ended, there are a lot of legal and technical uncertainties about what developers actually need to do to be compliant. Understandably, Apple seems reticent to provide any guidance to developers that could be interpreted as legal advice. Apple's docs simply describe what the APIs do with no guidance on what the overall flow is meant to look like or how and when the APIs should actually be used in practice.
  6. Americans familiar with the political situation seem to think there's the possibility of an injunction before this law goes into effect, but that looks increasingly unlikely given that it's two weeks away.

Developer solutions

  1. Many devs seem to be exploring two main workarounds, at least as temporary solutions: (1) Raise your app's rating to 18+. Putting aside the fact that Texas law would effectively be forcing developers to raise their global age rating (resulting in lost revenue that extends far beyond Texas), it remains unclear whether this solution is actually legally compliant, since the law specifically mandates that apps must implement logic to respond to signals from the platform. (2) Geo-block Texas. Again, it remains unclear if this is compliant because geo-blocking is not 100% accurate and it doesn't actually do what the law says you have to do. It also creates issues if you already have users in Texas, and it means performing additional privacy-hostile checks (i.e., detecting the user's location, even users who are not subject to the law).
  2. The DeclaredAgeRange API is actually pretty straight-forward to use – although there is still a lack of documentation on certain edge cases and it's difficult to test. In addition, the new APIs are only available in iOS 26.2, so it's unclear what you need to do if you're still supporting < iOS 26.2. Some people are of the opinion that developers can only reasonably respond to the signals that are available, thus pushing responsibility back to the platforms in regards to earlier OS versions.
  3. The API provides a bool (AgeRangeService.shared.isEligibleForAgeFeatures), which allows you to determine if the user is someone to whom age checks need to be applied. https://developer.apple.com/documentation/declaredagerange/agerangeservice/iseligibleforagefeatures I'm not 100% sure, but perhaps the simplest action you can take is to check this bool on launch and block access if it's true. In any case, it looks like this API will be very useful because it means we can avoid applying the checks in other jurisdictions and for grandfathered-in users without needing to implement custom geo-tracking code (albeit only in iOS 26.2+).
  4. To implement the API, my current thinking is that, on every launch, I should first check the above bool and, if it's true, do the following: (1) get the App Store age rating with let appStoreAgeRating = await AppStore.ageRatingCode ?? 18, (2) request the user's age with let ageRangeResponse = try await AgeRangeService.shared.requestAgeRange(ageGates: appStoreAgeRating), (3) check that the user has agreed to share their age, (4) check that lowerBound >= appStoreAgeRating, and (5) check that the verification method is not one of the self-declared methods. If this procedure fails, I should block access to the app and provide a link to Apple's support page: https://support.apple.com/en-us/122770 I stress, however, that this is just my current idea and there are some edge cases I'm unsure about.

Other issues

  1. It is possible to do some basic testing of the API, but only using a sandbox App Store account on a physical device. From the Developer section in iOS Settings, you can select from a few different scenarios, like "Texas user aged 14 without parental consent", etc.
  2. There's also a whole separate aspect to this law relating to "significant updates". Everyone seems kinda confused about this, but it seems like the general idea is that, if your app's age classification changes in the future, the app should be responsive to that change. My current interpretation is that if I use the AppStore.ageRatingCode as the age gate (as described above) then that should allow me to comply, but I haven't really looked into this aspect of the law yet.
  3. There's also another aspect to this law requiring developers to revoke access to the app when requested by the parent. I have not looked into this yet, but as noted above, it doesn't make sense to me why this is the developer's responsibility given that the platforms already provide solid parental controls. Do I need to something else in addition to what I've sketched out above?

It goes without saying, of course, that everything above is not legal advice, and I still have some gaps in my understanding.

I would really appreciate any feedback on the above, perhaps with recommendations about better ways to approach this.

Apple's hands have been tied

Apple could have implemented it much more simply. The way Google Play is doing it is simple to understand, clearly documented, easy to implement, and just as privacy-secure for end users. https://developer.android.com/google/play/age-signals/overview

From my reading and discussions, I believe raising an app's Age Rating to 18+ and Geo-Blocking Texas are both NOT COMPLIANT with the Texas (or UT (later in 2026), LA (later in 2026), and CA (January 2027)) law.

AgeRangeService.shared.isEligibleForAgeFeatures is currently broken though: https://developer.apple.com/forums/thread/809829 My team is working under the assumption that it will eventually work and it's on Apple to fix their backend so it correctly returns True or False based on the user being in the impacted region.

@jwcarr thanks for this detailed analysis. That's the type of advice we would have expected from Apple… I think they have ways to avoid the legal issues.

A few more points:

  • There should likely be a step (0)
  • (0) check if iOS is 26+. Otherwise, proceed without any test (because we cannot do them)
  • (1) get the App Store age rating with let appStoreAgeRating = await AppStore.ageRatingCode ?? 18,
  • (2) request the user's age with let ageRangeResponse = try await AgeRangeService.shared.requestAgeRange(ageGates: appStoreAgeRating),
  • (3) check that the user has agreed to share their age,
  • (4) check that lowerBound >= appStoreAgeRating, and
  • (5) check that the verification method is not one of the self-declared methods. If this procedure fails, I should block access to the app and provide a link to Apple's support page

A few more questions:

  • in (1), which import to use AppStore.ageRatingCode ?
  • in (2), if UIKit and not SwiftUI, need the in parameter let ageRangeResponse = try await AgeRangeService.shared.requestAgeRange(ageGates: appStoreAgeRating, in: self)
  • where should parental control be tested ? In step (5) ?
  • where to deal with change in user's age or repudiation (as required by law if I read properly)
  • what happens if the requests in await do not respond ? Is there some type of timeout, to avoid user being locked in waiting ?

It is a serious issue not be able to test on simulator. We may be forced to publish an app without thoroughly testing it works OK for so in Texas.

(0) check if iOS is 26+. Otherwise, proceed without any test (because we cannot do them)

Yep, agree. In fact, we specifically need to check for iOS 26.2.

in (1), which import to use AppStore.ageRatingCode ?

You just need to import storekit to access that. However, as noted below, I'm considering a different option.

in (2), if UIKit and not SwiftUI, need the in parameter

Indeed you do. Annoyingly, you also need the in parameter if you're putting your code in some class (e.g. some kind of age manager class), and it's not clear to me what you need to pass in (assuming you have a SwiftUI app).

where should parental control be tested ? In step (5) ?

I honestly don't know at this point – I haven't gotten to that point yet, but as noted below, I'm looking for ways to avoid that side of things.

where to deal with change in user's age or repudiation (as required by law if I read properly)

My assumption is that if you check the age on every launch, then this should take care of itself. But I could be wrong.

what happens if the requests in await do not respond ? Is there some type of timeout, to avoid user being locked in waiting ?

I'm not sure – I'm assuming they should return quite quickly and may not even require network connectivity if the information is cashed on device (the WWDC video talks about device caching of the age info).

In my testing, I've found that the API calls return very quickly (< 1 second), but that may simply be because it's in a test environment.

In any case, my intention is to default to giving the user full access to the app, and I'll only override that if the methods (called in an async task) suggest the user is not allowed access. However, I do not know if this is appropriate and I am open to other suggestions.

Relatedly, what should we do if the methods throw an error? Should we assume the user is a child and restrict access? My current plan is to treat the two API calls differently:

  1. If isEligibleForAgeFeatures throws, I will assume that the user is not eligible and therefore has full access.
  2. If requestAgeRange throws, I will assume the user is a child and restrict access.

My logic is that if I cannot determine eligibility, then I should err on the side of the user not being eligible, since the vast majority of users (for the foreseeable future and around the world) will not be eligible. However, if the user is eligible for age features, then we should err on the side of caution, and assume child until proven otherwise.


Having thought about all this for another day, my new plan is to drop the App Store age rating check (for now), and use age 18 for the age gate parameter.

My primary reason for this is because I don't have much time to properly investigate the PermissionKit stuff and the significant changes stuff before the end of the year (and this stuff seems rather more complicated). So, my first priority is to make sure that the app is blocked for all under-18s (who are subject to the law) until I have a clearer understanding of those issues.

However, I would be very keen to hear how other people are handling that stuff.

Many thanks.

 

Having thought about all this for another day, my new plan is to drop the App Store age rating check (for now), and use age 18 for the age gate parameter.

That may be the safe and simplest way. It will hurt some Texas users, but not the rest of the world, which is very important.

 

If requestAgeRange throws, I will assume the user is a child and restrict access.

That raises another question. If the whole app is OK for 4+, what should be blocked ? Should we terminate the app ?

It will hurt some Texas users, but not the rest of the world, which is very important.

I agree – one of my main concerns is how these laws will impact on all my other users.

Should we terminate the app ?

You could do that, but it's not a very good user experience. I plan to present some simple messaging that directs users to an Apple support article.

Here's a quick sketch of how I'm currently planning to handle this across a few different apps (in SwiftUI). I would appreciate any feedback on this approach, from either a technical or legal standpoint.

In my main App struct, I will branch into a new ContentViewWithAgeGate view for iOS 26.2+.

WindowGroup {
    if #available(iOS 26.2, *) {
        ContentViewWithAgeGate()
    } else {
        ContentView()
    }
}

ContentViewWithAgeGate acts as a wrapper around ContentView and perfoms the checks:

import SwiftUI
@preconcurrency import DeclaredAgeRange

@available(iOS 26.2, *)
struct ContentViewWithAgeGate: View {
    @Environment(\.requestAgeRange) var requestAgeRange
    @State private var isAgeRestricted: Bool = false
    
    var body: some View {
        if isAgeRestricted {
            AgeRestrictedView()
        } else {
            ContentView()
                .task {
                    isAgeRestricted = await determineIfUserIsAgeRestricted()
                }
        }
    }
    
    private func determineIfUserIsAgeRestricted() async -> Bool {
        let isEligibleForAgeFeatures = try? await AgeRangeService.shared.isEligibleForAgeFeatures
        guard let isEligibleForAgeFeatures, isEligibleForAgeFeatures == true else { return false }
        guard let ageRangeResponse = try? await requestAgeRange(ageGates: 18) else { return true }
        switch ageRangeResponse {
        case .sharing(let range):
            guard let lowerBound = range.lowerBound, let ageRangeDeclaration = range.ageRangeDeclaration else {
                // No lower bound or no declaration information; prevent access
                return true
            }
            if lowerBound >= 18 {
                // User is an adult
                switch ageRangeDeclaration {
                case .selfDeclared, .guardianDeclared:
                    // Insufficient level of evidence; prevent access
                    return true
                case .checkedByOtherMethod, .guardianCheckedByOtherMethod, .governmentIDChecked, .guardianGovernmentIDChecked, .paymentChecked, .guardianPaymentChecked:
                    // Sufficient level of evidence; permit access
                    return false
                @unknown default:
                    // Unknown AgeRangeDeclaration value; prevent access
                    return true
                }
            } else {
                // User is not old enough; prevent access
                return true
            }
        case .declinedSharing:
            // User declined to share age info; prevent access
            return true
        @unknown default:
            // Unknown response value; prevent access
            return true
        }
    }
}

Then, AgeRestrictedView just presents some general information:

struct AgeRestrictedView: View {
    var body: some View {
        VStack(alignment: .center, spacing: 30) {
            Text("Access to this app is age-restricted due to local laws in your state or territory.")
            Text("Please verify your age with Apple and allow this app to access your age information.")
            Text("For further information, please refer to the following Apple support article: https://support.apple.com/en-us/122770")
        }
        .multilineTextAlignment(.center)
        .padding()
    }
}

Yes, a message before termination is needed. But end of the game, once user clicks OK (UIKit) will be app termination, with all our apologies for the inconvenience.

So here is what the code could look like in UIKit. Any comment welcomed.

import UIKit
import DeclaredAgeRange
import StoreKit     // In case need to check appStoreAgeRating

class ViewController: UIViewController {

    @IBOutlet weak var welcomeLabel: UILabel!
    @IBOutlet weak var stopButton: UIButton!
    
    var task: Task<Void, Never>?    // To allow to cancel Task if needed
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        // test for age to be done here instead of button action ?
    }

    @available(iOS 26.2, *)
    // Called only on iOS 26.2+ in any case
    func testAgeRange() async -> Bool {
        do {
            print("Calling isEligibleForAgeFeatures")
            var isEligible = false

            print("iOS 26.2 or later")
            isEligible = try await AgeRangeService.shared.isEligibleForAgeFeatures
            // try Task.checkCancellation()   // How to allow user to cancel the check to avoid being blocked ?
            
            print("isEligible", isEligible) // Does not work on simulator
            if !isEligible {
                print("Not in Texas")
                return true // Not in Texas, so we can proceed
            }
        } catch { // AgeRangeService.Error.notAvailable {
           // No age range provided.
           return true // should we accept if no ageRange provided, in order not to cause problem out of Texas ?
        }

        do {
            let response = try await AgeRangeService.shared.requestAgeRange(ageGates: /*13, 15,*/ 18, in: self) // To be safe, test for 18 in Texas
            
            var lowerBound = 18
            switch response {
                case .declinedSharing:
                    print("User declined to share age.")
                    return false
               case .sharing(let range):
                    lowerBound = range.lowerBound ?? 18
                    print("User age range: \(range.lowerBound ?? 0)-\(range.upperBound ?? 99)")
                @unknown default:
                    print( "fatalError()")
                    return false
            }
            
            var ok = false
            if lowerBound >= 18 {
                // Allow access to 18+ features. We test only for Texas
                ok = true
            } else  {
                // Require parental consent ?
                // Show age-appropriate content
                ok = false // Allow access only to 18+ in Texas
            }
            return ok // Authorized for all 18+ in Texas 

        } catch { // AgeRangeService.Error.notAvailable {
           // No age range provided.
           return false
        }
    }
    
    func executeStart() {
        welcomeLabel.isHidden = false
    }
    
    // In case too long wait
    @IBAction func stopTask(_ sender: UIButton) {
        print("ask to stop task", task?.isCancelled)
        task?.cancel()
        print("cancelled?", task?.isCancelled)
        stopButton.isHidden = true
    }
    
    @IBAction func start(_ sender: UIButton) {
        
        stopButton.isHidden = false
        
        task = Task {
            // @MainActor in
            print("Start")
            if #available(iOS 26.2, *) {
                let appStoreAgeRating = await AppStore.ageRatingCode ?? 18 // Not used yet
                if await self.testAgeRange() {  // self needed if detached Task
                    // Need to test for parental control here ?
                    if self.task?.isCancelled != nil {
                        print("Task has been cancelled")
                    }
                } else {
                    print("No testAgeRange")
                    // Alert and exit the app when user acks alerts, with the following message
                    // "Access to this app is age-restricted due to local laws in your state or territory.")
                    // "Please verify your age with Apple and allow this app to access your age information.")
                    // "For further information, please refer to the following Apple support article: https://support.apple.com/en-us/122770")

                }
                
            } else {
                print("Not 26.2")
                // do nothing ? We can run the app.
            }
            self.executeStart()
        }
        print("We have completed the task")
    }
    
}

Related to termination, my advice is to NEVER terminate your app. In the past, some of our Apps had a Decline button on the Terms of Service (ToS) screen that terminated the app, but App Review flagged that behavior as a Crash and blocked our app submission. Now our ToS screens only have an Accept button and the user can press that or they can close the app themselves; this always gets through App Review.

For Age Verification, we have implemented similar: If an eligible user Declines to share their Age Range or is a minor without parental consent, they see a screen explaining that they are blocked with a link to App Store documents about the law and settings. There is no way out of that screen other than closing the app.

@jarrodlombardo-eventbase

NEVER terminate your app.

Thanks, I'll follow your advice. I will disable all buttons from the welcome page.

Where did you implement the ageVerification code ? In viewDidLoad ?

Guidance on implementing Declared Age Range API in response to Texas SB2420
 
 
Q