Not Simply Trusting API Requests That Come From Outside the App
I used App Attest to check app-origin trust and treated state-changing requests with extra care.
Hangangjari is a login-free app. Users do not create accounts before viewing Han River park parking lots, events, weather, and notifications. This structure is easy to use and keeps privacy explanations simple.
But from the server’s point of view, one question remains. How can it distinguish whether an HTTP request really came from the App Store iOS app, or from something outside the app that shaped a similar request?
A mobile app API is not a door visible only inside the app. If the API address and payload shape are known, a request with the same shape can be made outside the app too.
Putting an API key in the app does not make it a hidden server secret. That key exists on the device of anyone who installed the app.
So Hangangjari added App Attest. But it does not create an App Attest assertion for every request.
First, the server checks whether the request came from an app instance. For some requests that can change server state or operational metrics, it also checks body integrity one more time.
This is not a feature that removes all attacks
Adding App Attest does not make external attacks disappear. The network still carries HTTP requests, and attackers can observe public APIs, analyze old apps, or build automated callers.
So I did not treat this feature as a “hack prevention switch.” The more accurate goal was to let the server answer these questions when receiving requests.
- Did this request start from an app instance we distributed?
- Is this app instance still using the key and verification material the server registered once?
- Was the core content of a state-changing request modified in the middle?
- Are we mistaking a weaker fallback signal for the same level of authority?
For example, suppose someone repeatedly calls the first-screen API from outside the app. This cannot be made completely impossible.
But if the server requires an app token, a request that merely mimics the address and JSON shape from outside the app is harder to treat like a normal app request.
Another example is notification-setting changes. These requests create server state. If the body changes here, settings the user did not intend can be created.
So these requests do not stop at the app-instance signal. They also verify the core request content.
First, the app instance is registered
The iOS app does not start by making the server API trust it. A security module in the app checks App Attest availability, and if it is supported, creates a key on the device.
The app does not receive the private key directly. It receives only a key identifier, and stores it in secure device storage only after registration succeeds.
The registration flow starts with a server challenge.
sequenceDiagram autonumber participant App as iOS app participant Apple as Apple App Attest participant API as Hangangjari API participant DB as App-instance store App->>API: Request registration challenge API-->>App: One-time challenge App->>Apple: Create key attestation Apple-->>App: Attestation object App->>API: Register key ID and attestation object API->>API: Verify attestation API->>DB: Store app-instance verification material API-->>App: Short-lived app access token
The server does not trust the attestation object as-is. It checks that the Apple App Attestation certificate chain, the server-issued challenge, app identity context such as Team ID and Bundle ID, and the key identifier all match.
This verification must happen on the server. If the app decides “this is valid” inside the app, a modified app can also modify that decision.
The important part of App Attest is not only calling the iOS API. It is the server storing the public key and checking that the same key is used in later requests.
General requests are grouped behind short-lived tokens
After registration, the iOS app does not perform attestation on every request. It refreshes an app access token with the registered key, and the server issues a short-lived token.
Hangangjari’s app access token has a short lifetime. When it is close to expiring, the iOS app receives a new token, and general app API requests include it in a header.
flowchart LR Request["General app API request"] --> Header["App access token header"] Header --> Verify["Verify signature and expiry"] Verify --> Instance["Look up app instance"] Instance --> Context["Check registration context and state"] Context --> API["Handle API request"]
This token is not a user authentication token. It does not say who the user is. It is a signal that the request came from an app instance the server previously registered.
That distinction mattered. Hangangjari is login-free, and many read APIs return public data. There is no need to identify a user. But there is also no need to give “automated calls made outside the app” and “normal requests from the app we distributed” the same trust level.
After verifying the token, the server checks the app instance in DB again. If the token’s app instance does not match the stored registration context, or if the instance is inactive, the request is rejected.
This weakens simple copy requests. Matching the URL and body shape is not enough. A short-lived token from the server is required, and that token must continue from a registered App Attest key.
State-changing requests bind the body too
Every request could carry an App Attest assertion. I chose not to do that.
Assertions do not round-trip to Apple servers, but they still perform cryptographic work each time. The more important reason was operational interpretation. If every poll, read, and telemetry request carries the same weight of proof, it becomes less clear which requests are truly risky.
In Hangangjari, request proof is attached only to write requests that can create state or pollute operational metrics. In a public post, the kind of request matters more than exact endpoint names.
| Request category | Why it needs stronger handling |
|---|---|
| Notification-setting changes | They create or clean up notification settings on the server. |
| Telemetry collection | They affect events and performance signals used as operational metrics. |
Request proof is per-request verification. The token asks, “is this a registered app instance?” Request proof asks, “did this key sign while seeing the core content of this request?”
The server and app combine a server-issued challenge with a summary of the request body to create the proof challenge.
That result is signed as an App Attest assertion. The server checks the challenge and assertion, then verifies it again against the stored verification material.
This structure makes body tampering easier to reason about. Suppose a normal app creates a notification-setting change request, but the body changes in transit to a different setting.
If the server includes a body summary in the proof material, the old assertion no longer matches the changed body.
Replay protection follows the same principle. The server challenge is one-time, and counter signals such as assertion sign count need to continue from previous requests. Resending a previously captured request is caught in this section.
Fallback does not have the same authority
App Attest is not available in every iOS execution environment. Development environments, some runtime environments, and extension contexts can differ.
The Hangangjari iOS app separates development policy from distribution-build policy.
During development, local checks need a way not to block iteration. In distribution builds, App Attest failure does not silently pass.
DeviceCheck fallback also exists. If App Attest is impossible and DeviceCheck is available, the app can receive an app access token with a limited fallback signal.
The server distinguishes tokens issued from fallback from App Attest tokens.
But it does not treat them at the same level. Fallback is a way to keep general app requests alive, not a bypass that opens requests requiring stronger verification.
flowchart TD
Signal["App-origin signal"] --> Strong{"App Attest available?"}
Strong -->|"Yes"| AppAttest["General requests + strong request verification"]
Strong -->|"No"| Fallback["Limited fallback"]
AppAttest --> Allow["Allow according to request type"]
Fallback --> Limited["Allow only limited general app requests"]
This distinction matters when thinking about external attacks. Fallback is a device for preserving normal users, not a way to grant the same authority with weaker verification.
What it blocks and what remains
It would be wrong to say App Attest blocks every attack. So Hangangjari separates what changes from what still needs attention.
| Situation | What changes after App Attest | Still needed |
|---|---|---|
| Calls that mimic API shape outside the app | Without an app-origin signal, they are harder to treat as normal app requests. | IP rate limits, abuse metrics, cache protection |
| Attempt to modify state-changing body | The core request content is verified together. | Notification-setting protection, subscription cleanup, audit |
| Replay of an old request | One-time challenges and counter signals such as sign count participate. | Clock handling, storage incidents, retry policy |
| Environment without App Attest | Fallback is treated as limited authority. | Fail-closed policy for distribution builds, development-token management |
| Compromised device or broker-style calls | The server gains more risk signals. | Avoid overconfidence in complete blocking |
The last row is especially important. Apple also does not describe App Attest as a tool that perfectly identifies compromised operating systems.
App Attest is a risk signal. It raises attack cost and helps the server classify strange requests more accurately. It does not replace rate limits, log masking, token expiry policy, key recovery, or operational metrics.
Additional risk signals are left for the next step
Apple lets the server send App Attest receipts back to Apple and receive fraud metrics. This can be used as a risk signal for how many attested keys were created for a specific device/app pair in the last 30 days.
For Hangangjari, I thought it was more appropriate to build a baseline and watch spikes before using this as a direct blocking rule.
Reinstalls, restores, and device changes can also increase key count. Blocking users based on one metric can incorrectly block normal users.
At the current stage, I first closed the core path the server can verify, and left broader risk judgment for later.
The trust question came before the security feature
At first, it is easy to see App Attest as an “iOS security feature.” After implementing it, the more important questions were different.
Is it enough to verify only the app instance for this request? Which requests need the body signed too? How far should fallback be allowed? When verification fails, should we preserve user experience or protect server state?
Hangangjari groups general app API requests behind short-lived tokens. Requests that create state or can pollute quality signals, such as notification settings and telemetry, receive request proof.
DeviceCheck fallback remains, but it does not open requests that require stronger verification.
This choice does not remove attacks. It makes the server trust requests more carefully. A login-free app does not need to leave the whole API undefended.
But before deciding what to trust, I had to decide what not to simply trust.
The Apple documents I referenced were DeviceCheck, DCAppAttestService, Validating apps that connect to your server, and Assessing fraud risk.
Share
No comments yet. You can leave the first one.
Pending review