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 categoryWhy it needs stronger handling
Notification-setting changesThey create or clean up notification settings on the server.
Telemetry collectionThey 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.

SituationWhat changes after App AttestStill needed
Calls that mimic API shape outside the appWithout 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 bodyThe core request content is verified together.Notification-setting protection, subscription cleanup, audit
Replay of an old requestOne-time challenges and counter signals such as sign count participate.Clock handling, storage incidents, retry policy
Environment without App AttestFallback is treated as limited authority.Fail-closed policy for distribution builds, development-token management
Compromised device or broker-style callsThe 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

Share

Image preview