Palbase
Sign inGet started

iOS SDK

App Attest

App Attest verifies requests from your Apple apps to your user backend. Its policy is a server-side decision, scoped to one Environment. The SDK handles enrollment and retries; your app handles errors when device verification cannot complete.

Configure Authentication → Settings → Device trust in Studio, or use the same environment policy through the CLI:

palbase auth settings get
palbase auth settings set --json '{"app_attest_mode":"monitor","app_attest_apple_apps":["ABCDE12345.com.example.app"]}'

The administration endpoint is PUT /admin/auth-settings. Read GET /admin/auth-settings first and include its ETag in the write's If-Match header. A missing revision returns 428; settings changed by another administrator return 412. Review the current settings before retrying. Studio and palbase auth settings set manage the revision for you. Omitted fields retain their current values. The modes are off, monitor, and enforce. Monitor records observations without rejecting requests. Enforce requires a non-empty Apple app list and a configured challenge secret; the API rejects an incomplete enforcement configuration.

The deployed backend runtime must include the App Attest gate for this policy to protect user backend calls. A saved policy alone does not verify the deployed runtime or a physical device. Check the app in Monitor before requiring attestation.

What your code does

Handle the two error cases below. There is no client-side switch to enable the server policy:

import Palbe

do {
    let todos = try await pb.todos.list()
} catch let error as BackendError {
    switch error {
    case .attestationUnavailable(let failure):
        // This device can't attest (most commonly: the Simulator).
        // The server refused the call and the SDK won't retry.
        // failure.reason explains why.
        showBlockingNotice(failure.reason)
    case .appAttestRequired:
        // The server demanded attestation and the SDK's automatic
        // enrollment + retry didn't satisfy it.
        showBlockingNotice("Device verification failed. Please try again.")
    default:
        handle(error)
    }
}

The flow the SDK already implements

  1. A user backend call goes out plain first. No attestation headers on the first attempt, so a call to a backend that does not demand attestation pays zero attestation cost.
  2. The backend demands attestation. It answers 401 with the error code app_attest_required.
  3. Enrollment on demand. The SDK generates a hardware-backed key in the device's Secure Enclave, has Apple attest it, and registers the device over POST /auth/devices/challenge and POST /auth/devices/attest/ios. Both identifiers — the Secure Enclave keyId and the dev_-prefixed device id — are persisted in the Keychain, so this happens once per install, not per call. The private key never leaves the Secure Enclave.
  4. Retry, exactly once. The SDK generates a fresh assertion, bound to a server challenge and to a hash of this exact request so it cannot be replayed or moved to another request, attaches it, and retries the original call with the same idempotency key — so a non-GET call is never double-executed.

Enforcement scope

SurfaceAttested
Generated user backend calls (pb.todos.list(), …)Yes
pb.call(...) to your backendYes
Auth and device enrollmentNo — bootstrap must stay reachable
pb.upload(...) / a generated @Upload callNo — uploads go straight to Storage
Storage's own write of the file bytesNo — Storage is exempt
Palbase module routes (Messaging, Flags, Realtime, …)No — module routes are exempt
pb.analytics.* event ingestionNo — Analytics is exempt

The gate lives in your backend's runtime, and the edge decides what reaches it: platform prefixes (/auth, /v1, /admin, /realtime, …) are routed to the platform, and everything else to your runtime. That routing IS the exemption list — there is no second list to keep in step with it.

pb.upload(...) is on the platform side for a concrete reason: an upload goes to /v1/storage, and the whole point of a signed upload is that a large file does not stream through your runtime. This row said "Yes" until 2026-09-01; it was wrong, and it contradicted the row directly below it.

See Calling Your Backend and Uploads for the call surfaces themselves.

On the wire

The SDK attaches the device identity, server challenge, attestation payload, and signature to the retry. You do not construct these headers yourself.

The two errors

Both arrive as BackendError cases:

CasePayloadWhen
.attestationUnavailable(AttestationFailure)reason: StringThe device cannot attest at all, or enrollment failed — the SDK refuses to retry
.appAttestRequired(AuthFailure)requestId: String?The server demanded attestation and the automatic enroll-and-retry still came back 401

.attestationUnavailable is deterministic for a given device: retrying will not help, so treat it as a blocking state rather than a transient failure.

Note: AppAttestError itself is a separate public type and does not conform to PalbaseError — it has no code, statusCode or requestId. What a caller sees is BackendError.attestationUnavailable, whose reason comes from AppAttestError.backendReason. error.code, error.statusCode and error.requestId work uniformly on both BackendError cases.

It never runs off a physical iOS device

Warning: App Attest requires a supported physical iPhone or iPad with a Secure Enclave. It does not work in the Simulator, and it does not work on macOS or Mac Catalyst — the SDK does not even construct an attestor there. This is not a Simulator quirk; it is "anything that is not a physical iOS device".

The reason string the SDK ships is explicit about it:

App Attest is unavailable on this device — it requires a supported physical iPhone/iPad with a Secure Enclave and does not work on the Simulator or macOS. Either run on a physical device, or disable App Attest enforcement for this app/environment binding in Studio.

Environment policy

  • off bypasses the gate; monitor observes; enforce requires valid attestation for user backend calls.
  • An Environment has one policy and an allowed Apple app list. The policy is not a client flag.
  • Changes are read by the runtime on its next policy poll. No code deploy is required to write settings.
  • Use a separate Environment with enforcement off for Simulator work, and a physical device to validate enforcement.