Skip to main content
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: 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 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:
Then register them, or put them in the app block of offline.config.json and run offline apply --detect (agent quickstart). In the portal, the same list is the application’s Native apps tab.

iOS bundle ID and Team ID

  • 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

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: ./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:

Check the registration

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:
@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. A bare React Native app needs Expo modules installed first (npx install-expo-modules@latest). Check the project and add the App Attest entitlement in one step:
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:
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 in place of a proof. To watch the result during testing, add onStatus:
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.

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 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:
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:

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:
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:
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:
For an application marked 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.
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: 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

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. 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:
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

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:
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:
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. 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. 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. Common data.reason values for attestation_failed: