Post

iOS DeviceCheck|Implement One-Time Offers and Trials Seamlessly with Swift

iOS developers struggling with secure one-time offers or trials can leverage DeviceCheck to track user eligibility across devices, preventing abuse and ensuring fair usage. Learn how to implement this with Swift for reliable, scalable results.

iOS DeviceCheck|Implement One-Time Offers and Trials Seamlessly with Swift

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

This post was translated with AI assistance — let me know if anything sounds off!


Perfect iOS Implementation of One-Time Discounts or Trials (Swift)

iOS DeviceCheck Goes With You to the Ends of the Earth

While writing the previous article on Call Directory Extension, I accidentally discovered this lesser-known API. Although it’s not new (announced at WWDC 2017 / supported on iOS ≥11) and very easy to implement, I still did a bit of research and testing and compiled an article as a record.

What can DeviceCheck do?

Allow developers to create identification tags for users’ devices

Since iOS ≥ 6, developers cannot access the device’s unique identifier (UUID). The workaround is to use IDFV combined with KeyChain (for details, refer to the previous article). However, the UUID will reset when changing iCloud accounts or resetting the phone, etc. This cannot guarantee device uniqueness. If used for business logic storage and decisions, such as first-time free trials, users might exploit this by frequently changing accounts or resetting phones to repeatedly access unlimited free trials.

Although DeviceCheck does not guarantee an unchanging UUID, it provides a “storage” function. Apple offers 2 bits of cloud storage per device. By sending a temporary token generated by the device to Apple, you can write/read those 2 bits of information.

2 bits? What can it store?

Only four states can be combined, limiting the available functions.

Comparison with the Original Storage Method:

✓ means the data is still available

✓ indicates the data is still available

p.s. I personally tested this on my own phone, and the results matched; even after logging out and switching iCloud accounts, deleting all data, resetting all settings, restoring to factory defaults, and reinstalling the app, it still retrieved the values.

The main operation process is as follows:

The iOS app uses the DeviceCheck API to generate a temporary token for device identification, which is sent to the backend. The backend then combines the developer’s private key and developer information to create a JWT, which is sent to Apple’s server. After receiving the response from Apple, the backend processes the format and sends it back to the iOS app.

Applications of DeviceCheck

Attached is a screenshot of DeviceCheck from WWDC2017:

Because each device can only store 2 bits of information, the possible items are roughly as the official documentation mentions, including whether the device has been trialed, whether it has paid, whether it is a banned user, etc.; and only one item can be implemented.

Support: iOS ≥ 11

Start!

After understanding the basic information, let’s get started!

iOS APP side:

1
2
3
4
5
6
7
8
9
10
import DeviceCheck
//....
//
DCDevice.current.generateToken { dataOrNil, errorOrNil in
  guard let data = dataOrNil else { return }
  let deviceToken = data.base64EncodedString()
            
   //...
   //POST deviceToken to backend, let backend query Apple server, then return result to APP for processing
}

As described in the process, the APP only needs to obtain a temporary identification Token (deviceToken)!

Next, send the deviceToken to our backend API for processing.

Backend:

The focus is on the backend processing part.

1. First, log in to the Developer Console and note down the Team ID

2. Click the sidebar’s Certificates, IDs & Profiles to go to the certificate management platform

Select "Keys" -> "All" -> "+" in the top right to add

Select “Keys” -> “All” -> “+” at the top right to add a new one

Step 1. Create a new Key and check "DeviceCheck"

Step 1. Create a new Key and check “DeviceCheck”

Step 2. "Confirm"

Step 2. “Confirm” Confirmation

Finished.

Understood. Please provide the Markdown paragraphs you want me to translate into English.

After completing the final step, note down the Key ID and click “Download” to save the privateKey.p8 private key file.

At this point, you have prepared all the necessary data for push notifications:

  1. Team ID

  2. Key ID

  3. privateKey.p8

3. Combine JWT (JSON Web Token) format according to Apple specifications

Algorithm: ES256

1
2
3
4
5
6
7
8
9
10
11
12
//HEADER:
{
  "alg": "ES256",
  "kid": Key ID
}
//PAYLOAD:
{
  "iss": Team ID,
  "iat": Request timestamp (Unix Timestamp, EX: 1556549164),
  "exp": Expiration timestamp (Unix Timestamp, EX: 1557000000)
}
//Timestamps must be in integer format!

Get the JWT string of the token: xxxxxx.xxxxxx.xxxxxx

4. Send Data to Apple Server & Receive Response

APNS push notifications have separate development and production environments:

  1. Development environment: api.development.devicecheck.apple.com (For some reason, my development environment always returns failure when sending)
  2. Production environment: api.devicecheck.apple.com

DeviceCheck API offers two operations:
1. Query stored data: https://api.devicecheck.apple.com/v1/query_two_bits

1
2
3
4
5
6
7
//Headers:
Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (Combined JWT string)

//Content:
device_token:deviceToken (Device token to query)
transaction_id:UUID().uuidString (Query identifier, here represented by UUID)
timestamp: Request timestamp (milliseconds), note! This is in milliseconds (e.g., 1556549164000)

Return Status:

