> ## Documentation Index
> Fetch the complete documentation index at: https://www.offlineprotocol.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These docs target Mesh SDK v0.31.0. Match the installed package and binding before generating code. Start at /getting-started/agents for task-specific reading paths. Apps that call enableTelemetry on 0.26.0 to 0.29.0 should update to 0.30.0 or later: the telemetry uploader trusts only the telemetry endpoint's issuing roots from 0.30.0.
> Call the company and product Offline Protocol, never Offline alone. Current packages: @offline-protocol/mesh-sdk 0.31.0 (React Native), offline-protocol-sdk 0.31.0 on PyPI (Python 3.10 to 3.13; wheels for macOS 14+ arm64, Linux x86_64 and aarch64 with glibc 2.34+, Windows x86_64; other hosts build from source; extras lan and http add LAN discovery, the HTTP front and offline-protocol-verify), the offline-protocol crates 0.31.0 on crates.io, the OfflineProtocolSDK Swift package 0.31.0 (preview, iOS 13+ only), the Android library com.offlineprotocol:offline-protocol-sdk 0.31.0 on Maven Central (preview, minSdk 24), the Java binding com.offlineprotocol:offline-protocol-java 0.31.0 on Maven Central (JDK 22+, native library from the GitHub release's desktop archives), the C header offline_protocol.h with the native library in the GitHub release's desktop archives, the Ruby binding offline_protocol 0.31.0 on RubyGems (Ruby 3.3+, native library from the GitHub release's desktop archives), the browser binding (experimental; the offline-protocol-web crate 0.31.0 on crates.io, with the JavaScript in bindings/web built from the SDK repository, not on npm), the offline_protocol Flutter plugin (preview, not on pub.dev; a path dependency on the SDK repository at tag v0.31.0; MeshSdk carries the React Native module's native surface), the service container image ghcr.io/offline-protocol/offline-protocol-service 0.31.0 (linux/amd64 and linux/arm64), @offline-protocol/id-react 0.2.0, @offline-protocol/id-react-native 0.4.0, @offline-protocol/pol 0.1.2 and @offline-protocol/cli 0.2.9 (account setup commands such as offline apps status, offline apps native and offline apply from 0.3.0). Canonical docs URLs start with https://www.offlineprotocol.com/docs.
> The Mesh SDK runs in a native app or gateway, and from 0.31.0 in a browser through the experimental browser binding (a full node in a Web Worker; see /mesh-sdk/web). The OfflineID SDK for React does not provide browser mesh transport. Local mesh operation does not require a portal account or API key. The Mesh SDK appId is a mesh app identifier the developer chooses, not the portal App ID (app_...) that OfflineID and Proof of Location require.
> In v0.31.0 service RPC bodies are sealed in the 1:1 MLS session once the recipient runs 0.29 or later and the session is confirmed; discovery, the first request to a provider never met, and requests to older peers are signed plaintext, so keep secrets out of service bodies unless the event says encrypted: true. The HTTP front (offline-protocol-service --http) always carries calls inside encrypted direct messages but does not authorize callers. Message delivery, durable local acceptance and backend commit are distinct outcomes. Use the workflow guide for the required application logic.
> Offline Protocol CLI 0.2.9 is on npm (@offline-protocol/cli, command offline). Its local MCP server, named offline-protocol, runs with npx -y @offline-protocol/cli@0.2.9 mcp serve and requires no login or key. Hosted MCP is at https://mcp.offlineprotocol.com/mcp with an app API key in Authorization: Bearer and the matching App ID in x-app-id; organization keys are rejected, and Claude Desktop and Claude.ai cannot send these headers; there, use the public read-only endpoint https://mcp.offlineprotocol.com/public/mcp as a custom connector, or local MCP to scaffold or edit projects. Follow /tools/overview for setup and do not invent commands beyond it. MCP provides integration context and planning, not mesh execution; file-writing tools are local only.
> In v0.31.0 phone Wi-Fi peer streams carry data: Android over Wi-Fi Direct (the SDK forms the group with autoAccept on Android 10+) and over the Wi-Fi network it is on (DNS-SD), iOS over Network framework (LAN or AWDL). Android and iOS meet over that slot only on a shared Wi-Fi network that allows multicast; iOS 0.28 and later does not see iOS 0.27 or earlier over it. Otherwise use BLE or a provisioned relay across platforms. The Swift and Android packages are previews; the Android library is on Maven Central from 0.29.0 only, so never give a Gradle line for an earlier version. The receiver core ACKs before application persistence; use application acceptance for durable workflows.
> OfflineID App IDs are public. A request naming an application needs a credential: an allowed browser origin, an API key in X-Api-Key from a server, or, from an iOS or Android app, an attestation from a build registered to the application (iOS bundle ID and Team ID, Android package and signing certificate SHA-256). Register native apps with offline apps native add or offline apply, pass attestation providers (createExpoAppIntegrityProviders with @expo/app-integrity) to OfflineAppProvider, sign in on a physical device, and check readiness with offline apps status. Follow /getting-started/agent-quickstart and /id-sdk/app-attestation; error responses list the fixing commands in data.next_steps.
> Proof of Location is Sepolia testnet witness evidence, not zero-knowledge proof or proof of presence. The geohash is public onchain. Read /proof-of-location/security before integration.

# App attestation for native sign-in

> Register iOS and Android apps, attach App Attest or Android key attestation to OfflineID sign-ins, and use development mode for simulators and Expo Go.

Your App ID (`app_...`) ships inside every copy of your app and in every request it makes, so anyone can copy it. On its own it proves nothing about who is calling. A request that names your application is accepted when it also carries a credential for it:

| Client | Credential |
| - | - |
| iOS or Android app | An attestation from a build you registered: App Attest on iOS, hardware key attestation on Android |
| Website | An `Origin` on the application's allowed origins (`offline apps origins add`) |
| Your server | An API key of the application in `X-Api-Key` |
| A signed-in native user | The token from an attested sign-in, which carries the attestation result |
| A simulator, emulator or Expo Go | [Development mode](#development-mode-for-simulators-emulators-and-expo-go) on the application, for up to 72 hours at a time |

This page covers the native case. With `@offline-protocol/id-react-native` 0.4.0 and attestation providers configured, the SDK attaches an attestation to every code request, sign-in and new guest session. On a device that cannot attest, it sends the reason instead.

## How native attestation works

1. The SDK asks for a challenge (`POST /users/attestation/challenge` with `X-App-Id`). A challenge works once, belongs to one application and expires after five minutes. A code request and the sign-in that redeems the code each get their own.
2. It builds the binding string `<challenge>.<app id>.sign-in` and hashes it with SHA-256. The binding ties the proof to this request and this application, so a captured attestation cannot be replayed or used for another app.
3. The device signs the hash with hardware-held keys:
   * **iOS (App Attest).** Once per install, the SDK generates an App Attest key and registers it with an attestation bound to `<challenge>.<app id>.register`. Each later request carries an assertion from that key. The server checks Apple's certificate chain, that the key's app ID hash is one of your registered Team ID and bundle ID pairs, and that the assertion counter only increases.
   * **Android (hardware key attestation).** For each request, the SDK generates a fresh Keystore key whose attestation challenge is the SHA-256 of the binding, or the binding's UTF-8 bytes (what `@expo/app-integrity` sends), and sends the key's certificate chain. The server checks the challenge, that the attested package name and signing certificate match a pair you registered, and that the chain ends at Google's hardware attestation roots and is not revoked. A key held in a TEE or StrongBox on a device with a locked bootloader and verified boot gets `hardware` trust; [Android devices without full attestation](#android-devices-without-full-attestation) covers the other devices. No Google account or credential is involved.
4. The request goes out with an `attestation` object in its body. A sign-in whose attestation verified returns a token that carries the result and its trust level, and later requests with that token need nothing more.

Registration uploads nothing secret: a bundle ID, a Team ID, a package name and a certificate fingerprint are all public.

## Register your apps

Register every app you ship before it signs users in. From the project directory, the CLI finds the values for you:

```bash theme={null}
offline apps native detect
```

Then register them, or put them in the `app` block of `offline.config.json` and run `offline apply --detect` ([agent quickstart](https://www.offlineprotocol.com/docs/getting-started/agent-quickstart.md#keep-the-registration-in-the-repository)). In the portal, the same list is the application's **Native apps** tab.

### iOS bundle ID and Team ID

```bash theme={null}
offline apps native add ios --bundle-id com.example.fieldapp --team-id ABCDE12345
```

* **Bundle ID:** exactly as your signed build has it, including case. It is `ios.bundleIdentifier` in `app.json`, or `PRODUCT_BUNDLE_IDENTIFIER` in Xcode.
* **Team ID:** the 10-character Apple Team ID, shown under **Membership details** in your Apple Developer account and as `DEVELOPMENT_TEAM` in the Xcode project. Apps that use a separate App ID prefix register that prefix, shown with the identifier under **Certificates, Identifiers & Profiles**.

Register each bundle ID that signs users in, such as a staging variant, as its own entry.

### Android package and signing certificate

```bash theme={null}
offline apps native add android --package com.example.fieldapp \
  --sha256 <Play App Signing key SHA-256> \
  --sha256 <upload key SHA-256> \
  --sha256 <debug key SHA-256>
```

The package is `android.package` in `app.json`, or `applicationId` in `android/app/build.gradle`. Register the SHA-256 fingerprint of each certificate that signs builds people install:

| Certificate | Signs | Where to find its SHA-256 |
| - | - | - |
| Play App Signing key | Builds installed from Google Play | Play Console, your app, **Test and release > App integrity > App signing**: the app signing key certificate |
| Upload key | Builds you install directly, such as an internal APK | The same page: the upload key certificate. Or `keytool -list -v -keystore <upload keystore>` |
| Debug key | Local debug builds | `keytool -list -v -keystore ~/.android/debug.keystore -storepass android` |
| EAS-managed keystore | EAS builds | `eas credentials`, then choose Android and the build profile |

`./gradlew signingReport` in the `android` directory prints the SHA-256 for every variant the project signs. Remove the debug fingerprint before release if you do not want debug builds to sign users in:

```bash theme={null}
offline apps native remove android --package com.example.fieldapp --sha256 <debug key SHA-256>
```

### Check the registration

```bash theme={null}
offline apps native list
offline apps status
```

`status` lists what each platform has registered, what is missing and the commands that complete it. It also shows whether development mode is on and until when, whether the application is marked production, and the policy for Android devices without full attestation. Changes reach the sign-in service within about a minute.

## Expo and React Native

Install the SDK and `@expo/app-integrity`, which provides App Attest on iOS and hardware key attestation on Android:

```bash theme={null}
npx expo install @offline-protocol/id-react-native@0.4.0 @expo/app-integrity @offline-protocol/core @react-native-async-storage/async-storage@^2.2.0 react-native-svg axios
```

`@expo/app-integrity` is a native module, so it runs in a development build or a release build (`npx expo run:ios`, `npx expo run:android` or EAS Build). Expo Go does not include it; see [Expo Go](#expo-go). A bare React Native app needs [Expo modules](https://docs.expo.dev/bare/installing-expo-modules/) installed first (`npx install-expo-modules@latest`).

Check the project and add the App Attest entitlement in one step:

```bash theme={null}
offline apps native setup-project --write
```

It adds the entitlement to `app.json` and prints the `npx expo install` command for `@expo/app-integrity` and id-react-native 0.4.0 or later; it does not run the package manager. Without `--write` it only reports what it would change.

Create the providers once and pass them to `OfflineAppProvider`:

```tsx theme={null}
import type { ReactNode } from "react";
import { OfflineAppProvider, createExpoAppIntegrityProviders } from "@offline-protocol/id-react-native";

// Maps @expo/app-integrity to the SDK's `ios` and `androidKey` providers. The loader
// runs on first use, so the same code also starts in Expo Go, where the module is missing.
const attestation = createExpoAppIntegrityProviders(() => require("@expo/app-integrity"));

export default function Root({ children }: { children: ReactNode }) {
  return (
    <OfflineAppProvider appId="app_your_application_id" attestation={attestation}>
      {children}
    </OfflineAppProvider>
  );
}
```

An app that never runs in Expo Go can pass the module itself: `import * as AppIntegrity from "@expo/app-integrity"`, then `createExpoAppIntegrityProviders(AppIntegrity)`.

With `attestation` set, `sendCode`, `verifyCode`, Privy sign-in and new guest sessions each fetch a fresh challenge and attach an attestation. A refused attestation is retried once with a new one, and an iOS key the server no longer knows is registered again. On a device that cannot attest, the SDK sends [the reason](#devices-that-cannot-attest) in place of a proof.

To watch the result during testing, add `onStatus`:

```tsx theme={null}
const attestation = createExpoAppIntegrityProviders(AppIntegrity, {
  onStatus: (status) => console.info("Attestation:", status.state, status.message),
});
```

`verified` is the only state that confirms the server checked the proof. The sign-in response carries the result in `data.attestation`, and `onStatus` and the sign-in result expose the session's `trust` (`hardware`, `degraded` or `development`) and `reason`.

### Own native module

If you do not use Expo modules, pass your own implementation of the providers: `ios` with `isSupported`, `generateKey`, `attestKey` and `generateAssertion` over `DCAppAttestService`, and `androidKey` with `isAvailable` and `attest`, which generates a Keystore EC key with `setAttestationChallenge`, returns its certificate chain (each certificate DER, base64, leaf first) and deletes the key. The SDK passes each call the hash (base64 of the 32-byte SHA-256 of the binding string) and the binding string itself. Use the hash as App Attest's `clientDataHash`; on Android, use either the hash's 32 bytes or the binding's UTF-8 bytes as the attestation challenge.

When `isSupported` or `isAvailable` returns `false`, or `attest` throws, the SDK reports that the device cannot attest. The optional `isSimulator` hint (`() => boolean | Promise<boolean>`) marks a simulator or emulator.

### Handle refusals

From 0.4.0, `sendCode` resolves to `true` and throws an `OfflineIdError` when the server refuses the request or does not answer. `verifyCode` resolves to `null` for a wrong or expired code and throws an `OfflineIdError` for anything else. The error carries `code`, `status`, `message`, `missing`, `nextSteps`, `docs`, `appId` and, for a refused attestation, `reason`. The built-in login sheet shows its message.

```tsx theme={null}
import { isOfflineIdError, useAuth } from "@offline-protocol/id-react-native";

function useRequestCode() {
  const { sendCode } = useAuth();
  return async (email: string) => {
    try {
      await sendCode(email);
    } catch (error) {
      if (isOfflineIdError(error)) {
        console.warn(error.code, error.missing, error.nextSteps);
      }
      throw error;
    }
  };
}
```

### Expo Go

Expo Go does not contain `@expo/app-integrity`, and neither does a development build made before you installed it. The providers then report `unavailable: "expo_go"`. With [development mode](#development-mode-for-simulators-emulators-and-expo-go) on, sign-in works. With it off, the server refuses the request and the SDK throws an `OfflineIdError` with code `development_build_required`, whose `nextSteps` carry these commands. Install the module and build the app:

```bash theme={null}
npx expo install @expo/app-integrity
npx expo run:ios
npx expo run:android
```

`eas build --profile development` makes the same development build on EAS. To keep testing in Expo Go or a simulator, turn on development mode instead:

```bash theme={null}
offline apps native dev-mode on --hours 24
```

## Enable App Attest on iOS

App Attest needs the App Attest entitlement in the signed build. In an Expo app, add it to `app.json` and rebuild the native project:

```json theme={null}
{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.example.fieldapp",
      "entitlements": {
        "com.apple.developer.devicecheck.appattest-environment": "production"
      }
    }
  }
}
```

`offline apps native setup-project --write` makes the same change. In Xcode, open the target's **Signing & Capabilities**, choose **+ Capability** and add **App Attest**. Then set the environment to `production` for TestFlight and App Store builds.

## Xcode development builds

Builds you run on a device from Xcode, or with `npx expo run:ios`, attest in Apple's development environment. Accept them for your application while you develop:

```bash theme={null}
offline apps native dev-builds on
```

Turn it off with `offline apps native dev-builds off` when only TestFlight and App Store builds should sign users in. In `offline.config.json` the setting is `app.native.allowDevelopmentBuilds`.

On Android, a debug build is signed with your debug key: register its fingerprint as shown above.

## Devices that cannot attest

When attestation providers are configured but the device cannot attest, the SDK sends the reason in place of a proof on code requests, sign-ins and new guest sessions:

```json theme={null}
{
  "email": "person@example.com",
  "attestation": { "platform": "android", "unavailable": "emulator" }
}
```

| `unavailable` | Sent when | Development mode on | Development mode off |
| - | - | - | - |
| `simulator` | iOS: App Attest is not supported, as in the iOS Simulator | Accepted, `development` trust | 403 `development_mode_required` |
| `emulator` | Android: the `isSimulator` hint reports an emulator | Accepted, `development` trust | 403 `development_mode_required` |
| `expo_go` | The native module is missing: Expo Go, or a build made before it was installed | Accepted, `development` trust | 403 `development_mode_required` |
| `unsupported` | Android: the device produces no key attestation | Accepted, `development` trust | Accepted with `degraded` trust, unless the application [refuses degraded devices](#refuse-degraded-devices) |
| `keystore_error` | Android: the Keystore failed while making the key | Accepted, `development` trust | Accepted with `degraded` trust, unless the application refuses degraded devices |

For an application [marked production](#mark-an-application-as-production), development mode is refused, so `next_steps` in `development_mode_required` points to a separate development application.

## Development mode for simulators, emulators and Expo Go

Development mode lets an application sign users in from devices that cannot attest: the iOS Simulator, Android emulators and Expo Go. It is set per application, for 1 to 72 hours, and turns itself off when the time runs out.

```bash theme={null}
offline apps native dev-mode on --hours 24
offline apps native dev-mode off
```

From the local MCP server, call `set_development_mode` with `{ "enabled": true, "hours": 24 }`. In the portal, the switch is on the application's **Native apps** tab. `offline apps status` shows when development mode ends, and suggests it when the project targets a simulator.

While development mode is on:

* Email code requests, sign-ins and new guest sessions are accepted without an attestation. A request that carries an attestation is still checked as usual.
* Text-message codes still need an attestation. A development session that asks for one gets 403 `attestation_required`; email codes work.
* Each application can complete 200 development sign-ins per hour. Past that, the sign-in service answers 429 `development_sign_ins_limited`.
* Sessions are marked development: the token carries `app_trust: "development"`, and the sign-in response has `data.attestation.trust: "development"`. They work only while development mode lasts. When it ends, the gateway refuses them with 401 `app_credentials_required` and `data.missing: "development_mode"`, and the user signs in again from an attested build.

Development mode is refused for an application marked production.

Every change is recorded with the developer and the time. `offline apps native history` (local MCP: `attestation_history`) lists them, newest first:

| Event | Details |
| - | - |
| `development_mode_enabled` | `hours`, `until` |
| `development_mode_disabled` | `reason`: `turned_off`, or `production` when marking the application production ended it |
| `production_marked`, `production_unmarked` | |
| `android_degraded_changed` | `from`, `to` (`allow` or `refuse`) |

`offline apps status --json` reports the current state in `development` (`active`, `until`, `available` and the `command` that turns it on), `production` and `androidDegraded`.

## Mark an application as production

```bash theme={null}
offline apps native production on
```

An application marked production cannot turn on development mode, and marking it ends development mode at once. `offline apps native production off` removes the mark. The local MCP tool is `set_production`. Test simulators and Expo Go against a separate development application with its own App ID.

## Android devices without full attestation

Some Android phones cannot prove their state fully: phones without secure hardware for keys, phones with an unlocked bootloader and phones whose attestation key was revoked. By default an application accepts their sign-ins with `degraded` trust, within hourly caps, when the attestation is otherwise valid: well-formed, bound to this one-time challenge and App ID, and from a registered package and signing certificate.

| Device state | Result |
| - | - |
| Key in a TEE or StrongBox, locked bootloader, verified boot, chain to Google's hardware roots | Accepted, `hardware` trust |
| Key held in software, with no TEE or StrongBox | Accepted, `degraded` trust |
| Unlocked bootloader, or unverified or self-signed boot | Accepted, `degraded` trust |
| Chain that does not end at Google's hardware roots, as from a software keystore | Accepted, `degraded` trust |
| Attestation certificate expired, including an expired factory chain, or revoked | Accepted, `degraded` trust |
| No key attestation at all (`unavailable: "unsupported"` or `"keystore_error"`) | Accepted, `degraded` trust |
| Wrong or reused challenge, another App ID, an unregistered package or signing certificate, or a malformed chain (`challenge_invalid`, `nonce_mismatch`, `app_not_registered`, `package_mismatch`, `signing_certificate_mismatch`, `malformed`, `no_application_id`) | Refused, 403 `attestation_failed` |
| Emulator | Accepted only in [development mode](#development-mode-for-simulators-emulators-and-expo-go) |

The chain is checked signature by signature, and its root is matched to Google's attestation roots by key. Factory-provisioned phones whose batch certificate is not marked as a CA, or whose chain carries the expired 2016 copy of Google's root, verify like any other.

Degraded sign-ins are capped at 500 per application per hour and 30 per hour from one client; the per-client cap leaves room for many phones behind one carrier network address. Past either cap, the sign-in service answers 429 `degraded_sign_ins_limited`. Email codes are sent on a degraded attestation. A text-message code is never sent to a degraded or development session: it gets 403 `attestation_required`.

The trust level is on the session and in the sign-in response. The token carries `app_auth` (`attestation` for both `hardware` and `degraded` trust, `development` in development mode) and `app_trust`. The response carries `data.attestation`:

```json theme={null}
{
  "message": "OK",
  "data": {
    "user": { "id": 17, "email": "person@example.com", "phone_number": null, "username": null, "created_at": "2026-11-04T00:00:00Z" },
    "token": "<token>",
    "attestation": { "status": "degraded", "trust": "degraded", "reason": "bootloader_unlocked" }
  }
}
```

`status` is `verified`, `degraded` or `development` for an accepted native sign-in. `trust` is `hardware`, `degraded` or `development`. `reason` names the device state that lowered trust: `software_attestation`, `bootloader_unlocked`, `boot_not_verified`, `untrusted_root`, `no_root_of_trust`, `certificate_expired`, `revoked_certificate` or `device_cannot_attest`. Your app and backend can use it to ask for more before sensitive actions, such as payouts or account changes.

### Refuse degraded devices

```bash theme={null}
offline apps native degraded refuse
```

With `refuse`, Android sign-ins need `hardware` trust; the others are refused with 403 and the device state in `data.reason`. That locks out phones without secure hardware, with an unlocked bootloader or with a revoked attestation key: their users cannot sign in from your app at all. `offline apps native degraded allow` restores the default. The local MCP tool is `set_android_degraded` with `{ "allow": false }`.

## Test from a browser

To test sign-in from a browser, such as the web build of an Expo app, add the dev server's origin:

```bash theme={null}
offline apps origins add http://localhost:8081
```

A browser request from an allowed origin needs no attestation.

## Apps on earlier SDK versions

Versions before 0.4.0 send no attestation with the code request. For an application with at least one registered native app, an email code request without an attestation is still accepted, up to 300 per application per hour; past that it answers 429 `code_requests_limited`. The compatibility path covers email codes only. The sign-in that redeems the code always needs a verified attestation, so configure attestation providers on any version, and upgrade to 0.4.0 to attest code requests and lift the cap.

## Direct HTTP

Clients that call the API without the SDK send the same `attestation` object in the body of `POST /auth/send-code` and `POST /auth/verify-code`, each built on its own challenge:

```json theme={null}
{
  "email": "person@example.com",
  "attestation": {
    "platform": "ios",
    "challenge": "<challenge>",
    "keyId": "<App Attest key id>",
    "assertion": "<base64 assertion>"
  }
}
```

On Android, send `"platform": "android"`, the `challenge` and `certificateChain` (each certificate DER, base64, leaf first) of a fresh key whose attestation challenge is the SHA-256 of `<challenge>.<app id>.sign-in` or that string's UTF-8 bytes. Register an iOS key once per install with `POST /users/attestation/ios/register` and `{ "keyId", "attestation", "challenge" }`, using a challenge bound to `<challenge>.<app id>.register`.

On a device that cannot attest, send `{ "platform": "ios", "unavailable": "simulator" }` or the Android equivalent, as described in [devices that cannot attest](#devices-that-cannot-attest). The sign-in response reports the outcome in `data.attestation`: `status`, `trust` and, when trust is lowered, `reason`.

## Errors

The gateway answers 401 with the code in the top-level `error` string. The sign-in service answers 403 or 429 with it in `error.code`. Both put `app_id`, `missing`, `next_steps` and `docs` in `data`, and some add `reason`.

| Code | HTTP | Meaning | Fix |
| - | - | - | - |
| `app_credentials_required` with `missing: "native_registration"` | 401 | The application has no native app registered. | `offline apps native add ios ...` and `offline apps native add android ...`, or `offline apply --detect` |
| `app_credentials_required` with `missing: "attestation"` | 401 | Native apps are registered, but the request has no attestation. | Pass attestation providers to `OfflineAppProvider`. |
| `app_credentials_required` with `missing: "credential"` | 401 | No allowed origin, attested token or API key. | `offline apps origins add <origin>`, or an API key from your server |
| `app_credentials_required` with `missing: "development_mode"` | 401 | The session came from development mode, which has ended. | Sign in again from an attested build, or `offline apps native dev-mode on --hours 24`. |
| `invalid_app` | 401 | The App ID does not exist or is disabled. | `offline apps list` |
| `origin_not_allowed` | 403 | The browser origin is not on the application's list. | `offline apps origins add <origin>` |
| `attestation_required` | 403 | The request had no attestation, or the one it carried did not verify. | `offline apps status`, then sign in from a registered build on a physical device. |
| `attestation_failed` | 403 | The attestation did not verify. `data.reason` says why. | See the reasons below. |
| `attestation_key_unknown` | 403 | The server does not know this install's App Attest key. | The SDK registers a new key and retries. |
| `attestation_not_configured` | 403 | The request carried an attestation, but no native app is registered to check it against. | `offline apps native add ...` |
| `development_mode_required` | 403 | The device cannot attest (simulator, emulator or Expo Go) and development mode is off. | `offline apps native dev-mode on --hours 24`, or a development build on a physical device. For an application marked production, use a separate development application. |
| `code_requests_limited` | 429 | The hourly cap on email codes without an attestation is reached. | Upgrade to 0.4.0 with attestation providers. |
| `development_sign_ins_limited` | 429 | The application reached 200 development sign-ins this hour. | Sign in from an attested build, or wait for the next hour. |
| `degraded_sign_ins_limited` | 429 | Android sign-ins with `degraded` trust reached the cap for the application (500) or for this client (30) this hour. | Wait for the next hour. Phones with a TEE or StrongBox and a locked bootloader are not capped. |

The SDK raises one more code itself: `development_build_required`, when the server refuses a request and `@expo/app-integrity` is missing from the build. Its `nextSteps` are the commands under [Expo Go](#expo-go).

Common `data.reason` values for `attestation_failed`:

| Reason | Fix |
| - | - |
| `app_not_registered` | Register the bundle ID with its Team ID. |
| `package_mismatch` | Register the package name of this build. |
| `signing_certificate_mismatch` | Add the SHA-256 of the certificate that signed this build, often the Play App Signing key or the debug key. |
| `development_not_allowed` | `offline apps native dev-builds on`, or sign in from a TestFlight or App Store build. |
| `malformed`, `no_application_id` | The Android certificate chain could not be read, or it names no application. Send the full chain of a fresh Keystore key, leaf first. |
| `software_attestation`, `bootloader_unlocked`, `boot_not_verified`, `untrusted_root`, `no_root_of_trust`, `certificate_expired`, `revoked_certificate`, `device_cannot_attest` | Device states that give `degraded` trust. They refuse a sign-in only when the application refuses degraded devices; `offline apps native degraded allow` accepts them. |
| `challenge_invalid`, `nonce_mismatch` | The challenge expired or was used twice. Request a new one for each attempt. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.