[Official Documentation](https://developer.apple.com/documentation/devicecheck/accessing_and_modifying_per-device_data){:target="_blank"}

Official Documentation

Returned Content:

1
2
3
4
5
{
  "bit0": Int: 2 bits data, first bit value: 0 or 1,
  "bit1": Int: 2 bits data, second bit value: 0 or 1,
  "last_update_time": String: "Last modified time YYYY-MM"
}

p.s. You read that right, the last modified time can only be displayed up to year-month

2. Write Stored Data: https://api.devicecheck.apple.com/v1/update_two_bits

1
2
3
4
5
6
7
8
9
//Headers:
Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (Combined JWT string)

//Content:
device_token:deviceToken (Device token to query)
transaction_id:UUID().uuidString (Query identifier, here represented by UUID)
timestamp: Request timestamp (milliseconds), note! This is in milliseconds (e.g., 1556549164000)
bit0: 2 bits, first bit of the data: 0 or 1
bit1: 2 bits, second bit of the data: 0 or 1

5. Get the Result Returned from Apple Server

Return Status:

[Official Documentation](https://developer.apple.com/documentation/devicecheck/accessing_and_modifying_per-device_data){:target="_blank"}

Official Documentation

Response content: None, a 200 status code indicates a successful write!

6. Backend API Returns Results to the APP

The app is complete once it responds to the corresponding states!

Backend Supplement:

I haven’t worked with PHP for a long time. If interested, please refer to the requestToken.php section in the article DeviceCheck added in iOS11.

Swift Demo Example:

Since I cannot provide backend implementation and not everyone knows PHP, here is an example using pure iOS (Swift) that handles backend tasks (JWT creation, sending data to Apple) directly within the app for your reference!

Simulate and execute all content without writing backend code.

⚠ Please note This is for testing demonstration only and is not recommended for production use

Thanks to Ethan Huang for the great CupertinoJWT library that supports generating JWT format content within iOS apps!

Main Demo Program and Interface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import UIKit
import DeviceCheck
import CupertinoJWT

extension String {
    var queryEncode:String {
        return self.addingPercentEncoding(withAllowedCharacters: .whitespacesAndNewlines)?.replacingOccurrences(of: "+", with: "%2B") ?? ""
    }
}
class ViewController: UIViewController {

    
    @IBOutlet weak var getBtn: UIButton!
    @IBOutlet weak var statusBtn: UIButton!
    @IBAction func getBtnClick(_ sender: Any) {
        DCDevice.current.generateToken { dataOrNil, errorOrNil in
            guard let data = dataOrNil else { return }
            
            let deviceToken = data.base64EncodedString()
            
            // In production:
            // POST deviceToken to backend, let backend query Apple server, then return result to app
            
            
            //!!!!!! The following is only for testing/demo purposes, not recommended for production!!!!!!
            //!!!!!!      Do not expose your PRIVATE KEY carelessly    !!!!!!
                let p8 = """
                    -----BEGIN PRIVATE KEY-----
                    -----END PRIVATE KEY-----
                    """
                let keyID = "" // Your KEY ID
                let teamID = "" // Your Developer Team ID :https://developer.apple.com/account/#/membership
            
                let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60)
            
                do {
                    let token = try jwt.sign(with: p8)
                    var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/update_two_bits")!)
                    request.httpMethod = "POST"
                    request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
                    request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
                    let json:[String : Any] = ["device_token":deviceToken,"transaction_id":UUID().uuidString,"timestamp":Int(Date().timeIntervalSince1970.rounded()) * 1000,"bit0":true,"bit1":false]
                    request.httpBody = try? JSONSerialization.data(withJSONObject: json)
                    
                    let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
                        guard let data = data else {
                            return
                        }
                        print(String(data:data, encoding: String.Encoding.utf8))
                        DispatchQueue.main.async {
                            self.getBtn.isHidden = true
                            self.statusBtn.isSelected = true
                        }
                    }
                    task.resume()
                } catch {
                    // Handle error
                }
            //!!!!!! The above is only for testing/demo purposes, not recommended for production!!!!!!
            //
            
        }

    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        DCDevice.current.generateToken { dataOrNil, errorOrNil in
            guard let data = dataOrNil else { return }
            
            let deviceToken = data.base64EncodedString()
            
            // In production:
                // POST deviceToken to backend, let backend query Apple server, then return result to app
            
            
            //!!!!!! The following is only for testing/demo purposes, not recommended for production!!!!!!
            //!!!!!!      Do not expose your PRIVATE KEY carelessly    !!!!!!
                let p8 = """
                -----BEGIN PRIVATE KEY-----
                
                -----END PRIVATE KEY-----
                """
                let keyID = "" // Your KEY ID
                let teamID = "" // Your Developer Team ID :https://developer.apple.com/account/#/membership
            
                let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60)
            
                do {
                    let token = try jwt.sign(with: p8)
                    var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/query_two_bits")!)
                    request.httpMethod = "POST"
                    request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
                    request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
                    let json:[String : Any] = ["device_token":deviceToken,"transaction_id":UUID().uuidString,"timestamp":Int(Date().timeIntervalSince1970.rounded()) * 1000]
                    request.httpBody = try? JSONSerialization.data(withJSONObject: json)
                    
                    let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
                        guard let data = data,let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String:Any],let stauts = json["bit0"] as? Int else {
                            return
                        }
                        print(json)
                        
                        if stauts == 1 {
                            DispatchQueue.main.async {
                                self.getBtn.isHidden = true
                                self.statusBtn.isSelected = true
                            }
                        }
                    }
                    task.resume()
                } catch {
                    // Handle error
                }
            //!!!!!! The above is only for testing/demo purposes, not recommended for production!!!!!!
            //
            
        }
        // Do any additional setup after loading the view.
    }


}

Screenshot

Screenshot

This is a one-time offer redemption; each device can only claim it once!

Full Project Download:

If you have any questions or feedback, feel free to contact me.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.