# Octet SDK Documentation — full text Generated from the canonical markdown in octetproof/octet-site (Mobile SDK + Octet Browser + LFA + Magistrate). =================== MOBILE SDK =================== # Octet Mobile SDK Proof of location for iOS and Android apps. ## What the SDK does Your app calls `sdk.loc.isWithin(region: …)` and gets back an [`OctetVerdict`](/api-reference/octet-verdict). Its result is `YES` (the device is inside the region), `NO` (the device is outside it), or `INDETERMINATE` (the SDK cannot answer, and a reason code says why). A `YES` or `NO` carries a signed proof that anyone can verify offline. ## Starting points
iOS Quick StartInstall with Swift Package Manager and get a first verdict Android Quick StartInstall from Maven and get a first verdict Proof of LocationWhat a proof records and how the SDK produces it What users seeWhat the person sees in your app while the SDK runs On-device VerificationVerify a proof on the device, offline, with Octet.verify API ReferenceOne page per symbol, Swift and Kotlin side by side Upgrading from 2.xChanges from 2.0 to 3.0, breaking changes first Migrating from 1.xChanges from 1.x to 2.0, breaking changes first Troubleshooting & FAQIndexed by what you see in verdict.reason or LicenseError
## Access Create an account and register your app once at [sdk.octetproof.com/signup](https://sdk.octetproof.com/signup) (see [Prerequisites](/getting-started/prerequisites)). Use of the SDK is governed by the [Octet SDK License Agreement](https://octetproof.com/terms/mobile/). Pricing is at [octetproof.com/pricing](https://octetproof.com/pricing). :::note[SDK 3.0.0] These docs describe SDK 3.0.0, a major release. [What's new in 3.0](/whats-new) lists the changes from 2.0. The 2.0 docs stay at [/docs/v2.0/](/docs/v2.0/) and the 1.x docs at [/docs/v1.2/](/docs/v1.2/). ::: _Source: https://octetproof.com/docs/mobile/_ --- # What's new in 3.0 SDK 3.0.0 for iOS and Android is a major release. This page covers what changes for your integration. Read [Upgrading from 2.x](/getting-started/upgrading-from-2x) for the steps. The full release notes are in the [`octet-sdk-ios`](https://github.com/octetproof/octet-sdk-ios/blob/main/CHANGELOG.md) and [`octet-sdk-android`](https://github.com/octetproof/octet-sdk-android/blob/main/CHANGELOG.md) changelogs, and the 2.0 docs stay at [/docs/v2.0/](/docs/v2.0/). ## What you may need to change - **Usage reporting is always on.** The SDK sends one usage record per device per day. It carries no location, no proof data and no user identity, and it never delays a proof. Setting `creditServiceUrl` no longer turns it off. See [Metrics and usage reporting](/concepts/metrics). - **A pinned `claimedRegion` must be an assigned ISO 3166 code.** Any other value produces no proof. - **Android: with a debugger attached, the SDK refuses a proof (`TAMPERING`)**, as it already did on iOS. Debugging with a sandbox session (`sandboxBypassToken` set) still works. - **Android: `BootstrapReason` has a new case, `NewDevicesBlocked`.** A `when (reason)` without an `else` branch no longer compiles. Add the case or an `else` branch. - **Android: recompile against 3.0.** `AdvancedConfig` and `LicenseStatus` have new constructor parameters. Source using named arguments compiles unchanged. - **The first proof after `Octet.start` can take up to 30 seconds** while the SDK finishes starting, instead of returning `noFix`. If your app times out its first call, allow for this. - **If you verify proofs yourself with `octet-verify`, use 1.5.0 or later.** - **If you turned on certificate pinning with 2.0, upgrade to 3.0**, which carries updated pins. ## New - **Gateway routing.** `TransportPolicy` (`OctetConfig.advanced.transport`) can send all of the SDK's network calls through a gateway you run, or through Octet's hosted gateway, so your app talks to one domain. Direct mode, the default, is unchanged. See [Gateway routing](/concepts/gateway-routing). - **Assurance tier on every proof.** `LocationProof.spoofingVerdict` (`spoofing_verdict` in the proof JSON) says how strongly the location was corroborated. Use it to decide what a `YES` is worth. See [`OctetVerdict`](/api-reference/octet-verdict#the-assurance-tier). - **Require a tier when you verify.** `VerifyOptions.requireVerdict` fails a proof below the tier you ask for, matching `octet-verify --require-verdict`. See [`Octet.verify`](/api-reference/octet-verify). - **Opt-in `PLAUSIBLE` containment.** With `OctetConfig.advanced.acceptPlausibleContainment` on, `contains(...)` and disc queries can answer from a `PLAUSIBLE` proof. It is off by default. See [Predicates](/api-reference/predicates#plausible-containment). - **A typed error when your account takes no new devices.** `BootstrapReason.newDevicesBlocked` carries an `upgradeUrl`. Open it exactly as received. Devices that already activated keep working. See [License Types](/api-reference/license-types#bootstrapreason). ## React Native A React Native wrapper is in progress and not yet published. See [React Native](/getting-started/react-native). _Source: https://octetproof.com/docs/mobile/whats-new/_ --- # Prerequisites Three things every consumer app needs before calling `Octet.start(...)`: 1. A **registered app** so the SDK can bootstrap by attestation. 2. **Privacy declarations** in your app bundle (iOS `Info.plist`) or runtime permission grants (Android). 3. A **minimum platform version**. --- ## 1. Register your app Since 2.0, the SDK gets its license by proving the app's identity at first launch, not by carrying a key. Sign up for an Octet account at [sdk.octetproof.com/signup](https://sdk.octetproof.com/signup), then register your app identities at [sdk.octetproof.com/apps](https://sdk.octetproof.com/apps). The link in your onboarding email lands there. In the **Add an app identity** dialog: - **iOS:** your bundle id and Apple Team ID. - **Android:** your package name and signing-certificate SHA-256. Use the dialog's separate **Debug certificate** and **Release certificate** fields: a Debug cert registers a sandbox identity, a Release cert a release one. Avoid the single-certificate quick-register box on the signup page for Android, which writes one cert to both slots, so a debug build would take a paid release slot. The first valid attestation from your app binds the identity to your account. A developer dashboard at `dashboard.octetproof.com` is planned. Until it launches, register apps at `/apps`. See [Attested bootstrap](/concepts/attested-bootstrap) for how the handshake works. **iOS.** Enable the **App Attest** capability for your app id. The SDK produces an App Attest attestation at first launch and an assertion on later launches. **Android.** The SDK uses hardware **key attestation**: the device's TEE or StrongBox certificate chain is verified against Google's hardware-attestation root. No Google Cloud project is required. (The backend also checks Google's public keybox-revocation list to reject revoked devices, an unauthenticated fetch that needs no project or credentials.) **Local development, CI, and simulators.** An emulator, a CI runner, and a locally built debug app have no hardware attestation. Use a **sandbox bypass token**. Register your Debug certificate under an app at `/apps` (the Debug field creates a sandbox identity), then mint a sandbox token for it there, choosing a 7-, 14-, 30-, or 90-day lifetime. It is revocable. Pass the token as `OctetConfig.sandboxBypassToken`. It looks like `octet_sbx_…`. A store or release build cannot use one: the SDK refuses a sandbox token outside a debug or simulator build, and the server refuses it for a release app. For billing, the SDK sends one usage record per device per UTC day, with no identity and no coordinates. Aggregate usage counters are on by default and turn off with `OctetConfig.telemetryEnabled = false`. See [Metrics and usage reporting](/concepts/metrics) and [pricing](https://octetproof.com/pricing). Use of the SDK is governed by the [Octet SDK License Agreement](https://octetproof.com/terms/mobile/). Dated versions live at `/terms/mobile/vMM.DD.YYYY/`. --- ## 2. Platform privacy declarations ### iOS: `Info.plist` keys Add these to your app's `Info.plist`. Without them, the iOS runtime crashes on first launch with a privacy-sensitive-data error that names the missing key. #### Required ```xml NSLocationWhenInUseUsageDescription This app uses your location to verify and prove your location to services that request it. NSMotionUsageDescription This app uses motion data to detect when you're stationary or moving, which improves the confidence of location proofs. ``` `NSMotionUsageDescription` is required. The SDK touches `CMMotionActivityManager` immediately during `Octet.start(...)`, and Apple requires the usage description before any code accesses that API. Read-only access also counts. The strings are user-facing. The copy above is a safe default. Rewrite in your product's voice if you prefer. #### Required only if you enable background location If your app needs proofs while backgrounded, also add: ```xml NSLocationAlwaysAndWhenInUseUsageDescription This app uses background location to continue generating location proofs while you're not actively using it. UIBackgroundModes location ``` Without these the SDK silently falls back to foreground-only operation when the app is backgrounded. The SDK itself does not crash. Proofs stop generating until the app returns to foreground. ### Android: runtime permissions The SDK's `AndroidManifest.xml` declares the permissions the on-demand foreground proof flow uses: location, motion, foreground service, internet, and wake-lock. Manifest-merge propagates these into your app, so you do not copy them into your own manifest. The SDK's manifest declares only the permissions the foreground proof flow uses, so `ACCESS_BACKGROUND_LOCATION` is not bundled. To generate proofs while the app is backgrounded, declare `ACCESS_BACKGROUND_LOCATION` in your own manifest. You still request the runtime permissions from the user: | Permission | When to request | Notes | |---|---|---| | `ACCESS_FINE_LOCATION` | Before `Octet.start(...)`. | SDK refuses to start without it. | | `ACTIVITY_RECOGNITION` | Before `Octet.start(...)`. | Android 10+ (API 29+). Motion-classification features degrade gracefully if denied. Request it for full proof confidence. | | `ACCESS_BACKGROUND_LOCATION` | After `ACCESS_FINE_LOCATION` is granted, only if you need background proofs. | Declare it in your own manifest first. The SDK does not merge it. Android 10+ prompts for it separately. | --- ## 3. Minimum platform versions | Platform | Minimum | Toolchain | |---|---|---| | iOS | 16.0 | Xcode 15+, Swift 5.9+ | | Android | API 30 (Android 11) | Android Studio Hedgehog (2023.1.1)+, JDK 17, Kotlin 2.1+ | --- Next: the [iOS Quick Start](/getting-started/ios-quickstart) or [Android Quick Start](/getting-started/android-quickstart). _Source: https://octetproof.com/docs/mobile/getting-started/prerequisites/_ --- # iOS Quick Start Install the SDK, start it, and get a first verdict on an iOS device. :::note Work through [Prerequisites](/getting-started/prerequisites) first. You will need a registered app (or a sandbox token for local development) and the two `Info.plist` keys. ::: --- ## 1. Add the SDK ### Swift Package Manager (recommended) In Xcode: **File → Add Packages…** and enter: ``` https://github.com/octetproof/octet-sdk-ios ``` Pin to a version rather than tracking `main`. Or in `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/octetproof/octet-sdk-ios", exact: "3.0.0") ] ``` Pin 3.0.0 or later. 3.0 is a major release. If you are on 2.x, read [Upgrading from 2.x](/getting-started/upgrading-from-2x) first. If you are on 1.x, read [Migrating from 1.x](/getting-started/migrating-from-1x) as well. Then import: ```swift import OctetSDK ``` ### Carthage Carthage does not propagate SwiftPM transitive dependencies, but OctetSDK declares none. Add one line to your `Cartfile`: ``` binary "https://raw.githubusercontent.com/octetproof/octet-sdk-ios/main/OctetSDK.json" >= 3.0.0 ``` The module name is `OctetSDK`. --- ## 2. Add `Info.plist` keys ```xml NSLocationWhenInUseUsageDescription This app uses your location to verify and prove your location to services that request it. NSMotionUsageDescription This app uses motion data to detect when you're stationary or moving, which improves the confidence of location proofs. ``` Without these, the app crashes on first launch. The xcframework also bundles a privacy manifest (`PrivacyInfo.xcprivacy`) declaring the data types it handles (location, device identifier, aggregate usage counters and the daily usage record) and its required-reason API use. Xcode folds this into your app's privacy report at build time. See [Data collection](/concepts/data-collection). --- ## 3. Request location permission The SDK refuses to start until the user grants location authorization. Request it before calling `Octet.start(...)`: ```swift import CoreLocation let locationManager = CLLocationManager() locationManager.requestWhenInUseAuthorization() ``` Wait for the authorization status callback (`locationManagerDidChangeAuthorization`) before continuing. --- ## 4. Start the SDK `Octet.start(...)` bootstraps the license by attesting the app with Apple App Attest, then brings up the proof pipeline. On a real device with the App Attest capability enabled (see [Prerequisites](/getting-started/prerequisites)), no key or token is needed. ```swift import OctetSDK let config = OctetConfig(licenseKey: "") // required by the type, ignored by start let sdk = try await Octet.start(config: config) ``` On a **simulator** or a locally built debug build, there is no hardware attestation. Mint a sandbox bypass token (see [Prerequisites](/getting-started/prerequisites)) and pass it: ```swift let config = OctetConfig(licenseKey: "", sandboxBypassToken: "octet_sbx_…") ``` `Octet.start(...)` is `async throws`. Any license or bootstrap problem throws a typed `LicenseError`. See [License Types](/api-reference/license-types) for the case list, and [Attested bootstrap](/concepts/attested-bootstrap) for the handshake. --- ## 5. Query a region ```swift let verdict = await sdk.loc.isWithin( region: .country(isoCode: "US"), atTime: Date() ) switch verdict.result { case .yes: print("YES, proof attached: \(verdict.proof != nil)") case .no: print("NO, provable negative") case .indeterminate: print("INDETERMINATE, reason: \(verdict.reason)") } ``` The predicate returns an [`OctetVerdict`](/api-reference/octet-verdict). Never treat `INDETERMINATE` as `NO`. --- ## 6. Close when done When your proof flow ends, release the session and stop background work: ```swift await sdk.close() ``` See [Session Lifecycle](/concepts/lifecycle) for why this matters. --- ## 7. What to expect - **On a real device, outdoors**, with cellular and GPS available, `isWithin(.country(isoCode: ...))` typically returns `YES` with an attached proof. - **On the iOS Simulator** the verdict is always `INDETERMINATE / NO_FIX` with the message `running on simulator — location proofs are unavailable in this environment`. Run on hardware to see the full flow. - **On a real device, indoors**, the first proof may take longer. A `country` query can often answer. See [Concepts: Verdicts](/concepts/verdicts). --- ## 8. Next steps - The [OctetSample sample app](/samples/ios-toy-app) exercises this whole flow. - [On-device Verification](/concepts/on-device-verification) checks a proof with [`Octet.verify`](/api-reference/octet-verify), offline. - [Proof Binding](/concepts/proof-binding) ties a proof to a login or a decision, and `forceFresh` mints one on demand. - [Verifying Proofs](/concepts/verifying-proofs) and the [Verifier Quick Start](/getting-started/verifier-quickstart) show independent verification. - [What's new in 3.0](/whats-new) summarizes the release. The full notes are in the changelogs. _Source: https://octetproof.com/docs/mobile/getting-started/ios-quickstart/_ --- # Android Quick Start Install the SDK, start it, and get a first verdict on an Android device. :::note Work through [Prerequisites](/getting-started/prerequisites) first. You will need a registered app (or a sandbox token for local development) and a plan for runtime permissions. ::: --- ## 1. Add the Maven repo In your project's root `settings.gradle.kts`: ```kotlin dependencyResolutionManagement { repositories { google() mavenCentral() maven { url = uri("https://raw.githubusercontent.com/octetproof/octet-sdk-android/mvn-repo") } } } ``` The OctetSDK Maven artifacts live on the `mvn-repo` orphan branch of the public `octet-sdk-android` repository. --- ## 2. Add the dependency In your app `build.gradle.kts`: ```kotlin dependencies { implementation("com.octetproof:sdk:3.0.0") } ``` Pin 3.0.0 or later. 3.0 is a major release. If you are on 2.x, read [Upgrading from 2.x](/getting-started/upgrading-from-2x) first. If you are on 1.x, read [Migrating from 1.x](/getting-started/migrating-from-1x) as well. Sync Gradle. The SDK's foreground permissions merge into your manifest automatically. For proofs while the app is backgrounded, add `ACCESS_BACKGROUND_LOCATION` to your own manifest. See [Prerequisites](/getting-started/prerequisites). --- ## 3. Request runtime permissions The SDK refuses to start without `ACCESS_FINE_LOCATION`. Motion-classification confidence degrades without `ACTIVITY_RECOGNITION`. Request both before calling `Octet.start(...)`: ```kotlin ActivityCompat.requestPermissions( this, arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACTIVITY_RECOGNITION, ), REQUEST_CODE ) ``` Wait for `onRequestPermissionsResult(...)` to confirm `ACCESS_FINE_LOCATION` was granted before continuing. --- ## 4. Start the SDK `Octet.start(...)` bootstraps the license by attesting the app with Android hardware key attestation, then brings up the proof pipeline. On a real device, no key or token is needed (see [Prerequisites](/getting-started/prerequisites)). ```kotlin import com.octetproof.sdk.api.Octet import com.octetproof.sdk.api.OctetConfig lifecycleScope.launch { val config = OctetConfig(licenseKey = "") // required by the type, ignored by start val sdk = Octet.start(context = applicationContext, config = config) // sdk is ready } ``` On an **emulator** or a locally built debug build, there is no hardware attestation. Mint a sandbox bypass token (see [Prerequisites](/getting-started/prerequisites)) and pass it: ```kotlin val config = OctetConfig(licenseKey = "", sandboxBypassToken = "octet_sbx_…") ``` `Octet.start(...)` is a `suspend` function. Any license or bootstrap problem throws a typed `LicenseError`. See [License Types](/api-reference/license-types) and [Attested bootstrap](/concepts/attested-bootstrap). --- ## 5. Query a region ```kotlin import com.octetproof.sdk.api.OctetRegion import com.octetproof.sdk.api.OctetVerdict import java.time.Instant val verdict = sdk.loc.isWithin( region = OctetRegion.country("US"), atTime = Instant.now() ) when (verdict.result) { OctetVerdict.Result.YES -> println("YES, proof attached: ${verdict.proof != null}") OctetVerdict.Result.NO -> println("NO, provable negative") OctetVerdict.Result.INDETERMINATE -> println("INDETERMINATE, reason: ${verdict.reason}") } ``` The predicate returns an [`OctetVerdict`](/api-reference/octet-verdict). Never treat `INDETERMINATE` as `NO`. --- ## 6. Close when done When your proof flow ends, release the session and stop the location foreground service: ```kotlin sdk.close() ``` See [Session Lifecycle](/concepts/lifecycle) for why this matters. --- ## 7. What to expect - **On a real device, outdoors**, with cellular and GPS available, `isWithin(country("US"))` typically returns `YES` with an attached proof. - **On the Android emulator** the verdict is always `INDETERMINATE / NO_FIX` with the message `running on emulator — location proofs are unavailable in this environment`. Run on hardware to see the full flow. - **On a real device, indoors**, a `country` query can often answer. See [Concepts: Verdicts](/concepts/verdicts). --- ## 8. Next steps - The [Android sample app](/samples/android-toy-app) exercises this whole flow. - [On-device Verification](/concepts/on-device-verification) checks a proof with [`Octet.verify`](/api-reference/octet-verify), offline. - [Proof Binding](/concepts/proof-binding) ties a proof to a login or a decision, and `forceFresh` mints one on demand. - [Verifying Proofs](/concepts/verifying-proofs) and the [Verifier Quick Start](/getting-started/verifier-quickstart) show independent verification. - [What's new in 3.0](/whats-new) summarizes the release. The full notes are in the changelogs. _Source: https://octetproof.com/docs/mobile/getting-started/android-quickstart/_ --- # React Native :::note[Coming soon] The React Native wrapper is in progress and not yet published to npm. This page is a placeholder. Until it ships, integrate through the native [iOS](/getting-started/ios-quickstart) and [Android](/getting-started/android-quickstart) SDKs. ::: `@octetproof/react-native` is a thin binding over the native iOS and Android SDKs. The wrapper pins one exact native SDK version for each platform, and both must match, so a proof returned through the wrapper has the same meaning as a proof from the native SDK. The wrapper is versioned on its own line (its major version tracks the native major). One difference from native: the wrapper validates `OctetConfig.licenseKey` as non-empty and throws `"OctetConfig.licenseKey is required"` on an empty string. Pass any non-empty placeholder (for example `"unused"`), even though the field is ignored at runtime. On native (Swift and Kotlin), an empty string is fine. When the package publishes, this page will carry the install and quickstart steps. Check the [`octet-sdk-react-native`](https://github.com/octetproof/octet-sdk-react-native) repository for status. _Source: https://octetproof.com/docs/mobile/getting-started/react-native/_ --- # Verifier Quick Start Build `octet-verify`, the open-source command-line tool that independently checks an Octet proof, and use it to verify a proof. :::note This page is the how-to. [Verifying Proofs](/concepts/verifying-proofs) explains what the verifier does and doesn't establish, and why it can run offline. ::: --- ## 1. Build it The verifier is public Rust. Clone and build: ```bash git clone https://github.com/octetproof/octet-verify cd octet-verify cargo build --release # binary at ./target/release/octet-verify ``` If you have no proof of your own, the clone ships real ones under `test-vectors/golden/1.0.0/`. They are historical and CI-signed, so widen the freshness window and expect `attestation-root` to report `NOT-CHECKED`: ```bash ./target/release/octet-verify test-vectors/golden/1.0.0/android-onearth.bin \ --hardware-pubkey test-vectors/golden/1.0.0/android-onearth.pubkey \ --max-age-seconds 9999999999 ``` The default build is offline and dependency-light. It pulls no networking or JSON libraries, so the cryptographic path you're trusting stays small and auditable. Two optional features sit behind build flags: - `--features net` adds [backend fetch mode](#3-fetch-from-the-ingestion-api). - `--features appattest` adds offline [hardware-attestation](/concepts/device-attestation) validation: iOS App Attest to Apple's root, and the Android key-attestation chain to Google's root. Without it, the `attestation-root` check stays `NOT-CHECKED`. For proofs from SDK 3.0, build `octet-verify` at 1.5.0 or later. To enforce a verdict tier with `--require-verdict verified` (or `plausible`), build 1.6.0 or later. A build older than 1.2.0 reports the session binding of a session-bound proof (one made with a `sessionNonce`, see [Proof Binding](/concepts/proof-binding)) as `NOT-CHECKED` and still validates the rest of the proof. --- ## 2. Verify a proof you received If you have the proof bytes as a file (the SDK can export one, or a backend can hand you the raw bytes), that's all you need. There is no network, and no keys to trust beyond what is inside the proof: ```bash octet-verify proof.bin # or: cat proof.bin | octet-verify ``` A passing run looks like this: ``` == octet-verify == verdict: VALID [ PASS] freshness 42 s old (limit 300 s) [ PASS] nullifier replay token present (32 bytes) [ PASS] stage-chain 8 stages, hash linkage intact [ PASS] stage-signatures all 8 stage signatures verify [ PASS] chain-assembly final stage binds all 7 prior signatures [ PASS] field-binding commitment, nullifier, zkProof bound to signed stage hashes [ PASS] semantic-binding verdict / region / level / integrity / commitment bound [ PASS] region-claim claims country:US [NOT-CHECKED] attestation-root hardware key trusted as carried (rebuild --features appattest to validate) … 11 pass · 0 fail · 0 warn · 3 not-checked ``` Use the exit code in automation: | Code | Meaning | |---|---| | `0` | **authentic**: valid and signatures cryptographically verified | | `1` | **invalid**: a check failed | | `2` | usage / IO / decode / backend error | | `3` | **inconclusive**: structurally valid but signatures not verified (e.g. no hardware key) | A CI gate can be `octet-verify proof.bin && deploy`. Exit `0` means authentic, and an unverified proof exits `3`, never `0`. Add `--json` for one machine-readable object per proof (`valid` reports *authenticity*, plus `signatures_verified` and the `verdict` string): ```bash octet-verify proof.bin --json | jq 'select(.valid == false)' ``` Useful flags: `--hardware-pubkey ` (supply the device key for iOS proofs that carry no certificate), `--expect-region ` (assert a claimed region), and `--max-age-seconds ` (widen the freshness window). See the repo's [`INTEGRATION.md`](https://github.com/octetproof/octet-verify/blob/main/INTEGRATION.md) for the full reference. --- ## 3. Fetch from the ingestion API If your proofs are uploaded to the Octet proof ingestion API, the verifier can fetch and check them in one step. This needs the network-enabled build: ```bash cargo build --release --features net ``` The backend is untrusted. Fetched bytes run through the same checks as a local file. Fetching requires a token that can read your license's proofs. ### Read credential `--token` is an **`octet_read_` read credential**: a read-scoped, server-side token that is not tied to a device. Hold it on your backend or CI like an API key. It is not a license key, and it does not come from an app bootstrap. Octet issues the credential. Request one, then export it: ```bash export OCTET_TOKEN="octet_read_…" ``` The CLI exchanges it for a short-lived, read-scoped session internally. Confirm fetch mode is enabled for your account before you rely on it in production. ### Fetch and verify ```bash octet-verify fetch --backend https://api.octetproof.com --token "$OCTET_TOKEN" ``` Two more modes: - `octet-verify watch …`: poll for new proofs and verify each as it arrives (for example, to feed a live demo or dashboard). - `octet-verify range --since --until …`: verify every proof in a window. Exits non-zero if any fails. > Proofs are stored for **less than 24 hours**, then purged. Fetch and verify > promptly. An aged-out id returns `404`. --- ## 4. End-to-end flow From device to independent verdict: ```mermaid flowchart LR A[Your app + OctetSDK
generate a proof] -->|upload| B[Ingestion API
stores under 24h, bytes only] B -->|fetch by id| C[octet-verify
checks signatures] C --> D[VALID / INVALID] ``` 1. **Produce.** Your app calls the SDK. On a `YES`/`NO` it gets a signed proof and uploads it. (Setup is in the [iOS](/getting-started/ios-quickstart) and [Android](/getting-started/android-quickstart) quick starts.) 2. **Store.** The ingestion API keeps the raw bytes for less than 24 hours, keyed by id. It never decodes or signs anything. 3. **Verify.** `octet-verify fetch …` pulls those bytes and validates them against the keys *inside* the proof. The verdict depends only on the bytes, not on anything the backend says. A proof produced on a phone is confirmed by a separate, public binary, using only the bytes and the keys inside the proof. If the proof was altered after it was signed, the verifier rejects it. The default build does not show that the signing key is genuine device hardware. For that, build with `--features appattest` and pass `--require-attestation` (see [Hardware attestation](/concepts/verifying-proofs#hardware-attestation-the-appattest-build)). --- ## Troubleshooting | Symptom | Fix | |---|---| | `octet-verify: command not found` | Build it (step 1) and use `./target/release/octet-verify`, or put it on your `PATH`. | | `verdict: INCONCLUSIVE` | The signatures weren't checked, usually an iOS proof with no embedded certificate. Pass the device key with `--hardware-pubkey `. | | `verdict: INVALID` | A check failed. The output names which one. Common during development: a stale proof from before a wire change, or a key that isn't the one that signed the proof. | | `404` / no proof on fetch | The proof hasn't been uploaded yet, or it has aged past the retention window (less than 24 hours). Generate a fresh one and fetch within the day. | | `… requires a build with the net feature` | Rebuild with `cargo build --release --features net` to use `fetch` / `watch` / `range`. | ## Reference - [`README.md`](https://github.com/octetproof/octet-verify): overview and what the verifier does and does not check. - [`INTEGRATION.md`](https://github.com/octetproof/octet-verify/blob/main/INTEGRATION.md): full CLI reference, flags, and consumer guide. - [`VERIFICATION-SPEC.md`](https://github.com/octetproof/octet-verify/blob/main/VERIFICATION-SPEC.md): the byte-exact signing contract every check implements. _Source: https://octetproof.com/docs/mobile/getting-started/verifier-quickstart/_ --- # Upgrading from 2.x 3.0 is a major release. This page lists what changes for an app moving from 2.0.0, breaking changes first. The 2.0 docs stay at [/docs/v2.0/](/docs/v2.0/). For the full list, see [What's new in 3.0](/whats-new). ## 1. Update the version ```swift .package(url: "https://github.com/octetproof/octet-sdk-ios", exact: "3.0.0") ``` ```kotlin implementation("com.octetproof:sdk:3.0.0") ``` If you verify proofs yourself with `octet-verify`, run 1.5.0 or later. To enforce a verdict tier on your backend with `--require-verdict`, run 1.6.0 or later. ## 2. Review the breaking changes ### Usage reporting is always on After the first proof of each UTC day, the SDK sends one `device_active` usage record with the next background heartbeat, so Octet can count active devices for billing. The record carries no location, no proof data, no user or account identity and no new device identifier. It never blocks or delays a proof. `OctetConfig.creditServiceUrl` is only for testing. Leaving it unset, or setting it to `nil` / `null`, does not turn reporting off. Update your app's privacy disclosures to include the daily usage record. See [Data collection](/concepts/data-collection) and [Metrics and usage reporting](/concepts/metrics). ### A pinned `claimedRegion` must be an assigned ISO 3166 code A pinned `claimedRegion` whose code is malformed or not assigned in ISO 3166 produces no proof, and the verdict reason is `regionUnresolved`. 2.0.0 produced a signed proof in this case. ### Android: debugger refusal outside a sandbox session With a debugger or native tracer attached, the SDK now refuses a proof with `TAMPERING`, as on iOS. A sandbox session (`sandboxBypassToken` set) still allows one, so debugging against the sandbox keeps working. ### Android: `BootstrapReason.NewDevicesBlocked` `BootstrapReason` gains `NewDevicesBlocked`. A `when (reason)` over `BootstrapReason` without an `else` branch no longer compiles. Add the new case or an `else` branch: ```kotlin try { val sdk = Octet.start(context = applicationContext, config = config) } catch (e: LicenseError.BootstrapFailed) { when (e.reason) { // Open the server-signed link exactly as received. BootstrapReason.NewDevicesBlocked -> e.upgradeUrl?.let { openInBrowser(it) } else -> showStartupError(e) } } ``` On iOS the new case is `.newDevicesBlocked(upgradeUrl:)`. A `switch` with a `default` keeps compiling. See [License Types](/api-reference/license-types#bootstrapreason). ### Android: Kotlin binary compatibility `AdvancedConfig` and `LicenseStatus` gained constructor parameters with defaults. Source that uses named arguments compiles unchanged. Code compiled against 2.0.0 that calls their constructors or `copy` must be recompiled. ## 3. Check the behavior changes - **The first proof after `Octet.start` waits for startup to finish**, for up to 30 s, instead of returning `noFix` / `NO_FIX`. If your app applies its own timeout to the first call, allow for this. - **TLS pins.** The built-in pins for Octet's hosts are updated. If you turned on certificate pinning with 2.0.0, upgrade to 3.0.0. ## 4. Adopt what is new - **Gateway routing.** Route every SDK network call through your own HTTPS host or through Octet's hosted gateway with `OctetConfig.advanced.transport`. See [Gateway routing](/concepts/gateway-routing). - **Assurance tier.** Read `LocationProof.spoofingVerdict` to decide what a `YES` is worth. See [`OctetVerdict`](/api-reference/octet-verdict#the-assurance-tier). - **Verdict tier on verification.** `VerifyOptions.requireVerdict` fails a proof below the tier you require. See [`Octet.verify`](/api-reference/octet-verify). - **`PLAUSIBLE` containment.** Turn on `OctetConfig.advanced.acceptPlausibleContainment` to let disc queries answer from a `PLAUSIBLE` proof. See [Predicates](/api-reference/predicates#plausible-containment). - **Upgrade link on new-device refusal.** Handle `newDevicesBlocked` and open its `upgradeUrl`. See [License Types](/api-reference/license-types#bootstrapreason). _Source: https://octetproof.com/docs/mobile/getting-started/upgrading-from-2x/_ --- # Migrating from 1.x This page lists what changed for an app moving from 1.x to 2.0, breaking changes first. 3.0 changes more on top of 2.0, so after this page read [Upgrading from 2.x](/getting-started/upgrading-from-2x). The 1.x docs stay available at [/docs/v1.2/](/docs/v1.2/). ## Licensing moves to attested bootstrap In 1.x the SDK activated with a pasted `licenseKey`. In 2.0 it proves the app's identity with a platform attestation (Apple App Attest, Android key attestation) and the backend mints the license. You register your app once instead of embedding a key. See [Attested bootstrap](/concepts/attested-bootstrap) for the model and [Prerequisites](/getting-started/prerequisites) for setup. New `LicenseError` cases accompany this: `bootstrapFailed(reason:)` carries a `BootstrapReason` naming the cause. See [License Types](/api-reference/license-types). ## Breaking changes - **iOS: `confidence.flags` JSON values are now `UPPER_SNAKE`.** `toJson()` / `toJsonl()` on iOS previously emitted Swift case names (`vpnActive`). They now emit `VPN_ACTIVE`, matching Android. This affects only code that parses the JSON on iOS. The typed `ConfidenceSummary.flags` and `toStr()` are unchanged. See [Serialization](/api-reference/serialization). - **Region resolution can now return `regionUnresolved`.** A country or subdivision predicate that cannot resolve the device's region at the required confidence returns `INDETERMINATE / REGION_UNRESOLVED` instead of silently coarsening to an `earth()` claim. Code that treated every `INDETERMINATE` the same should read the reason. See [Verdicts](/concepts/verdicts). ## Behavior changes - **Proof emission is semantic-binding v2.** Proofs now bind city and earth geometry and a `location_verdict`. Verify proofs with `octet-verify` 1.5.0 or later, or on-device with [`Octet.verify`](/api-reference/octet-verify). Both still accept 1.x proofs. - **New adversarial reason codes.** A detected spoof now carries `spoofingDetected` or `tampering`, distinct from the benign "couldn't measure" reasons. See the [reason-code taxonomy](/concepts/verdicts). ## New in 2.0 - **`OctetSdk.close()`** ends the session and stops the Android location foreground service. Call it when your proof flow finishes. See [Session Lifecycle](/concepts/lifecycle). - **On-device verification** with [`Octet.verify`](/api-reference/octet-verify). - **Decision binding and force-fresh** on the predicates: `decisionRef` and `forceFresh`. See [Proof Binding](/concepts/proof-binding). ## Version coordinates Pin `3.0.0`. iOS Swift Package Manager `exact: "3.0.0"`, Android Gradle `com.octetproof:sdk:3.0.0`. See the [iOS](/getting-started/ios-quickstart) and [Android](/getting-started/android-quickstart) quick starts. _Source: https://octetproof.com/docs/mobile/getting-started/migrating-from-1x/_ --- # Proof of Location :::note[In one sentence] A *proof of location* is a signed, time-stamped record that a device was inside a region at a given time, and anyone holding it can verify the signatures offline. ::: ## Why a claim needs a proof A claim like "the device was in the US at 3 pm Tuesday" can come from the OS, the GPS chip, a browser API, or a mock-location app. All of them are easy to forge. The claim is only worth acting on if a third party (your backend, an auditor, a regulator) can check it without trusting whoever made it. The Octet SDK produces those proofs. Every `YES` or `NO` it returns carries one. An `INDETERMINATE` carries none. You can: - Forward a proof to your backend and verify it offline with the open-source verifier. - Hand it to a compliance auditor to re-check months later. - Store it as a durable record of where the device was at that time. ## How it works The SDK combines several independent on-device signals (GNSS, cellular network identity, motion sensors) with platform attestation (Apple App Attest on iOS, hardware key attestation on Android) to produce a verdict and a cryptographic proof. The SDK creates the signing key in the device's hardware where it can (the Secure Enclave on iOS, StrongBox or the Trusted Execution Environment on Android). Each proof records where its key lives as the device-key security tier: `HARDWARE_STRONGBOX`, `HARDWARE_TEE`, or `SOFTWARE`. On a device without hardware-backed key storage, the SDK falls back to a software key and still signs proofs, so a verifier decides which tiers to accept. The proof is a signed protobuf that names: - The **claimed region** (for example country `US`, or a disc of radius 250 m). - The **time of the fix** and the validity interval the proof covers. - The **assurance tier** (`spoofingVerdict`: `VERIFIED` or `PLAUSIBLE`) and the device-integrity status. - An **attestation chain** from platform hardware (App Attest on iOS, key attestation on Android), which the SDK checks when it builds the proof. You call `isWithin(region)` and get back a `Verdict`. On `YES` or `NO` the verdict carries a proof. If the SDK cannot get a trustworthy fix (rooted device, a mock-location app, or no usable GPS signal), it issues no proof and returns `INDETERMINATE` with a reason code. ```mermaid flowchart LR A[Your app] -->|isWithin region| B[OctetSDK] B --> C{Can the SDK
answer?} C -->|Yes| D[Verdict YES or NO
+ signed proof] C -->|No| E[Verdict INDETERMINATE
+ reason code] D -->|forward| F[Your backend] F -->|verify offline| G[octet-verify] ``` ## What the SDK does not do - **Judge the user.** It reports whether it can issue a proof and, when it cannot, a reason code. An adversarial reason (`SPOOFING_DETECTED`, `MOCK_LOCATION_DETECTED`, `TAMPERING`, `ATTESTATION_FAILED`) describes the location fix, not the person. What to do about the user is your policy. - **Guarantee a `YES`.** On an emulator, indoors with no cellular, on a rooted device, or with a mock-location app running, the result is `INDETERMINATE`. - **Predict the future.** A proof states where the device was at time T, not where it will be later. See [Time Semantics](/concepts/time-semantics). ## Where to go next - [Device Attestation](/concepts/device-attestation). How App Attest and key attestation tie a proof to genuine hardware. - [Verdicts](/concepts/verdicts). The `YES` / `NO` / `INDETERMINATE` values and how to read the reason code. - [Regions](/concepts/regions). What kinds of areas you can ask about. - [Time Semantics](/concepts/time-semantics). How `atTime` and proof validity intervals interact. _Source: https://octetproof.com/docs/mobile/concepts/proof-of-location/_ --- # Attested bootstrap :::note[Licensing since 2.0] The SDK activates by attesting the app: Apple App Attest on iOS, hardware key attestation on Android. No license key is embedded. Register your app at `sdk.octetproof.com/apps` (below). ::: ## The model Since 2.0, the SDK proves the app's identity at first launch with a **platform attestation**, so no secret ships in the app: | Platform | Anchor | Verified against | |---|---|---| | iOS | Apple App Attest | Apple's App Attest root | | Android | Hardware key attestation | Google's hardware-attestation root (TEE / StrongBox) | | macOS / Windows | Code-signing metadata | The platform signing authority | The attestation names the app (its bundle identifier and Apple Team ID on iOS, its package name and signing-certificate SHA-256 on Android). Octet reads that identity from the verified attestation, looks it up in a registry of apps you own, and mints a license bound to the device. ## Registering your app You register each app once against your Octet account. Sign up at [sdk.octetproof.com/signup](https://sdk.octetproof.com/signup), then add your app identities at [sdk.octetproof.com/apps](https://sdk.octetproof.com/apps) (the onboarding email links there). The **Add an app identity** dialog records the bundle id and Apple Team ID on iOS, or the package name and signing-certificate SHA-256 on Android, with separate **Debug certificate** (sandbox) and **Release certificate** (release) fields. The portal labels the two sides **Release** and **Sandbox**. The side a build lands on follows its signature, not its build configuration. An Xcode Release build installed with a development profile, or an Android release build signed with the debug key, lands on sandbox. The first valid attestation that arrives for an unclaimed identity binds it to the account that registered it (race-to-attest). A contested identity is refused at bootstrap. A developer dashboard at `dashboard.octetproof.com` is on the way. `/apps` is the live surface today. ## The bootstrap handshake `Octet.start(...)` runs this at first launch. It is a two-call flow so the server controls the anti-replay challenge: 1. **Challenge.** The SDK calls `POST /v1/bootstrap/challenge` and receives `{ challenge, expires_at }` (the challenge is single-use and expires in about 60 seconds). 2. **Attest.** The SDK folds the challenge into the attestation (`clientDataHash` on iOS, the key-attestation challenge extension on Android) and produces a signed attestation payload. 3. **Bootstrap.** The SDK calls `POST /v1/bootstrap` with the channel, the attestation, the key id, the challenge, the app identity, and the device fingerprint. 4. **Verify + mint.** The backend confirms the challenge is one it issued and burns it, verifies the attestation against the bundled platform root, looks up the app identity in your account's registry, checks the account is active, and mints a device-bound license plus an activation lease and bearer. The bootstrap response is the same shape the old `/v1/activate` returned: a lease, an activation bearer, and a license token. Everything after bootstrap is unchanged from 1.x: [lease refresh, offline grace, and revocation](/concepts/license-activation) work as before. ```mermaid sequenceDiagram participant App as App + OctetSDK participant HW as App Attest / Key attestation participant LB as Octet license backend App->>LB: POST /v1/bootstrap/challenge LB-->>App: { challenge, expires_at } App->>HW: attest, challenge folded in HW-->>App: signed attestation App->>LB: POST /v1/bootstrap (attestation, identity, device_fp) LB->>LB: verify attestation + look up app + check account LB-->>App: { lease, activation_bearer, license_token } ``` ## Devices that cannot attest An emulator, a CI runner, and a locally built debug app have no hardware attestation to offer. For those, register your Debug certificate under an app at `/apps` (the Debug field creates a sandbox identity), mint a **sandbox bypass token** for it there (a 7-, 14-, 30-, or 90-day lifetime, revocable), and pass it as `OctetConfig.sandboxBypassToken`. When it is set, the SDK bootstraps against that sandbox identity instead of attesting. A store or release build cannot use one: the SDK refuses a sandbox token in a release build, and the server refuses it for a release app. A real device with no attestation and no sandbox token fails closed at `start` with a `LicenseError`, because without an attestation there is nothing to bind the license to. ## When bootstrap fails `Octet.start(...)` throws a typed [`LicenseError`](/api-reference/license-types). A bootstrap-specific failure carries a `BootstrapReason` naming the cause, for example `attestationInvalid` (the attestation did not verify), `appNotRegistered` (the app identity is not registered to any account), or `bootstrapReplay` (the challenge was already used, or was not one the server issued). When the account accepts no new devices, the reason is `newDevicesBlocked`, and a server-signed `upgradeUrl` comes with it, to open exactly as received. Devices that already activated are unaffected. See [License Types](/api-reference/license-types#bootstrapreason) for the full case list. ## Where to go next - [Prerequisites](/getting-started/prerequisites): register your app and set up attestation. - [Device Attestation](/concepts/device-attestation): what App Attest and key attestation prove. - [License & Activation](/concepts/license-activation): the lease, refresh, and revocation lifecycle after bootstrap. _Source: https://octetproof.com/docs/mobile/concepts/attested-bootstrap/_ --- # Device Attestation :::note[In short] By default, every signed proof carries hardware-backed device attestation: Apple App Attest on iOS, hardware key attestation on Android. Proofs from a sandbox session, or with `osAttestationInGatewayModes` set to `false`, carry none. A relying party that runs `octet-verify` built with the `appattest` feature can confirm the proof came from a genuine app on a genuine device. No integration code is required. It is part of proof generation. ::: A [proof of location](/concepts/proof-of-location) records where the device was. Device attestation establishes that the proof came from a genuine app instance on genuine hardware. By default, each proof the SDK signs also carries an attestation produced by the operating system's attestation service, so the [verifier](/concepts/verifying-proofs) can tie the proof to genuine hardware instead of trusting the signing key as carried. Device attestation is separate from the [device-key security tier](/concepts/proof-of-location) (`HARDWARE_STRONGBOX` / `HARDWARE_TEE` / `SOFTWARE`), which records where the signing key lives. Attestation establishes that the app and OS are genuine. The security tier records the strength of the key that signed. A verifier reads both. Three uses of the word appear in these docs. **Platform attestation** is the hardware-rooted evidence the platform provides: Apple App Attest on iOS, hardware key attestation on Android. **Device attestation** is the check described on this page, which ties that evidence to the key that signed the proof. **Hardware attestation** is the offline validation of the platform roots, available when `octet-verify` is built with the `appattest` feature. On Android, the device attestation that binds each attested proof to genuine hardware is hardware key attestation. Android proofs can also carry a Google Play Integrity verdict, a separate signal that covers the app and device environment rather than the signing key. See [Play Integrity on Android](#play-integrity-on-android). ## What gets attested | Platform | Mechanism | What it shows | |---|---|---| | iOS | Apple App Attest | The proof came from your App ID, running on a genuine Apple device with a Secure Enclave key. | | Android | Hardware key attestation | The signing key lives in TEE or StrongBox, attested by a certificate chain rooted in Google's hardware-attestation root. The chain also carries the app identity: package name and signing-certificate digests. | The evidence is bound into the signed proof chain. Editing it after signing breaks verification. On iOS the App Attest assertion carries an anti-replay counter, so a captured assertion cannot be reused on a later proof. ## Activation and proof time Activation is the step where attestation is required. On iOS, a device without working App Attest cannot activate. On Android, a device that cannot produce hardware key attestation cannot activate, and `Octet.start` fails with `bootstrapFailed(attestationInvalid)`. On both platforms the only way past this is a sandbox session (`sandboxBypassToken` set), which a store build rejects. See [License types](/api-reference/license-types). After activation, an attestation failure at proof time does not block the proof. The SDK still produces it with weaker attestation, and the verifier reports what was attested. ## Attestation cadence Attestation is on by default. `OctetConfig.advanced.attestationCadence` sets how often a fresh attestation is produced: | Cadence | Behaviour | |---|---| | per-session | One attestation per SDK session. Lowest overhead. | | periodic (interval) | Re-attest on the interval you give. **Default** (5 minutes). | | per-proof | A fresh attestation on every proof. Highest assurance, highest cost. | ```swift let config = OctetConfig( licenseKey: "", advanced: AdvancedConfig( attestationCadence: .periodic(interval: 300) // the default ) ) let octet = try await Octet.start(config: config) ``` ```kotlin val config = OctetConfig( licenseKey = "", advanced = AdvancedConfig( attestationCadence = AttestationCadence.Periodic(intervalSeconds = 300) ) ) val octet = Octet.start(context, config) ``` Android device attestation uses hardware key attestation: the certificate chain is verified offline against Google's bundled hardware-attestation roots. The backend also checks Google's public keybox-revocation list and rejects revoked devices. ## Play Integrity on Android On Android, each proof can carry a Google Play Integrity verdict alongside the hardware key attestation. The two cover different layers: - **Hardware key attestation** proves the signing key is hardware-backed (TEE or StrongBox) and chains to Google's hardware-attestation root. - **Play Integrity** reports on the app and device environment: a genuine, unmodified app on a genuine device with a licensed install. The Play Integrity token is opaque on the device, so the SDK attaches it to the proof without reading it. The token is bound into the proof through its nonce, which is part of the data the device key signs. The proof records the status as `INTEGRITY_PENDING` when the SDK obtained a token, or `INTEGRITY_UNAVAILABLE` when it did not, for example on a build without Google Play or with no Google Cloud project linked. With `osAttestationInGatewayModes` set to `false` in a gateway mode, the SDK does not request a token, and the proof carries no Play Integrity status at all. The verifier decodes the token and decides what the Play Integrity result means for the proof. `AdvancedConfig.playIntegrityCloudProjectNumber` binds the verdict to a specific Google Cloud project. Leave it `null` (the default) to use the project linked to your app in the Play Console. Play Integrity is a per-proof signal only: it is not part of bootstrap, and activation uses hardware key attestation, so a proof still generates without it. ## Non-compliant Play Integrity results A Play Integrity result that is not compliant, or missing, does not stop the SDK from producing the proof. The token is bound into the proof, and the verifier decides whether to accept it. See [Verifying proofs](/concepts/verifying-proofs). ## Verifying attestation off the device The SDK attaches attestation on the device with no integration code. Off the device, [`octet-verify`](/concepts/verifying-proofs) validates it when built with the `appattest` feature: ```bash cargo build --release --features appattest ``` With that build: - **iOS App Attest** is validated against Apple's embedded App Attest root, with no secret held, so the same proof verifies identically on any machine. - **Android key attestation** validates the Keystore certificate chain to Google's embedded, fingerprint-pinned hardware-attestation root, confirming the key is TEE or StrongBox backed. Both paths are provided by [`octet-attest-verify`](https://github.com/octetproof/octet-attest-verify), a standalone library you can also use directly if you write your own verification logic. Both verify fully offline against Apple's and Google's embedded roots. No network call is required. ## Establishing the hardware root ahead of time Added in 1.2. A verifier normally learns a device's hardware root from the attestation evidence that rides on that device's first proof. `Octet.attestationEnrolmentBundle()` returns that evidence directly, so a verifier can enrol the device's key before any proof arrives. This helps a verifier that was freshly deployed, scaled out, or migrated. On iOS the App Attest object is produced once per key, so pre-enrolment removes the wait for the first proof. On Android every attested proof already carries the full Key Attestation chain, so the bundle is a convenience mirror. See [`attestationEnrolmentBundle()`](/api-reference/octet-start). Two Android fixes in 1.2 affect verification: - The proof now carries the device-key security level (`device_attestation.security_level`) on the wire. A 1.1 serialization bug dropped it, so a verifier gating on a hardware tier could reject a healthy TEE or StrongBox device. A 1.2 proof reports the true tier. - The SDK regenerates the device key before its key-attestation chain ages out. The chain's intermediate is short-lived (about 14 days). The earlier build could present an aged-out chain about two weeks after enrolment, which fails verification. The key is now re-attested while the chain is still valid. ## Where to go next - [Verifying Proofs](/concepts/verifying-proofs): what the verifier checks, including the attestation-root step. - [Verdicts](/concepts/verdicts): the `attestationFailed` reason and the rest of the trichotomy. - [Proof of Location](/concepts/proof-of-location): the location half of the proof, and the device-key security tier. _Source: https://octetproof.com/docs/mobile/concepts/device-attestation/_ --- # Verifying Proofs :::note[In short] The proof is self-contained. With the open-source verifier and the bytes alone, anyone can confirm offline that a proof is signed by the key it carries and has not been altered since. The SDK establishes the location on the device, and the verifier proves that record is genuine. ::: A [proof of location](/concepts/proof-of-location) is only useful if a third party can verify it independently. `octet-verify` is the reference verifier: a standalone command-line tool whose source you can read, build, and audit at [github.com/octetproof/octet-verify](https://github.com/octetproof/octet-verify). It contains none of the SDK's proof-creation or spoof-detection logic, and confirms only that a proof is signed by the key it carries and has not been altered since. That narrow scope is what makes it safe to open-source. To run it, see the [Verifier Quick Start](/getting-started/verifier-quickstart). ## The trust model The trust model has three components: ```mermaid flowchart LR A[Device + OctetSDK
produces & signs the proof] -->|uploads| B[Ingestion API
api.octetproof.com
stores bytes only] B -->|serves the same bytes| C[octet-verify
independent CLI] A -.->|or export the proof to a file| C C --> D{Verdict} ``` The ingestion API is optional. A proof can travel straight from the device to the verifier (the dashed path above). The API is a store-and-serve relay: it holds uploaded proof bytes by id for less than 24 hours so they can be fetched and verified later. Nothing about verifying a proof depends on it. | Component | Trusted to… | Not trusted to… | |---|---|---| | **OctetSDK** (on device) | produce a proof correctly, including determining the location correctly, and sign each stage with the hardware-backed key | vouch for its own signatures. The proof carries everything needed to re-check them, so you verify the signing yourself rather than take the SDK's word. | | **Ingestion API** (`api.octetproof.com`) | receive proof bytes, store them for less than 24 hours, and serve them back | decode, change, sign, or vouch for any proof. It is transport and index only. | | **octet-verify** | check signatures, chain linkage, and field bindings against the keys embedded in the proof | nothing. This is the one piece a recipient must trust, and its source is public. | The load-bearing property: if the backend were fully compromised, the verifier would still reject a tampered proof. None of the verifier's checks rely on anything the backend says. They rely only on the bytes and the cryptographic keys inside the proof. A broken or hostile backend cannot alter a proof without the verifier rejecting it. To also reject a proof signed by a key that is not attested device hardware, use the [`appattest` build](#hardware-attestation-the-appattest-build) with `--require-attestation`. ## What the verifier checks Against the bytes of a single proof, the default build of `octet-verify` confirms: - **Stage signatures.** Every stage of the proof is signed by the same hardware-backed P-256 key the proof carries. - **Chain linkage.** Each stage links to the one before it. The final assembly stage binds every prior signature into a single value. Change any stage and the chain breaks. - **Field bindings.** The commitment, the nullifier, and the `zkProof` field match the hashes they were signed under. A field present with no signed binding fails, so nothing rides along unverified. The `zkProof` field holds a SHA-256 placeholder, not a zero-knowledge proof. - **Semantic-field binding.** The spoofing verdict, the region, the level, the integrity status, and the position commitment are bound to the signed proof. Editing any of them after signing is rejected. - **Session binding.** When the proof was made with a `sessionNonce`, the verifier confirms a hash of that nonce is committed and bound into the signed proof. Your login backend then compares that hash against the nonce it issued. A verifier older than 1.2.0 reports this as `NOT-CHECKED`. See [Proof Binding](/concepts/proof-binding). - **Freshness.** The proof falls within the age window you allow, judged against the signed timestamp rather than the editable top-level field. - **Wire-format guard.** A proof smuggling a duplicate of a single-value field is rejected. Two more checks run when you pass the upload envelope (`--envelope`): - **Transport signature.** An Ed25519 signature ties the whole proof to the enrolled device identity. - **Replay-control binding.** The backend-supplied upload nonce, nullifier, and signed timestamp are confirmed against what the proof itself signed. Semantic-field binding and replay-control binding are new in the v1.1 verifier. Session binding is new in the 1.2 verifier. `octet-verify` 1.5.0 and later verify semantic-binding v3, v2 and v1 proofs. Run 1.5.0 or later with SDK 3.0. `octet-verify` 1.6.0 adds `--require-verdict verified` (or `plausible`), which fails a disc answer signed below that tier. [`Octet.verify`](/concepts/on-device-verification) runs these same checks in-process, offline, on the device. The exact byte layout of every signature is documented in the repo's [`VERIFICATION-SPEC.md`](https://github.com/octetproof/octet-verify/blob/main/VERIFICATION-SPEC.md). ## What a verdict means The verifier's verdict is not the same as the [SDK's verdict](/concepts/verdicts). Keep them separate: - The **SDK** answers *"is the device in the region?"* It returns `YES`, `NO`, or `INDETERMINATE`. - The **verifier** answers *"do this proof's signatures verify, and is it unaltered?"* It returns one of three states: | Verdict | Meaning | |---|---| | **VALID** | The proof passed every check and its signatures verified cryptographically. | | **INCONCLUSIVE** | The proof is structurally fine, but its signatures could not be checked (for example, the hardware public key was not available). An `INCONCLUSIVE` proof has not passed verification. | | **INVALID** | A check failed. The proof is rejected. | Each individual check is reported as `PASS`, `FAIL`, or `NOT-CHECKED`. A `NOT-CHECKED` line never fails a proof and never makes it valid. It appears in the output so the report shows which checks did not run. ### Hardware attestation: the `appattest` build The default build trusts the signing key as carried: a passing proof shows the proof is signed by that key and unaltered, not that the key is genuine device hardware. To close that gap, build with the `appattest` feature: ```bash cargo build --release --features appattest ``` That build validates the [device attestation](/concepts/device-attestation) offline: - **iOS App Attest** verified against Apple's embedded App Attest root. - **Android key attestation** validated up the Keystore certificate chain to Google's embedded hardware-attestation root, confirming a TEE or StrongBox key. These two offline checks run through [`octet-attest-verify`](https://github.com/octetproof/octet-attest-verify), and the `attestation-root` line (Android) and `app-attest` line (iOS) move from `NOT-CHECKED` to `PASS` or `FAIL`. On iOS, `attestation-root` stays `NOT-CHECKED`. See [Device Attestation](/concepts/device-attestation) for the full picture. ## Where to go next - [Verifier Quick Start](/getting-started/verifier-quickstart): build it and verify your first proof. - [Device Attestation](/concepts/device-attestation): what attestation proves, and the `appattest` verification path. - [Proof of Location](/concepts/proof-of-location): what a proof is and why it can be checked. - [Verdicts](/concepts/verdicts): the SDK's `YES`, `NO`, and `INDETERMINATE`, which answer a different question. _Source: https://octetproof.com/docs/mobile/concepts/verifying-proofs/_ --- # On-device Verification :::note[In short] `Octet.verify` checks a proof on the device, offline, with no network call. It runs the same checks as the [`octet-verify`](/concepts/verifying-proofs) command-line tool and reports the same tri-state verdict. ::: A [proof of location](/concepts/proof-of-location) is verified by re-checking its signatures against the keys inside it. `octet-verify` does that as a standalone tool. `Octet.verify` (new in 2.0) does the same work in-process, so an app can check a proof it just produced, or one it received, without a round trip. ## What it runs `Octet.verify` runs ECDSA P-256 stage-signature verification, plus the structural, binding, freshness, region, and wire-format self-consistency checks. These checks catch a tampered field, an edited region, a stale proof, or a forged signature, all offline. When the proof's attestation can be anchored, it also checks the device attestation against the bundled Apple and Google roots, so the proof is hardware-attested. Android proofs carry their own key-attestation chain. For an iOS proof that carries only an assertion, pass `attestationBundle`. Two properties need state that a single proof does not carry, so on the device they always report `NOT-CHECKED`: - **Cross-proof replay-uniqueness.** The Octet proofs backend enforces nullifier uniqueness when a proof is uploaded. `octet-verify --nullifier-store ` adds a local seen-before check across your own runs. - **Revocation.** The Octet backend checks Google's keybox-revocation list. Neither `Octet.verify` nor `octet-verify` consults a revocation list. `Octet.verify` is the fast local gate. ## The verdict `Octet.verify` returns a [`ProofVerification`](/api-reference/octet-verify) with a tri-state `verdict` and two booleans: | Field | Meaning | |---|---| | `verdict` | `valid`, `invalid`, or `inconclusive`. | | `isValid` | No check is `FAIL`. `NOT-CHECKED` and `WARN` never reject. | | `isAuthentic` | `isValid` and `stage-signatures` is `PASS`. A `NOT-CHECKED` signature check does not count. | The rules match `octet-verify`. A check that could not run is reported as `NOT-CHECKED`, and `NOT-CHECKED` never makes a proof authentic. - `verdict` is `invalid` if any check is `FAIL`. - Otherwise `valid` if the proof is authentic. - Otherwise `inconclusive`: the structure passed but the signatures were not verified (for example, an iOS proof with no embedded certificate and no attestation bundle supplied). Read `isAuthentic` (or `verdict == valid`) when you need the cryptographic guarantee. Reading `isValid` alone accepts an `inconclusive` proof. A disc answer verifies whether it was signed `VERIFIED` or `PLAUSIBLE`, so also check `spoofingVerdict`, or set `requireVerdict`, before accepting one. ## Relationship to octet-verify `Octet.verify` and [`octet-verify`](/concepts/verifying-proofs) share one check recipe and one verdict model, so a proof gets the same verdict in both. Use the on-device call for an immediate local gate on the phone. Use `octet-verify` off-device for an audit trail independent of the app, for hardware-attestation validation (`appattest` build), or for a local replay check with `--nullifier-store`. ## Where to go next - [`Octet.verify` API reference](/api-reference/octet-verify): the exact signatures, options, and check taxonomy. - [Verifying Proofs](/concepts/verifying-proofs): the `octet-verify` tool and the full trust model. - [Device Attestation](/concepts/device-attestation): what the hardware-attestation anchoring proves. _Source: https://octetproof.com/docs/mobile/concepts/on-device-verification/_ --- # Proof Binding :::note[In short] By default the SDK may reuse a recent buffered proof for a predicate call. Three optional parameters change that: `sessionNonce` binds a proof to one login, `decisionRef` binds it to one authorization decision, and `forceFresh` mints a new proof with no binding. ::: ## The freshness buffer A predicate call can be answered from a buffered proof that is still inside its validity window, rather than generating a new one every time. That keeps repeated checks cheap. When you need a proof tied to a specific request, or a proof made at this exact moment, use one of the three controls below. Each is an optional parameter on `isWithin`, `isOutside`, and `contains`. ## Session binding (`sessionNonce`) Bind a proof to a single login. Your login backend issues a one-time nonce, and you pass it as `sessionNonce` (opaque bytes, 1 to 512). The SDK commits a hash of the nonce inside the signed proof as a `sessionBinding` stage, so a verifier can confirm the proof was made for that login. Only the hash rides the proof, never the raw nonce. Passing a nonce forces a fresh proof (it bypasses the buffer). An empty or over-512-byte nonce returns `INDETERMINATE / INVALID_SESSION_NONCE` with no proof. ```swift let nonce = try await loginBackend.issueNonce() let v = await sdk.loc.isWithin(region: .country(isoCode: "US"), sessionNonce: nonce) if v.result == .yes { send(v.proof!, to: loginBackend) } ``` ```kotlin val nonce: ByteArray = loginBackend.issueNonce() val v = sdk.loc.isWithin(OctetRegion.country("US"), sessionNonce = nonce) if (v.result == OctetVerdict.Result.YES) send(v.proof!!, loginBackend) ``` ## Decision binding (`decisionRef`) Bind a proof to a specific authorization decision. Pass an opaque, host-minted decision identifier (1 to 256 characters) as `decisionRef`. The SDK fetches a decision-scoped upload nonce (`POST /v1/proofs/challenge` with a `decision_ref` body) so the proofs backend stamps a trusted `challenge_decision_ref` anchor, and it tags the upload envelope with two opaque top-level fields: `decision_ref` (the find key you gave) and `region_ref` (the proof's own claimed region in canonical form, for example `country(US)`). A relying party can then fetch and trust exactly the proof for one decision. An over-256-character ref returns `INDETERMINATE / INVALID_DECISION_REF` with no proof and no network call. A decision ref always asks for a fresh proof, so a query that also pins a past `atTime` returns `INDETERMINATE / INVALID_DECISION_TIME`, with no proof and no network call. Leave out `decisionRef` to ask about a past time. A session nonce binds the proof to a login and commits a hash inside the signed proof. A decision ref binds the uploaded proof to a decision through the upload envelope, and is the key a backend fetches the proof by. ## Force fresh (`forceFresh`) Set `forceFresh: true` to bypass the buffer and mint a proof for this call, with none of the binding semantics above: no committed nonce, no decision-scoped challenge, no envelope tags. Use it when a proof must reflect the device's location right now rather than a cached fix that is still valid, for example a "refresh" button. It does not loosen any spoof check: a fresh proof still runs the full pipeline, and a call that cannot generate one returns `INDETERMINATE` with the reason that blocked it (`NO_FIX` when no fix is available), never a cached `YES`. Combining `forceFresh` with a binding is redundant but harmless, because a binding already bypasses the buffer. ## Choosing between them | Goal | Use | |---|---| | Prove for one login, verifiable in the signed proof | `sessionNonce` | | Bind an uploaded proof to one authorization decision a backend fetches | `decisionRef` | | A proof of the device's location right now, no binding | `forceFresh` | | Cheap repeated checks | none (let the buffer serve) | ## Where to go next - [Predicates](/api-reference/predicates): the exact parameter signatures. - [Verifying Proofs](/concepts/verifying-proofs): how a bound proof is checked. - [Serialization](/api-reference/serialization): the fields a proof commits and serializes. _Source: https://octetproof.com/docs/mobile/concepts/proof-binding/_ --- # Verdicts :::note[In one sentence] A verdict is one of three values, `YES`, `NO`, or `INDETERMINATE`, where the third lets the SDK report that it cannot answer instead of falsely returning `NO`. ::: ## Why not a boolean A boolean would collapse two different outcomes: the device was not in the region, and the SDK could not tell. The SDK cannot tell for a range of distinct reasons, and the `reason` code says which. The main cases: - It has started but has not seen a usable fix yet. - The query is about a moment in the future. - The query is about a moment too old to have a cached proof. - The cached proof is too coarse for the question (for example, a country-level proof cannot answer a city-level question). - Conditions cannot support a proof at the precision you asked for. - The OS flagged the location as mocked. - The device's platform attestation did not pass. - The session ended. - The predicate path is not available in this SDK build. - The supplied session nonce was empty or larger than 512 bytes. Collapsing these into `NO` would hide the difference between *answered no* and *could not answer*. Code that retries on failure, or forwards a `NO` as if it were a proof, would then do the wrong thing. The three values keep the two cases separate. ## The three values ```mermaid flowchart TD Q[Predicate query] --> E{Can the SDK
evaluate it?} E -->|Yes, condition holds| YES[YES + proof] E -->|Yes, condition does not hold| NO[NO + proof] E -->|No| IND[INDETERMINATE
+ ReasonCode] ``` - **`YES`**. The predicate holds, and a proof is attached. - **`NO`**. The predicate does *not* hold, and a proof of the negative is attached. `NO` does not mean "I don't know." - **`INDETERMINATE`**. The SDK cannot answer. The `reason` field says why, and the `proof` is `nil`. ## Reason codes | `ReasonCode` | When | Result | |---|---|---| | `OK` | Proof covers `atTime`. Predicate evaluated cleanly. | `YES` or `NO` | | `NO_FIX` | SDK started but no fix yet. | `INDETERMINATE` | | `FUTURE_TIME` | `atTime` is in the future beyond ±2 s clock-skew tolerance. | `INDETERMINATE` | | `STALE_FIX` | `atTime` falls outside the validity window of any cached proof. | `INDETERMINATE` | | `NO_PROOF_AT_RESOLUTION` | Cached proof is too coarse for the query (for example, country-level proof vs. city-level question). | `INDETERMINATE` | | `INSUFFICIENT_PRECISION` | Conditions cannot support a proof at the requested precision. `achievableLevel` names the best level reachable. | `INDETERMINATE` | | `ATTESTATION_FAILED` | Device attestation did not pass. | `INDETERMINATE` | | `MOCK_LOCATION_DETECTED` | OS flagged mocked location for this fix. | `INDETERMINATE` | | `SDK_NOT_RUNNING` | `Octet.start(...)` was never called or session ended. | `INDETERMINATE` | | `NOT_YET_RELEASED` | Predicate path declared in the API but not available in this SDK build. | `INDETERMINATE` | | `INVALID_SESSION_NONCE` | Supplied `sessionNonce` is empty or exceeds 512 bytes. Added in 1.2. | `INDETERMINATE` | | `INVALID_DECISION_REF` | Supplied `decisionRef` exceeds 256 characters. Added in 2.0. | `INDETERMINATE` | | `INVALID_DECISION_TIME` | A query with `decisionRef` set was pinned to a past `atTime`. Added in 2.0. | `INDETERMINATE` | | `SPOOFING_DETECTED` | The spoof pipeline flagged the fix. Adversarial. Added in 2.0. | `INDETERMINATE` | | `TAMPERING` | Device-integrity tampering (root / hook), or an attached debugger or native tracer outside a sandbox session. Adversarial. Added in 2.0. | `INDETERMINATE` | | `REGION_UNRESOLVED` | A country or subdivision predicate could not resolve the region at the required confidence. Since 3.0, also a pinned `claimedRegion` that is not an assigned ISO 3166 code. Benign. Added in 2.0. | `INDETERMINATE` | ### Adversarial and benign The measurement-related `INDETERMINATE` reasons split into two classes so a policy layer can treat them differently. **Adversarial** (a detected forgery or compromise): `SPOOFING_DETECTED`, `MOCK_LOCATION_DETECTED`, `TAMPERING`, `ATTESTATION_FAILED`. Deny. **Benign** (the SDK could not measure): `REGION_UNRESOLVED`, `INSUFFICIENT_PRECISION`, `NO_FIX`, `STALE_FIX`, `FUTURE_TIME`, `NO_PROOF_AT_RESOLUTION`. A policy layer can permit these with a step-up. `NO_PROOF_AT_RESOLUTION` means the proof on hand is too coarse for the question: ask again, or ask at the level the proof covers. Both classes carry no proof. The distinction is in the reason. The remaining reasons are not detections. `SDK_NOT_RUNNING`, `NOT_YET_RELEASED`, `INVALID_SESSION_NONCE`, `INVALID_DECISION_REF` and `INVALID_DECISION_TIME` point to the calling code or the SDK build, so fix the call rather than stepping up the user. The `reason` code names the category that blocked the proof (for example `ATTESTATION_FAILED` or `MOCK_LOCATION_DETECTED`). The SDK does not surface the sensor-level detail beneath it (which signal fired, or how), because that detail would tell an attacker what to defeat. On a simulator or emulator the SDK returns `NO_FIX`. That reflects the development environment, not a detected attack. ## Assurance tier A `YES` or `NO` carries a proof, and the proof carries an assurance tier, `spoofingVerdict`: `VERIFIED` when independent evidence supports the fix, `PLAUSIBLE` when the fix is consistent with the query but not independently corroborated. Read the tier, not the region granularity, to decide what a `YES` is worth. See [`OctetVerdict`](/api-reference/octet-verdict#the-assurance-tier). ## Achievable level When the result is `INDETERMINATE` with reason `INSUFFICIENT_PRECISION`, the verdict carries an `achievableLevel`: the best precision the SDK can reach for this query right now. The SDK does not answer a coarser question than the one you asked. Read it to choose between re-requesting at the coarser level or applying your own fallback: ```swift // iOS if verdict.reason == .insufficientPrecision, let level = verdict.achievableLevel { // e.g. you asked for city, the SDK can prove country right now. // Re-request at `level`, or treat as not-good-enough for your use case. } ``` ```kotlin // Android if (verdict.reason == VerdictReason.INSUFFICIENT_PRECISION) { val level = verdict.achievableLevel // best level reachable now, or null } ``` ## Strict-boolean callers If your logic needs a boolean, collapse the verdict yourself. Only a `YES` maps to `true`. `NO` and `INDETERMINATE` both map to `false`, so read `reason` first if you need to tell them apart. To also require the highest assurance tier, check the proof's `spoofingVerdict`: ```swift // iOS let ok = verdict.result == .yes && verdict.proof?.spoofingVerdict == .verified ``` ```kotlin // Android val ok = verdict.result == OctetVerdict.Result.YES && verdict.proof?.spoofingVerdict == LocationProofVerdict.VERIFIED ``` Some domains (KYC, sanctions, payments) need a stricter acceptance rule than any `YES`. The SDK does not apply one. Base yours on the result, the reason, and the assurance tier. ## Where to go next - [Time Semantics](/concepts/time-semantics) for how `atTime`, `validity`, and the staleness window interact. - [Regions](/concepts/regions) for why `NO_PROOF_AT_RESOLUTION` can occur when a cached proof exists. - [OctetVerdict API Reference](/api-reference/octet-verdict) for the exact field types. _Source: https://octetproof.com/docs/mobile/concepts/verdicts/_ --- # Regions :::note[In one sentence] An `OctetRegion` is the *area* you ask about (country, city, disc, polygon, bounding box), and its shape determines how the SDK proves containment. ::: ## How shapes map to evidence Different region shapes are answered in different ways. A *disc* needs a precise location fix. A *city* resolves to a polygon set. Letting callers supply a free-form lat/lon polygon would force the SDK to triangulate and re-quantize every query, which the H3-based proof format does not support. Polygons are therefore accepted only as H3 cell sets. ```mermaid flowchart TD R[OctetRegion] --> Named[Named lookups] R --> Geometric[Geometric shapes] Named --> Country[country isoCode] Named --> Sub[subdivision isoCode] Named --> City[city name] Geometric --> Disc[disc center radius] Geometric --> Ellipse[ellipse center axes heading] Geometric --> Box[box3D lat lon alt ranges] Geometric --> Poly[polygonSet H3 cells] Geometric --> Earth[earth maxAltitude] ``` ## The shapes | Factory | Verified via | Typical use | |---|---|---| | `earth(maxAltitudeMeters)` | Always `YES`. The altitude is not tested. | Sanity check / fallback. | | `country(isoCode)` | Resolved on the device and bound into the signed proof. | "Is the user in the US?" | | `subdivision(isoCode)` | Resolved on the device at the state or province level. | ISO 3166-2 codes: `US-CA`, `FR-75`, `JP-13`. | | `usState(stateCode)` | Same as `subdivision`. | Sugar for `subdivision("US-XX")`. | | `city(name)` | SDK resolves the name to an H3 polygon set. The proof is verified against that polygon. | "Is the user in San Francisco?" | | `disc(center, radiusMeters)` | The proof carries the disc as an ellipse with equal axes. The SDK checks containment from the distance between the centers and the two radii. | "Is the device within 250 m of a saved anchor point?" | | `ellipse(center, semiMajorM, semiMinorM, headingDeg)` | 2D ground ellipse. Disc is a special case. Only discs are answered today: an ellipse with unequal axes returns `INDETERMINATE` with `NOT_YET_RELEASED`. | GNSS uncertainty ellipses, oriented zones. | | `box3D(latRange, lonRange, altRange)` | 3D bounding box. | Volumetric containment, building floors. | | `polygonSet(cells)` | Union of H3 cells. | Custom geofences pre-quantized to H3. | All factories validate eagerly. Invalid lat/lon, malformed ISO codes, non-positive radii, ellipse axes out of order: all trap at construction. An invalid region never reaches a predicate call. ## Pinned claimed region A pinned `claimedRegion` must be an assigned ISO 3166 code. A malformed or unassigned code produces no proof (`REGION_UNRESOLVED`). ## Why polygons are H3-only If you have a free-form polygon (a delivery zone, an event venue, a building footprint), you (or your tooling) quantize it to H3 cells ahead of time and pass the cell list. The SDK compares those cell IDs with the cells of the proof's claimed region on the device, so nothing is triangulated at request time. The claimed cells are part of the signed proof. The comparison treats each cell ID as an exact value. When every claimed cell is in your list, the answer is `YES`. When none of them is, the answer is `NO`. Any other overlap is `INDETERMINATE` with `NO_PROOF_AT_RESOLUTION`. A cell ID also encodes its resolution, but the comparison doesn't relate cells across resolutions: a coarse cell in your list doesn't match the finer cells inside it. ## Construction helpers (Android only) On Android, two additional surfaces let you build regions dynamically: - `getRegion(RegionSpec.country("US"))`. Symbolic lookup, useful when the region name comes from configuration or a server response. The `city` / `namedZone` variants currently throw `OctetFutureFlag` and are not available in this release. - `buildRegion { disc(center = LatLon(37.42, -122.08), radiusMeters = 250.0) }`. A DSL over the static factories for assembling regions from runtime data. The `RegionSpec` DSL and `getRegion` are Android-only. iOS exposes the static factories. ## Inspecting a region Every region has `.toStr()`, `.toJson()` and `.toJsonl()`. See [Serialization](/api-reference/serialization). Android also exposes two free functions: - **`whatisRegion(r)`**. Short, human-readable. For log lines and debug overlays. **Not stable** across SDK versions. - **`regionToStr(r)`**. Canonical, machine-readable, **stable** form. Suitable for log diffs and idempotency keys. To turn a region back into an `OctetRegion`, pass its `toJson()` output to `regionFromJson`. ## Where to go next - [OctetRegion API Reference](/api-reference/octet-region) for the exact signatures and validation rules. - [Verdicts](/concepts/verdicts) for what happens when the cached proof's region is coarser than your query. _Source: https://octetproof.com/docs/mobile/concepts/regions/_ --- # Time Semantics :::note[In short] Every predicate takes an `atTime`, and every `YES` or `NO` verdict carries the *interval* over which its proof answers the question. A proof attests to a moment in time, not an ongoing state. ::: ## The `atTime` parameter `atTime` sets the moment the predicate asks about. A verifier usually cares about the moment of a business event: when the order was placed, when the auth attempt happened, when the camera was triggered. `atTime` defaults to "now", so most calls look like: ```swift let v = await sdk.loc.isWithin(region: .country(isoCode: "US"), atTime: Date()) ``` You can also pass any past instant within the SDK's cached-proof window. The SDK will search for a proof whose validity covers it. ## How `atTime` is resolved ```mermaid flowchart LR A[atTime in the future?] -->|yes, >2s| F[INDETERMINATE / FUTURE_TIME] A -->|no| B[atTime near now?] B -->|±2s| L[Live query: use ring buffer
or trigger a fresh proof] B -->|older| H[Historical query: search
cached proofs for a covering one] L --> R[Result] H -->|covered| R H -->|not covered| S[INDETERMINATE / STALE_FIX] ``` 1. **Future**. `atTime > now + 2 s` returns `INDETERMINATE / FUTURE_TIME`. The SDK does not predict. 2. **Live**. Within ±2 s of `now`. The SDK searches its ring buffer at the right resolution. On a miss, it can trigger a fresh proof. 3. **Historical**. Older. The SDK searches cached proofs for one whose `[from, to]` interval covers `atTime`. On a miss, `INDETERMINATE / STALE_FIX`. The 2 s tolerance is the SDK's internal clock-skew budget. It is not configurable. ## Validity intervals A `LocationProof` is good for a window around its fix time, not forever. The SDK's per-resolution windows reflect how quickly a moving user can leave the claimed region: | Disclosure level | Window (± from fix) | Reasoning | |---|---|---| | `country` | 5 min | A commercial flight is the fastest realistic country-change vector. | | `subdivision` | 3 min | Margin against transitions across small US states. | | `city` | 1 min | A 5 km city radius is crossed in ~3 min at 100 km/h. | | `continuousArea` / `continuousVolume` | 30 s | Hundred-meter scales, which a person running can cross in seconds. | The `validity` field on every `YES` / `NO` verdict gives the window that applies to that proof. Do not assume a flat 30 s for everything. If your business rule needs a tighter time bound than `validity` provides, reject the verdict. :::caution `validity` is the interval the underlying proof covers. It does not say how long the verdict will stay true. ::: ## Validity in practice The SDK reports the interval its proof answers, not the moment of the predicate call. That distinction matters when: - A user asks "where was I at 3:00:00 pm" and the proof was fixed at 3:00:30 pm. With a 60 s window, that proof covers 2:59:30 to 3:01:30. So `atTime = 3:00:00` is answered with `validity = [2:59:30, 3:01:30]`. - A 5-minute country-level proof can answer many country-level predicate calls without regenerating. The verifier sees one proof. The integrator sees the same `validity` repeated across the verdicts. ## Where to go next - [Verdicts](/concepts/verdicts) for the full `ReasonCode` taxonomy including `STALE_FIX` and `FUTURE_TIME`. - [Predicates API Reference](/api-reference/predicates) for the exact signatures. - [OctetVerdict API Reference](/api-reference/octet-verdict) for the `validity` field type. _Source: https://octetproof.com/docs/mobile/concepts/time-semantics/_ --- # Session Lifecycle :::note[In short] `Octet.start(...)` brings the SDK up and returns a handle. `OctetSdk.close()` (new in 2.0) tears it down and stops all background work. Between the two, the SDK runs a proof pipeline and, on Android, a location foreground service. ::: ## Starting [`Octet.start(...)`](/api-reference/octet-start) bootstraps the license, brings up the proof pipeline, and returns an `OctetSdk` handle. Hold that handle for as long as you need proofs. Calling `start` a second time in one process is not supported. Reuse the handle you already have. ## Running While the handle is live, the SDK keeps a location and sensor session running so a predicate can produce a proof on demand. On Android this session runs inside a location foreground service, which Android requires for location work while the app is off-screen. The service posts a notification titled "Location active" on a channel named "Location". Android requires this notification for every location foreground service, so it cannot be hidden while the service runs. ## Closing `OctetSdk.close()` releases every resource the handle holds and stops all background work: the location and sensor session, the Android foreground service (which removes its notification), and the periodic license heartbeat. ```swift let sdk = try await Octet.start(config: config) let verdict = await sdk.loc.isWithin(region: .country(isoCode: "US")) // … prove during the flow … await sdk.close() // async on iOS ``` ```kotlin val sdk = Octet.start(context, config) val verdict = sdk.loc.isWithin(OctetRegion.country("US")) // … prove during the flow … sdk.close() // synchronous on Android ``` `close()` is `async` on iOS and synchronous on Android. After `close()` the handle is spent: a predicate call on a closed handle returns `INDETERMINATE`. Call `Octet.start` again for a new session. ## When to close Close the handle when the flow that needs proofs ends. An app that proves on a foreground consent screen should `close()` when that screen closes, so location collection matches an "only while in use" claim. Leaving the handle open keeps the Android foreground service (and its high-power location work) running. ## Where to go next - [`OctetSdk`](/api-reference/octet-sdk): the handle, `loc`, `flags`, and `close()`. - [`Octet.start(...)`](/api-reference/octet-start): how the session is brought up. _Source: https://octetproof.com/docs/mobile/concepts/lifecycle/_ --- # License & Activation :::note[In short] The SDK gets its license by attesting your app on the first call to `Octet.start(...)`, then works from a local lease. The lease lifecycle below (refresh, offline grace, states, revocation) is unchanged from 1.x. ::: ## Getting a license Since 2.0, the SDK obtains its license by attesting your app at first launch, not by carrying a key. Register your app once against your Octet account, then call `Octet.start(...)`. See [Attested bootstrap](/concepts/attested-bootstrap) for the handshake and [Prerequisites](/getting-started/prerequisites) for the registration steps. For local development, CI, and simulators, use a sandbox bypass token. Use of the SDK is governed by the [Mobile SDK terms](https://octetproof.com/terms/mobile/). The current terms live at `/terms/mobile/`. A dated snapshot lives at `/terms/mobile/vMM.DD.YYYY/`. The first call bootstraps the license over the network. Subsequent launches read from a local lease and do not bootstrap again until the lease needs to refresh. After a successful activation the SDK keeps working offline for up to 14 days (the offline grace). As of 1.2, when a lease response returns a refreshed license token, the SDK persists it. The device then carries a fresh token across restarts throughout the offline-grace window, rather than falling back to the token it first activated with. ## Validity Octet bills per active device: the SDK sends one usage record per device per UTC day, with no identity and no coordinates (see [Metrics](/concepts/metrics)). Aggregate usage counters are on by default and are described under [Usage telemetry](#usage-telemetry). Verifying happens in your own code, offline. See [pricing](https://octetproof.com/pricing). Licenses renew automatically, so you do not need to replace a working license. By default a license has no device cap, so you can install on as many devices as you like. Octet can set a device cap on a license. When the cap is reached, a new device fails to activate with `deviceLimitReached`. Devices that already activated keep working and keep refreshing their lease. ## License states Octet maintains active licenses, so a license in normal use stays in `ACTIVE`. The `GRACE_PERIOD` and `EXPIRED` states below handle prolonged offline periods and revocation. Age alone does not expire a maintained license. ```mermaid stateDiagram-v2 [*] --> NOT_ACTIVATED: license verified locally NOT_ACTIVATED --> ACTIVE: activation succeeds ACTIVE --> RENEWAL_RECOMMENDED: < 30 days to expiry ACTIVE --> GRACE_PERIOD: cached past exp, offline RENEWAL_RECOMMENDED --> GRACE_PERIOD: same trigger GRACE_PERIOD --> EXPIRED: past grace period NOT_ACTIVATED --> INVALID: signature / server reject ACTIVE --> INVALID: server reject EXPIRED --> [*] INVALID --> [*] ``` Read the state at any time: ```swift // iOS if let status = sdk.licenseStatus { print("state: \(status.state), days left: \(status.daysUntilHardStop ?? -1)") } ``` ```kotlin // Android sdk.licenseStatus?.let { status -> println("state: ${status.state}, days left: ${status.daysUntilHardStop ?: -1}") } ``` `LicenseStatus.state` drives in-app UI only. It never drives the cryptographic gate. Forcing `state = ACTIVE` in your own code accomplishes nothing. ## Failure modes `Octet.start(...)` throws a typed `LicenseError`. The complete taxonomy is in [License Types](/api-reference/license-types). The common cases: - `BootstrapFailed(reason:)`. Attested bootstrap failed. `reason` names the cause. Fix: see [`BootstrapReason`](/api-reference/license-types#bootstrapreason). - `MalformedKey`. The license token the SDK holds did not verify. Fix: confirm the SDK is on a supported version, and contact support if it persists. - `NoActivation`. First launch, and the device is offline. Fix: retry when network returns. - `Expired`. Cached activation past the offline grace, backend unreachable. Fix: reconnect so the SDK can re-activate. - `ActivationWindowClosed`. Not returned under the current maintained-license model. Retained for compatibility. Fix: contact support. - `Revoked`. Admin revoke (key leaked, abuse). Fix: contact support. - `Network(message:)`. Transient. Fix: retry. - `ServerRejected(httpStatus:, reason:)`. Anything else from the backend. Fix: see `reason`. ## Usage telemetry The SDK reports aggregate usage counters to the license backend, indexed by your license: how many proofs were generated, uploaded, or could not be produced, by coarse level and region type. The counters carry no location data: no coordinates, no region IDs, no proof contents. They are buffered in an encrypted file in the app's private storage and uploaded once a day, plus a best-effort flush when the app enters the background. Telemetry is on by default. Disable it with `telemetryEnabled = false` on [`OctetConfig`](/api-reference/octet-start). Disabling deletes any buffered file. Separately, the SDK sends one usage record per device per UTC day so Octet can count active devices for billing. That record is always on. See [Metrics and usage reporting](/concepts/metrics). ## SDK version gating Added in 1.2. The SDK reports its version and platform on every backend request. The backend can act on that in two ways: - A hard gate. `Octet.start(...)` throws `LicenseError.upgradeRequired(minVersion:message:)` when the backend rejects an out-of-support version. - A soft hint. `LicenseStatus.upgradeRecommended` and `minSupportedVersion` flag a version behind the recommended floor without stopping the SDK. Both paths were inert when they were added in 1.2, because the backend gated no version yet. Octet turns gating on server-side, without an SDK release, so handle `upgradeRequired` and read `upgradeRecommended` now. See [License Types](/api-reference/license-types) for the field and case shapes. ## Where to go next - [Prerequisites](/getting-started/prerequisites) for how to register your app. - [License Types API Reference](/api-reference/license-types) for the exact types of `LicenseStatus` and `LicenseError`. - [Troubleshooting FAQ](/troubleshooting/faq) for what to do when activation fails. _Source: https://octetproof.com/docs/mobile/concepts/license-activation/_ --- # Gateway routing :::note[In short] `TransportPolicy` (`OctetConfig.advanced.transport`, new in 3.0) chooses where the SDK's network calls go: straight to Octet, through an HTTPS host you run, or through Octet's hosted gateway. ::: By default the SDK talks to the Octet backend at `api.octetproof.com` and, for a few enrichment calls, to a third-party host: IP location on iOS, and GNSS broadcast-ephemeris servers on Android. If your app's network surface is audited, for example by a firewall allowlist, an app-store network disclosure or a rule that the app talks only to your own domain, you can route every SDK request through one host. ## The three modes | Mode | Swift | Kotlin | Where calls go | |---|---|---|---| | Direct (default) | `.direct` | `Mode.DIRECT` | Octet and the third-party hosts, as in 2.0. | | Integrator gateway | `.integratorGateway` | `Mode.INTEGRATOR_GATEWAY` | Every first-party call, and the third-party lookups, go to the HTTPS host you set in `gateway`. | | Octet gateway | `.octetGateway` | `Mode.OCTET_GATEWAY` | Every call goes to Octet's hosted gateway. | In both gateway modes, the SDK sends every request to `{gateway}{path}`. Activation, verification, and proof and flag signing still terminate at Octet. The gateway only forwards requests, so the trust model is unchanged and only the network path moves. ```swift // Your own gateway let config = OctetConfig( licenseKey: "", advanced: AdvancedConfig( transport: TransportPolicy( mode: .integratorGateway, gateway: "https://api.example.com/octet"))) // Octet's gateway let hosted = OctetConfig( licenseKey: "", advanced: AdvancedConfig(transport: .octetGateway)) ``` ```kotlin // Your own gateway val config = OctetConfig( licenseKey = "", advanced = AdvancedConfig( transport = TransportPolicy( mode = TransportPolicy.Mode.INTEGRATOR_GATEWAY, gateway = "https://api.example.com/octet"))) // Octet's gateway val hosted = OctetConfig( licenseKey = "", advanced = AdvancedConfig(transport = TransportPolicy.octetGateway)) ``` ### Octet gateway The Octet gateway gives the app a single backend domain without a proxy for you to run. The SDK sends every call, first-party and third-party, to Octet's hosted gateway. The host is built in, so there is no `gateway` to supply and any value you set is ignored. This mode uses standard certificate-authority validation, and `pins` is ignored. `fallbackToDirect` applies as in the integrator-gateway mode. On iOS, the Octet gateway also serves the IP-based country hint. ## `TransportPolicy` options | Option | Default | Effect | |---|---|---| | `mode` | direct | One of the three modes above. | | `gateway` | `nil` / `null` | Base URL of your gateway. Used only in the integrator-gateway mode. | | `thirdParty` | `.proxy` / `ThirdParty.PROXY` | Sends third-party lookups through the gateway. `.disable` / `ThirdParty.DISABLE` skips them in any mode, and a proof is still produced. | | `pins` | empty | SPKI pins for the app-to-gateway TLS connection. Ignored in the Octet-gateway mode. | | `fallbackToDirect` | `false` | After repeated gateway failures, critical calls go direct until the gateway recovers. | | `fallbackAfterFailures` | `3` | How many transport failures in a row trip the fallback. | | `osAttestationInGatewayModes` | `true` | `false` skips per-proof OS attestation in gateway modes, so proofs carry no hardware attestation. | ## What your gateway must forward This table covers the integrator-gateway mode. | The SDK sends (under your gateway) | Forward it to | Notes | |---|---|---| | `/v1/bootstrap/challenge`, `/v1/bootstrap`, `/v1/heartbeat` | `https://api.octetproof.com/v1/…` | Activation. Never modify the body. | | `/v1/flags`, `/v1/metrics`, `/v1/credits/…` | `https://api.octetproof.com/v1/…` | Flag bundles are signed. Never modify. | | `/v1/proofs`, `/v1/proofs/auth`, `/v1/proofs/challenge` | `https://api.octetproof.com/v1/…` | Proof body up to 256 KiB. Never modify. | | `/v1/exposure` | `https://api.octetproof.com/v1/…` | Both SDKs. Usage reporting, sent only with `telemetryEnabled` on. | | `/v1/network/observe` | `https://api.octetproof.com/v1/…` | Both SDKs. A `GET` with no body. | | `/v1/integrity/decode` | `https://api.octetproof.com/v1/…` | Both SDKs. Sent only when your app calls `Octet.verifyOnline` with a Play Integrity decoder. | | `/ext/ipgeo/…` | Optional, see [IP-based country hint](#ip-based-country-hint) | iOS SDK only. IP location hint. | | `/ext/gnss/…` | `https://cddis.nasa.gov/archive/gnss/data/daily/…` (fallback `https://igs.ign.fr/pub/igs/data/…`) | Android SDK only. GNSS ephemeris. Large files, and the upstream may need NASA Earthdata credentials. | | `/ext/geo/…` | `https://api.octetproof.com/ext/geo/…` | Both SDKs. First-party and bearer-authenticated, so forward it like a `/v1/…` route: keep `Authorization` and send `Host: api.octetproof.com`. | Forward `/ext/geo/` on both platforms: without it, country and subdivision proofs return `INDETERMINATE` (`REGION_UNRESOLVED`). `fallbackToDirect` does not cover a missing route, because it trips only on transport failures. Forward `/ext/gnss/` only if you ship the Android SDK. ### Forwarding rules - **Preserve the request exactly.** Keep the method, the path after your prefix, the query string, and all headers, especially `Authorization`, `Content-Type`, and any `X-Octet-…` header. - **Never modify the body** of `/v1/proofs`, `/v1/flags`, `/v1/bootstrap/challenge` or `/v1/bootstrap`. Each signature covers the exact bytes, so any rewrite, including re-serializing the JSON, breaks verification. - **Use TLS 1.2 or higher to the upstream**, verify the upstream certificate, and send `Host: api.octetproof.com` with matching SNI on the first-party routes. - **Pass status codes and the `Retry-After` header through unchanged.** The SDK honors `429`. - **Allow request bodies of at least 256 KiB**, and give the upstream a timeout at least as long as the SDK's. Activation and attestation can be slow. ## Reference configurations Replace `api.example.com/octet` with your own gateway base URL, and keep only the `ext` blocks for the platforms you ship. **nginx** ```nginx # All first-party routes are under /v1/: activation, flags, metrics, credits, # proofs (incl. /v1/proofs/auth + /v1/proofs/challenge). location /octet/v1/ { proxy_pass https://api.octetproof.com/v1/; proxy_ssl_server_name on; proxy_set_header Host api.octetproof.com; proxy_pass_request_headers on; client_max_body_size 256k; } # Android apps only: location /octet/ext/gnss/ { proxy_pass https://cddis.nasa.gov/archive/gnss/data/daily/; proxy_ssl_server_name on; } # All apps (first-party, bearer-authed): location /octet/ext/geo/ { proxy_pass https://api.octetproof.com/ext/geo/; proxy_ssl_server_name on; proxy_set_header Host api.octetproof.com; } ``` **Cloudflare Worker** ```js const UPSTREAM = { "/v1/": "https://api.octetproof.com/v1/", // all first-party (incl. /v1/proofs/auth, /v1/proofs/challenge) "/ext/gnss/": "https://cddis.nasa.gov/archive/gnss/data/daily/", // Android apps "/ext/geo/": "https://api.octetproof.com/ext/geo/", // all apps (first-party) }; export default { async fetch(request) { const url = new URL(request.url); const path = url.pathname.replace(/^\/octet/, ""); // strip your gateway prefix const match = Object.entries(UPSTREAM).find(([prefix]) => path.startsWith(prefix)); if (!match) return new Response("not found", { status: 404 }); const [prefix, base] = match; const target = base + path.slice(prefix.length) + url.search; // Forward method, headers, and body untouched; return the upstream response as-is. return fetch(target, { method: request.method, headers: request.headers, body: request.body, redirect: "follow", }); }, }; ``` **AWS** - **API Gateway (HTTP API).** Add one HTTP-proxy integration per prefix. For example, route `ANY /octet/v1/{proxy+}` to `https://api.octetproof.com/v1/{proxy}`, and add one each for `/octet/ext/geo/` and, for Android, `/octet/ext/gnss/`. Leave request-parameter mapping untouched so headers and the query string pass through unchanged. - **CloudFront.** Define one origin per upstream and one cache behavior per path pattern (`/octet/v1/*`, `/octet/ext/geo/*`, `/octet/ext/gnss/*`). Attach an origin-request policy that forwards all headers, query strings, and cookies, and the managed `CachingDisabled` cache policy so nothing is buffered or rewritten. Set each origin to HTTPS-only. ## Gateway-mode settings ### Certificate pinning The SDK's built-in pins are for Octet's certificates, so they do not apply once traffic goes through your gateway: the app sees your certificate. To keep pinning on the app-to-gateway connection, pass your gateway's SPKI pins in `pins`: ```swift transport: TransportPolicy( mode: .integratorGateway, gateway: "https://api.example.com/octet", pins: ["", ""]) ``` ```kotlin transport = TransportPolicy( mode = TransportPolicy.Mode.INTEGRATOR_GATEWAY, gateway = "https://api.example.com/octet", pins = listOf("", "")) ``` Compute a pin with: ```sh openssl x509 -in cert.pem -pubkey -noout | openssl pkey -pubin -outform DER \ | openssl dgst -sha256 -binary | openssl enc -base64 ``` Pin a stable point in the chain, such as a CA intermediate or root. A managed certificate (Cloudflare, ACME, Let's Encrypt) gets a new key on renewal, which breaks a pin on the leaf certificate the next time it rotates. Always include a backup pin. Leave `pins` empty, the default, to rely on standard certificate-authority validation. ### Fallback to direct `fallbackToDirect` is off by default, so a gateway outage makes the SDK's calls fail. Set it to `true` to keep the SDK working through an outage. After `fallbackAfterFailures` transport-level failures in a row (connect, TLS, DNS or timeout, and never an HTTP status), the SDK sends the critical calls (activation, proof upload, and the daily usage record) directly to Octet. It then checks your gateway again and switches back once the gateway recovers. While the fallback is active, those calls go to `api.octetproof.com` directly, so the app no longer talks to a single domain. A deployment that must keep to one domain should leave it off. Telemetry and flags always stay on the gateway. ```swift transport: TransportPolicy( mode: .integratorGateway, gateway: "https://api.example.com/octet", fallbackToDirect: true, fallbackAfterFailures: 3) ``` ```kotlin transport = TransportPolicy( mode = TransportPolicy.Mode.INTEGRATOR_GATEWAY, gateway = "https://api.example.com/octet", fallbackToDirect = true, fallbackAfterFailures = 3) ``` ### OS attestation in gateway modes In a gateway mode, the SDK still attaches OS attestation to proofs by default, at the configured `attestationCadence`. That includes App Attest on iOS, which calls Apple, and Play Integrity on Android, which calls Google. These calls cannot go through a proxy. Set `osAttestationInGatewayModes` to `false` to skip them. The proofs then carry no App Attest evidence on iOS, and no key-attestation chain and no Play Integrity verdict on Android. They are still signed by the device key and verify as authentic, but they are not hardware-attested. `octet-verify` reports them as `attested: false` in `--json` output and fails them under `--require-attestation`. `Octet.verify` reports `hardware-attestation` as `NOT-CHECKED` and fails them when `requireAttestation` is set. Gate acceptance on attestation, not on validity alone. Startup activation still attests, so the app still activates. ```swift transport: TransportPolicy( mode: .octetGateway, osAttestationInGatewayModes: false) // un-attested proofs ``` ```kotlin transport = TransportPolicy( mode = TransportPolicy.Mode.OCTET_GATEWAY, osAttestationInGatewayModes = false) // un-attested proofs ``` ### IP-based country hint This applies to the iOS SDK. With your own gateway, Octet provides no upstream for `/ext/ipgeo/`. Leave that route unrouted, or set `TransportPolicy.thirdParty` to `.disable` so the SDK skips the lookup. The hint is optional, and a proof is still produced either way. The Octet gateway serves the hint itself. ## Where to go next - [`Octet.start`](/api-reference/octet-start#advancedconfig) for the full `AdvancedConfig` and `TransportPolicy` shapes. - [Device Attestation](/concepts/device-attestation) for what an un-attested proof means to a verifier. _Source: https://octetproof.com/docs/mobile/concepts/gateway-routing/_ --- # Metrics and usage reporting :::note[In short] The SDK sends two kinds of usage data, and neither carries coordinates. Aggregate metrics counters are on by default and turn off with `OctetConfig.telemetryEnabled = false`. The daily usage record, one per device per UTC day, is always on, because Octet counts active devices. ::: ## Metrics counters When `telemetryEnabled` is on (the default), the SDK reports counters to `{activationServerUrl}/v1/metrics`, indexed by your license: how many proofs were generated, uploaded, or could not be produced, by coarse level and region type. The counters are buffered in an encrypted file in the app's private storage and uploaded once a day, plus a best-effort flush when the app enters the background. A metrics report contains no coordinates and no proof contents. Octet turns on additional counters through remote configuration, and they are on for all licenses. They include region codes, for example the ISO 3166 code of each region your app checks (`US` or `US-NY`) with the verdict, and the code a background proof resolved to. See [Data collection](/concepts/data-collection#usage-counters) for the full list. | Sent | Not sent | |---|---| | Counts of proofs generated / uploaded / not produced, by coarse level and region type, and region codes with per-proof events (see [Usage counters](/concepts/data-collection#usage-counters)) | Coordinates or proof contents | | SDK version, platform, OS version, app id | Any identifier that resolves to a person | | A pseudonymous device fingerprint (a one-way SHA-256 hash of the install UUID and a platform device identifier) | The install UUID or the platform identifier itself | ### Turning metrics off Set `OctetConfig.telemetryEnabled = false`. That disables the metrics upload and deletes any buffered report. It does not affect the daily usage record. ## Daily usage record New in 3.0. To count active devices, the SDK sends one usage record per device per UTC day: 1. The app generates its first proof of the UTC day. 2. The SDK queues a single `device_active` record. This needs no network, and the proof is already complete. 3. The SDK sends the record with the next background heartbeat. Later proofs that day send nothing more. **What it contains.** The operation name, a timestamp and a record ID. The record ID is a one-way SHA-256 hash of the device's opaque activation fingerprint and the date, so it changes every day and lets the server ignore duplicates. The device is counted from the activation credentials the SDK already holds. The record carries no location, no coordinates, no proof data, no user or account identity, and no new device identifier. **It never affects a proof.** The record is written after the proof exists. A failed send is retried on later heartbeats, and a record that could not be sent within 30 days is dropped. A sandbox session sends nothing. **It is always on.** Reporting cannot be switched off. `OctetConfig.creditServiceUrl` defaults to `https://credits.octetproof.com` (`OctetConfig.defaultCreditServiceUrl` on iOS, `OctetConfig.DEFAULT_CREDIT_SERVICE_URL` on Android). Set it only to point a test build at another environment. Setting it to `nil` / `null` does not disable reporting. In a [gateway mode](/concepts/gateway-routing), the record goes through your gateway under `/v1/credits/…`. ## Where to go next - [Data collection](/concepts/data-collection): how to declare this data in the App Store and Play Console. - [License & Activation](/concepts/license-activation): how the SDK reaches the backend. _Source: https://octetproof.com/docs/mobile/concepts/metrics/_ --- # Data collection :::note[In short] This page lists the data the SDK handles, so you can complete Apple's App Store privacy details and Google Play's Data safety form. The SDK uses no data for advertising or tracking. With `proofUploadUrl` unset (the default) and the default direct transport mode, the SDK sends no coordinates to Octet. Country and state lookups send coordinates to the operating system's geocoder or, in a gateway mode, to the gateway. See [Location lookups](#location-lookups). ::: ## What the SDK handles | Data | Leaves the device | Purpose | Linked to identity | Tracking | |---|---|---|---|---| | Precise location | To Octet: only if you set `proofUploadUrl` or use a gateway mode. See [Uploaded proofs](#uploaded-proofs) and [Location lookups](#location-lookups). | App functionality (location proofs) | No¹ | No | | Coarse location | To Octet: yes, as region codes in the usage counters, and in uploaded proofs if you set `proofUploadUrl`. See [Usage counters](#usage-counters). | App functionality (country-level proofs), Analytics (region codes in the usage reports) | No¹ | No | | Device ID (device fingerprint) | Yes: license activation and usage reports, and bound into proofs | App functionality, anti-fraud | No¹ | No | | Product interaction (usage counters) | Yes, unless `telemetryEnabled = false` | Analytics: usage counters and per-proof events, no coordinates | No | No | | Product interaction (daily usage record) | Yes, always: one record per device per day | Billing (active-device count), no location | No | No | ¹ The device fingerprint is a pseudonymous one-way hash, not an account identity, and the SDK does not link this data to a user identity. On iOS the SDK keeps it in the Keychain, so it survives an app reinstall. On Android it changes when the app is reinstalled. If your app associates proofs with a user account, classify the data accordingly on your own form. The daily usage record is new in 3.0 and cannot be turned off. See [Metrics and usage reporting](/concepts/metrics) for what each report contains. ## Uploaded proofs With `proofUploadUrl` set, the SDK uploads each proof it generates. ## Location lookups To check a country or US state, the SDK turns the device's coordinates into a country or state code. Where that lookup runs depends on the transport mode (see [Gateway routing](/concepts/gateway-routing)). - **Direct mode (the default).** The SDK calls the operating system's geocoder: `CLGeocoder` on iOS, `Geocoder` on Android. Apple or Google receives the coordinates. The SDK sends no coordinates to Octet. - **Gateway modes.** The SDK sends the coordinates to `{gateway}/ext/geo/reverse` instead of the operating system's geocoder. With your own gateway (`integratorGateway` on iOS, `INTEGRATOR_GATEWAY` on Android), your gateway receives them and forwards the request to Octet. In `octetGateway` (`OCTET_GATEWAY`) mode, Octet's gateway receives them. The request carries the full-precision latitude and longitude and the SDK's activation credential, so the receiver can link the lookup to the activated device. Two other requests carry no location: - **IP-based country hint (iOS only).** In direct mode, the iOS SDK requests the device's country from `ipwho.is`. The service sees the device's IP address and the standard `User-Agent` header that iOS adds to every request, which names your app, its build number and the iOS version. In a gateway mode the request goes to `{gateway}/ext/ipgeo/` instead. Set `TransportPolicy.thirdParty = .disable` to skip the lookup. Octet can also switch the lookup off through remote configuration. The Android SDK makes no IP lookup. - **Border-map updates.** The SDK downloads updated border maps from `/ext/geo/borders/` on the host it already uses. These requests carry no location and no device data. ## Usage counters The usage counters carry no coordinates. Octet also turns on additional usage reporting through remote configuration, without an app release, and it is on for all licenses. The usage reports also include: - The ISO 3166 country or subdivision code of each region your app checks (for example `US` or `US-NY`), with the verdict. - The country or subdivision code the device was resolved to by a background proof, whether or not your app asked about that region. - How the location was found, for example GPS or the cell network. - The proof's confidence and the time it took to produce, each reported as a range. - The app's location-permission state: granted, denied, not determined or restricted. - The category of an internal SDK error and where in the SDK it occurred. - Which remote-configuration variant the device received, with a pseudonymous device key. None of these reports carries coordinates. Set `telemetryEnabled = false` to stop all usage counters and the additional reporting. To keep the basic usage counters but not the additional reporting, set `flagsEnabled = false`, which stops the SDK from receiving remote configuration. These requests continue with `telemetryEnabled = false`: - **License heartbeat.** Renews the SDK's activation in the background. - **Remote-configuration fetch.** Sends the SDK version, platform, OS version, your app ID and a pseudonymous device key. - **Daily usage record.** See [Metrics and usage reporting](/concepts/metrics#daily-usage-record). ## iOS: privacy manifest The xcframework bundles a `PrivacyInfo.xcprivacy` privacy manifest. Xcode adds it to your app's privacy report at build time, so no action is needed beyond reviewing it alongside your own disclosures. It declares: - **Collected data types.** Precise location, coarse location and device ID, each for App Functionality. Coarse location should also be declared for Analytics, because of the region codes in the usage reports. The manifest in SDK 3.0.0 leaves that out, and a later release adds it, so add Analytics for coarse location in your App Store privacy details yourself. Product interaction, for Analytics (the usage counters) and Other (the daily usage record, used for billing). Every type is declared as not linked to the user and not used for tracking. - **Tracking.** None, and no tracking domains. - **Required-reason APIs.** System boot time, for the SDK's anchored license clock, and `UserDefaults`, for a one-time device-ID migration. When you upgrade from 2.x, re-check your App Store privacy details so they include product interaction for the Other purpose. ## Android: Data safety form Android has no privacy manifest inside the library, so the table above is the statement to use for your app's Play Console Data safety form. The SDK's permissions merge into your manifest automatically. See [Prerequisites](/getting-started/prerequisites) for the permission list. ## Where to go next - [Metrics and usage reporting](/concepts/metrics). What the counters and the daily record contain. - [Gateway routing](/concepts/gateway-routing). Which hosts the SDK contacts, and how to route them through one domain. _Source: https://octetproof.com/docs/mobile/concepts/data-collection/_ --- # API Reference Overview A location query is a predicate call on the SDK. It returns an `OctetVerdict`, and a `YES` or `NO` verdict carries a signed proof. ## The full surface ```mermaid flowchart TD A[Octet.start config] --> B[OctetSdk handle] B --> L[OctetSdk.loc] B --> S[OctetSdk.licenseStatus] L --> P1[isWithin region atTime] L --> P2[isOutside region atTime] L --> P3[contains center tol atTime] P1 --> V[OctetVerdict] P2 --> V P3 --> V V --> R[result YES NO INDETERMINATE] V --> RC[reason ReasonCode] V --> PR[proof LocationProof?] V --> VAL[validity Interval?] V --> C[confidence ConfidenceSummary] ``` ## Per-symbol reference | Symbol | What it is | |---|---| | [`Octet.start(...)`](/api-reference/octet-start) | The single bring-up call. Attests the app, bootstraps a license, returns an `OctetSdk`. Also exposes `attestationEnrolmentBundle()`, and documents `OctetConfig`, `AdvancedConfig` and `TransportPolicy`. | | [`OctetSdk`](/api-reference/octet-sdk) | The handle returned by `start`. Exposes `loc`, `flags`, `licenseStatus`, and `close()`. | | [Predicates](/api-reference/predicates) | `isWithin`, `isOutside`, `contains` on `sdk.loc`, each with optional `sessionNonce`, `decisionRef`, `forceFresh`. | | [`OctetRegion`](/api-reference/octet-region) | The shape you query against. Factories plus `regionFromJson`. | | [`OctetVerdict`](/api-reference/octet-verdict) | The answer. Carries `result`, `reason`, `proof`, `validity`, `confidence`. | | [`Octet.verify`](/api-reference/octet-verify) | On-device proof verification: `ProofVerification`, `ProofCheck`, `CheckStatus`, `VerifyOptions`, `RequiredVerdict`. | | [License Types](/api-reference/license-types) | `LicenseStatus`, `LicenseState`, `LicenseError`, `BootstrapReason`. | | [Serialization](/api-reference/serialization) | `.toStr()`, `.toJson()`, `.toJsonl()` on `OctetRegion`, `OctetVerdict`, `LatLon`, `H3Cell` and `Interval`. | ## Platform asymmetry notes | Surface | iOS | Android | |---|---|---| | `OctetRegion` factories | All | All | | `RegionSpec` + `getRegion(spec)` | n/a | ✓ | | `buildRegion { … }` DSL | n/a | ✓ | | `whatisRegion(r)` / `regionToStr(r)` free functions | (via `.toStr()` extension) | ✓ (also `.toStr()`) | iOS callers use the factories directly and `region.toStr()` for the human-readable form. ## Conventions used throughout - **Code samples are shown in tabs**, Swift on the left, Kotlin on the right. Pick your platform once. The choice is remembered across the site. - **Async**. `Octet.start` is `async throws` (Swift) / `suspend` (Kotlin). The predicates (`isWithin`, etc.) are `async` and do not throw. License problems throw a `LicenseError` from `Octet.start`. Runtime problems become `INDETERMINATE` reasons on the verdict. - **Validation is eager.** Region factories trap on malformed inputs at construction. The predicate API never sees an invalid region. - **Time is `Date` on iOS, `java.time.Instant` on Android.** Both default to "now". - **`Meters` is a `Double` typealias** on both platforms (a plain `Double`, not a wrapper type). - **`LatLon(latitude:, longitude:)`** is the coordinate type. WGS84, pre-validated. ## Where to start If you're integrating for the first time, go to the [iOS Quick Start](/getting-started/ios-quickstart) or [Android Quick Start](/getting-started/android-quickstart). They walk through `Octet.start`, an `isWithin` query and `Octet.verify` end to end. _Source: https://octetproof.com/docs/mobile/api-reference/overview/_ --- # `Octet.start(...)` The single bring-up call. It obtains the license by attesting the app (Apple App Attest, Android key attestation), brings up the proof pipeline, and returns an `OctetSdk` handle. See [Attested bootstrap](/concepts/attested-bootstrap) for the licensing model. ## Signature ```swift public enum Octet { public static let sdkVersion: String // "3.0.0" public static func start( config: OctetConfig, startPosition: Position? = nil ) async throws -> OctetSdk public static func attestationEnrolmentBundle() -> AttestationEnrolmentBundle? } ``` ```kotlin object Octet { const val SDK_VERSION: String // "3.0.0" suspend fun start( context: Context, config: OctetConfig, startPosition: Position? = null, ): OctetSdk fun attestationEnrolmentBundle(): AttestationEnrolmentBundle? } ``` `startPosition` is an optional hint used internally during pipeline bring-up. Most integrators omit it. ## What it does, in order 1. Loads (or generates) a per-install UUID from secure storage (Keychain on iOS, `EncryptedSharedPreferences` on Android). 2. Reads the app identity from the bundle (`Bundle.main.bundleIdentifier` / `context.packageName`). 3. Reuses a valid cached lease if present. Otherwise bootstraps: attests the app and calls `POST /v1/bootstrap` to mint a device-bound license and lease. See [Attested bootstrap](/concepts/attested-bootstrap). 4. Brings up the internal proof pipeline. 5. Attaches the resulting `LicenseStatus` to the returned `OctetSdk`. ## `OctetConfig` ```swift public struct OctetConfig: Sendable { public let licenseKey: String // required by the config type, ignored at runtime; pass "" public var proofUploadUrl: String? // opt-in proof upload; nil disables (default) public var telemetryEnabled: Bool // aggregate usage counters; default true public var creditServiceUrl: String? // daily usage record endpoint; nil = default (testing override only) public var flagsEnabled: Bool // remote configuration (sdk.flags); default true public var flagServiceUrl: String? // remote configuration endpoint; nil = activation host (testing override only) public var advanced: AdvancedConfig public var sandboxBypassToken: String? // dev / CI / simulator bootstrap; nil disables public init( licenseKey: String, proofUploadUrl: String? = nil, telemetryEnabled: Bool = true, creditServiceUrl: String? = nil, flagsEnabled: Bool = true, flagServiceUrl: String? = nil, advanced: AdvancedConfig = AdvancedConfig(), sandboxBypassToken: String? = nil ) public static let defaultActivationServerUrl: String // "https://api.octetproof.com" public static let defaultCreditServiceUrl: String // "https://credits.octetproof.com" } ``` ```kotlin data class OctetConfig( val licenseKey: String, // required by the config type, ignored at runtime; pass "" val proofUploadUrl: String? = null, // opt-in proof upload; null disables (default) val telemetryEnabled: Boolean = true, // aggregate usage counters; default true val creditServiceUrl: String? = null, // daily usage record endpoint; null = default (testing override only) val flagsEnabled: Boolean = true, // remote configuration (sdk.flags); default true val flagServiceUrl: String? = null, // remote configuration endpoint; null = activation host (testing override only) val advanced: AdvancedConfig = AdvancedConfig(), val sandboxBypassToken: String? = null, // dev / CI / simulator bootstrap; null disables ) { companion object { const val DEFAULT_ACTIVATION_SERVER_URL: String // "https://api.octetproof.com" const val DEFAULT_CREDIT_SERVICE_URL: String // "https://credits.octetproof.com" } } ``` `licenseKey` is required by the config type but ignored by `Octet.start`, which activates through [attested bootstrap](/concepts/attested-bootstrap). Pass an empty string on native (Swift and Kotlin). The React Native wrapper validates it as non-empty, so pass any non-empty placeholder there (for example `"unused"`). For local development, CI, and simulators, set `sandboxBypassToken` to bootstrap without hardware attestation. `creditServiceUrl` is the endpoint for the [daily usage record](/concepts/metrics#daily-usage-record). It defaults to `https://credits.octetproof.com`. Set it only to point a test build at another environment. Setting it to `nil` / `null` does not disable reporting. `flagsEnabled` switches [`sdk.flags`](/api-reference/octet-sdk#flags), the read-only remote configuration, on or off. It defaults to `true`. On Android it is the fifth constructor parameter, unchanged since 2.0. ## `AdvancedConfig` Battery profile, sensor tuning, and ML parameters are internal. `AdvancedConfig` exposes the activation server, log level, iOS certificate pinning, [device-attestation](/concepts/device-attestation) cadence, the per-platform attestation settings, opt-in `PLAUSIBLE` containment, and the network transport. ```swift public struct AdvancedConfig: Sendable { public var activationServerUrl: String // default: production public var logLevel: LogLevel // default: .info public var enableCertPinning: Bool // default: false public var acceptPlausibleContainment: Bool // default: false public var attestationCadence: AttestationCadence // default: .periodic(interval: 300) public var transport: TransportPolicy // default: .direct public init( activationServerUrl: String = OctetConfig.defaultActivationServerUrl, logLevel: LogLevel = .info, enableCertPinning: Bool = false, acceptPlausibleContainment: Bool = false, attestationCadence: AttestationCadence = .periodic(interval: 300), transport: TransportPolicy = .direct ) } public struct TransportPolicy: Sendable { public enum Mode: Sendable { case direct, octetGateway, integratorGateway } public enum ThirdParty: Sendable { case proxy, disable } public init( mode: Mode = .direct, gateway: String? = nil, thirdParty: ThirdParty = .proxy, pins: [String] = [], fallbackToDirect: Bool = false, fallbackAfterFailures: Int = 3, osAttestationInGatewayModes: Bool = true ) public static let direct: TransportPolicy public static let octetGateway: TransportPolicy } public enum LogLevel { case verbose, debug, info, warn, error } public enum AttestationCadence: Sendable { case perSession case periodic(interval: TimeInterval) // default; 5 minutes case perProof } ``` ```kotlin data class AdvancedConfig( val activationServerUrl: String = OctetConfig.DEFAULT_ACTIVATION_SERVER_URL, val logLevel: LogLevel = LogLevel.INFO, // binds the per-proof Play Integrity verdict to a GCP project; null = Play Console project val playIntegrityCloudProjectNumber: Long? = null, val attestationCadence: AttestationCadence = AttestationCadence.Periodic(intervalSeconds = 300), val acceptPlausibleContainment: Boolean = false, val transport: TransportPolicy = TransportPolicy.direct, ) data class TransportPolicy( val mode: Mode = Mode.DIRECT, val gateway: String? = null, val thirdParty: ThirdParty = ThirdParty.PROXY, val pins: List = emptyList(), val fallbackToDirect: Boolean = false, val fallbackAfterFailures: Int = 3, val osAttestationInGatewayModes: Boolean = true, ) { enum class Mode { DIRECT, OCTET_GATEWAY, INTEGRATOR_GATEWAY } enum class ThirdParty { PROXY, DISABLE } companion object { val direct: TransportPolicy val octetGateway: TransportPolicy } } enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR } sealed class AttestationCadence { object PerSession : AttestationCadence() data class Periodic(val intervalSeconds: Int) : AttestationCadence() // default; 5 minutes object PerProof : AttestationCadence() } ``` Override `activationServerUrl` only if Octet gives you a specific URL to point at. It defaults to production. `attestationCadence` and the Android `playIntegrityCloudProjectNumber` are covered in [Device Attestation](/concepts/device-attestation). `enableCertPinning` (iOS only) opts into the bundled certificate pin set for `api.octetproof.com`. On Android, opt in by setting `android:networkSecurityConfig="@xml/octet_network_security_config"` on your manifest's `` element. Both pin sets are separate from `TransportPolicy.pins`, which pins your own gateway in `integratorGateway` mode. The Android `playIntegrityCloudProjectNumber` binds the per-proof [Play Integrity](/concepts/device-attestation#play-integrity-on-android) verdict to a Google Cloud project. Leave it `null` to use the project linked in the Play Console. `acceptPlausibleContainment` (new in 3.0, default off) lets `contains(...)` and a disc `isWithin(...)` answer `YES` or `NO` from a `PLAUSIBLE` proof. See [Predicates](/api-reference/predicates#plausible-containment). `transport` (new in 3.0) chooses where the SDK's network calls go. See [Gateway routing](/concepts/gateway-routing). Android: `AdvancedConfig` gained constructor parameters in 3.0. Code compiled against 2.0.0 that calls its constructor or `copy` must be recompiled. Source that uses named arguments compiles unchanged. ## `attestationEnrolmentBundle()` Added in 1.2. Returns this device key's `AttestationEnrolmentBundle`, or `nil` before the device key has been attested. Attestation first happens on the install's first proof. The read is local (a Keychain entry on iOS, a Keystore entry on Android) and makes no network call. Hand the bundle to a verifier's enrolment step so the verifier can establish this device's hardware root ahead of time, without waiting for the once-per-key attestation object to arrive on a submitted proof. This helps a verifier that was freshly deployed, scaled out, or migrated. ```swift public struct AttestationEnrolmentBundle: Sendable { public func jsonString() -> String // canonical v:1 envelope public func protoData() throws -> Data // DeviceAttestation proto bytes } ``` ```kotlin class AttestationEnrolmentBundle internal constructor(/* … */) { fun jsonString(): String fun protoData(): ByteArray } ``` On iOS, App Attest produces the object once per key, so a verifier that has not yet seen a proof from this device cannot check its hardware root until the bundle arrives. On Android, every attested proof already carries the full Key Attestation certificate chain, so the bundle duplicates that chain and is optional. ## Example ```swift let config = OctetConfig(licenseKey: "") // required by the type, ignored by start; the SDK attests the app let sdk = try await Octet.start(config: config) ``` ```kotlin lifecycleScope.launch { // licenseKey required by the type, ignored by start; the SDK attests the app val config = OctetConfig(licenseKey = "") val sdk = Octet.start(context = applicationContext, config = config) } ``` ## Failure modes `Octet.start(...)` throws a typed [`LicenseError`](/api-reference/license-types) for every license-related failure. Other failures propagate as their native error types. | `LicenseError` case | Meaning | |---|---| | `MalformedKey` | The license token the SDK holds did not verify. `licenseKey` is not involved. | | `NoActivation` | No cached activation, offline. | | `Expired` | Cached activation past the offline grace with the backend unreachable to refresh. | | `ActivationWindowClosed` | Not returned under the current maintained-license model. Retained for compatibility. | | `Revoked` | Admin revoke. | | `Network(message)` / `Network(cause)` | Transient network failure during activation. | | `ServerRejected(httpStatus, reason)` | Backend rejected for another reason (e.g., `app_blocked`). | | `UpgradeRequired(minVersion, message)` | Backend rejected this SDK version as out of support. | | `BootstrapFailed(reason)` | Attested bootstrap failed. `reason` is a `BootstrapReason` (attestation invalid, app not registered, sandbox-token issues, …). | `BootstrapFailed` carries a `BootstrapReason`. When the account accepts no new devices, the reason is `newDevicesBlocked` (`NewDevicesBlocked` on Android) and a server-signed `upgradeUrl` comes with it: on iOS as the reason's associated value, on Android as `BootstrapFailed.upgradeUrl`. See [License Types](/api-reference/license-types) and [Attested bootstrap](/concepts/attested-bootstrap). `UpgradeRequired` is SDK-version upgrade gating: the SDK reports its version on every backend request, and the backend can refuse an out-of-support version at `Octet.start` or return the softer `LicenseStatus.upgradeRecommended` and `minSupportedVersion` hints. ## See also - [License & Activation](/concepts/license-activation) for the timeline model and activation flow. - [`OctetSdk`](/api-reference/octet-sdk) for what `start` returns. - [License Types](/api-reference/license-types) for the full `LicenseError` reference. _Source: https://octetproof.com/docs/mobile/api-reference/octet-start/_ --- # `OctetSdk` The handle returned by [`Octet.start(...)`](/api-reference/octet-start). Holds the predicate surface (`loc`), read-only remote configuration (`flags`) and a snapshot of license state (`licenseStatus`), and exposes `close()`. ## Shape ```swift public final class OctetSdk: @unchecked Sendable { public let loc: OctetLoc // flags: read-only remote configuration (see `flags` below) public internal(set) var licenseStatus: LicenseStatus? public func close() async // release + stop all activity } ``` ```kotlin class OctetSdk { val loc: OctetLoc // flags: read-only remote configuration (see `flags` below) var licenseStatus: LicenseStatus? internal set fun close() // release + stop all activity } ``` `licenseStatus` is optional in the type. Callers that obtained `sdk` via `Octet.start(...)` always see a non-nil value, since `start` attaches it before returning. ## `loc` `OctetSdk.loc` is the only way to issue a predicate query. See [Predicates](/api-reference/predicates) for `isWithin`, `isOutside`, `contains`. ## `close()` `close()` releases every resource the handle holds and stops all background work: the location and sensor session, the Android location foreground service, and the license heartbeat. It is `async` on iOS and synchronous on Android. After `close()` the handle is spent, and a predicate call on it returns `INDETERMINATE`. See [Session Lifecycle](/concepts/lifecycle). ## `licenseStatus` Read synchronously at any time. See [License Types](/api-reference/license-types) for the field shape. ```swift if let status = sdk.licenseStatus { print("\(status.state): \(status.daysUntilHardStop ?? -1) days left") } ``` ```kotlin sdk.licenseStatus?.let { status -> println("${status.state}: ${status.daysUntilHardStop ?: -1} days left") } ``` ## `flags` Read-only remote configuration. `getBoolean(key, default)`, `getInt(key, default)`, `getString(key, default)` and `getAssignment(experiment)` read a signed bundle that the SDK fetches at `start` and refreshes on each heartbeat. An unknown or wrong-typed key, a missing bundle, or a disabled subsystem returns the default you pass. Set `OctetConfig.flagsEnabled = false` to turn it off. ## Lifecycle notes - Calling `Octet.start(...)` a second time within the same process is not supported. Hold the first `OctetSdk` for as long as you need predicates, then `close()` it. - `OctetSdk` is `Sendable` (Swift) and thread-safe to use from any coroutine context (Kotlin). The predicates serialize internally. ## See also - [`Octet.start(...)`](/api-reference/octet-start). How to obtain an `OctetSdk`. - [Predicates](/api-reference/predicates). The methods on `sdk.loc`. - [`OctetVerdict`](/api-reference/octet-verdict). What predicates return. _Source: https://octetproof.com/docs/mobile/api-reference/octet-sdk/_ --- # Predicates The three predicate methods on `OctetSdk.loc`. All three are `async` (Swift) / `suspend` (Kotlin) and do not throw. A runtime problem produces an `INDETERMINATE` verdict. ## `isWithin` The primary predicate. Tests whether the device's provable location at `atTime` (default now) is inside `region`. ```swift public func isWithin( region: OctetRegion, atTime: Date = Date(), sessionNonce: Data? = nil, decisionRef: String? = nil, forceFresh: Bool = false ) async -> OctetVerdict ``` ```kotlin suspend fun isWithin( region: OctetRegion, atTime: Instant = Instant.now(), sessionNonce: ByteArray? = null, decisionRef: String? = null, forceFresh: Boolean = false, ): OctetVerdict ``` ### Example ```swift let v = await sdk.loc.isWithin( region: .country(isoCode: "US"), atTime: Date() ) if v.result == .yes { ship(v.proof!) } ``` ```kotlin val v = sdk.loc.isWithin( region = OctetRegion.country("US"), atTime = Instant.now() ) if (v.result == OctetVerdict.Result.YES) ship(v.proof!!) ``` --- ## `isOutside` Tests whether the device's provable location at `atTime` (default now) is outside `region`. ```swift public func isOutside( region: OctetRegion, atTime: Date = Date(), sessionNonce: Data? = nil, decisionRef: String? = nil, forceFresh: Bool = false ) async -> OctetVerdict ``` ```kotlin suspend fun isOutside( region: OctetRegion, atTime: Instant = Instant.now(), sessionNonce: ByteArray? = null, decisionRef: String? = null, forceFresh: Boolean = false, ): OctetVerdict ``` :::caution `isOutside` is a separate query, not the negation of `isWithin`. A `YES` from `isOutside` carries a proof that the device is outside `region`, and a verifier checks that proof the same way as any other. An `INDETERMINATE` from `isWithin` does not mean the device is outside. Negating it would merge two outcomes: the device is not inside `region`, and the SDK could not tell. ::: --- ## `contains` Tests whether the point `center` is within `tol` meters of the device's provable location. ```swift public func contains( center: LatLon, tol: Meters, atTime: Date = Date(), sessionNonce: Data? = nil, decisionRef: String? = nil, forceFresh: Bool = false ) async -> OctetVerdict ``` ```kotlin suspend fun contains( center: LatLon, tol: Meters, atTime: Instant = Instant.now(), sessionNonce: ByteArray? = null, decisionRef: String? = null, forceFresh: Boolean = false, ): OctetVerdict ``` Mathematically equivalent to `isWithin(region: .disc(center: center, radiusMeters: tol), atTime:)`. Kept as a distinct verb because the device-centric reading is the natural one for proximity use cases ("am I near the delivery dropoff", "did I arrive at the geofence"). ### `PLAUSIBLE` containment A disc answer, from `contains(...)` or a disc `isWithin(...)`, carries an [assurance tier](/api-reference/octet-verdict#the-assurance-tier) on its proof. By default a disc query answers `YES` or `NO` only from a `VERIFIED` proof, and otherwise returns `INDETERMINATE`. New in 3.0: setting `OctetConfig.advanced.acceptPlausibleContainment = true` lets it answer from a `PLAUSIBLE` proof as well. Tight tolerances pass only with a tight location fix. Read `proof.spoofingVerdict` before treating a `YES` as authoritative. ```swift let config = OctetConfig( licenseKey: "", advanced: AdvancedConfig(acceptPlausibleContainment: true)) ``` ```kotlin val config = OctetConfig( licenseKey = "", advanced = AdvancedConfig(acceptPlausibleContainment = true)) ``` ### Custom predicates `isWithin` is the load-bearing primitive, and `contains` is a one-line wrapper over it. Custom predicates can be built the same way: ```swift extension OctetLoc { func isNear(_ center: LatLon, _ tol: Meters) async -> OctetVerdict { await isWithin(region: .disc(center: center, radiusMeters: tol)) } func isInsideAny(_ regions: [OctetRegion]) async -> OctetVerdict? { for r in regions { let v = await isWithin(region: r) if v.result == .yes { return v } } return nil } } ``` ```kotlin suspend fun OctetLoc.isNear(center: LatLon, tol: Meters): OctetVerdict = isWithin(OctetRegion.disc(center, tol)) suspend fun OctetLoc.isInsideAny(regions: List): OctetVerdict? { for (r in regions) { val v = isWithin(r) if (v.result == OctetVerdict.Result.YES) return v } return null } ``` ## Time semantics All three predicates take an optional `atTime`. See [Time Semantics](/concepts/time-semantics) for the live / historical / future regimes and the per-resolution validity windows. Default is "now". Future times beyond ±2 s come back `INDETERMINATE / FUTURE_TIME`. ## Proof binding and freshness All three predicates take three optional controls over which proof answers the call: - **`sessionNonce`** (opaque bytes, 1 to 512) binds the proof to one login. The SDK commits a hash of the nonce inside the signed proof, so a verifier can confirm the proof was made for that login. An empty or over-512-byte nonce returns `INDETERMINATE / INVALID_SESSION_NONCE`. - **`decisionRef`** (1 to 256 characters) binds the uploaded proof to one authorization decision, tagging the upload envelope with `decision_ref` and `region_ref`. An over-256-character ref returns `INDETERMINATE / INVALID_DECISION_REF`, with no network call. Pass `nil` / `null`, not an empty string, when there is no decision. - **`forceFresh`** bypasses the freshness buffer and mints a proof for this call, with none of the binding semantics above. Each of `sessionNonce` and `decisionRef` forces a fresh proof. Setting `forceFresh` as well has no additional effect. ```swift let nonce = try await loginBackend.issueNonce() let v = await sdk.loc.isWithin(region: .country(isoCode: "US"), sessionNonce: nonce) if v.result == .yes { send(v.proof!, to: loginBackend) } ``` [Proof Binding](/concepts/proof-binding) explains when to use each, and how a bound proof is verified. ## See also - [`OctetRegion`](/api-reference/octet-region). The shapes you can pass. - [`OctetVerdict`](/api-reference/octet-verdict). What you get back. - [Proof Binding](/concepts/proof-binding). Binding a proof to a login or a decision. - [Verdicts](/concepts/verdicts). The trichotomy and the `ReasonCode` taxonomy. _Source: https://octetproof.com/docs/mobile/api-reference/predicates/_ --- # `OctetRegion` The shape a predicate is evaluated against. Constructed via static factory methods that validate inputs eagerly. Invalid regions trap at construction. ## Factories ### `country` (ISO 3166-1) ```swift public static func country(isoCode: String) -> OctetRegion // e.g. OctetRegion.country(isoCode: "US") ``` ```kotlin @JvmStatic fun country(isoCode: String): CountryRegion // e.g. OctetRegion.country("US") ``` `isoCode` must be ISO 3166-1 alpha-2 (two uppercase letters). ### `subdivision` (ISO 3166-2) ```swift public static func subdivision(isoCode: String) -> OctetRegion // e.g. OctetRegion.subdivision(isoCode: "US-CA") ``` ```kotlin @JvmStatic fun subdivision(isoCode: String): SubdivisionRegion // e.g. OctetRegion.subdivision("US-CA") ``` Country code, hyphen, 1–3 alphanumeric chars. ### `usState` ```swift public static func usState(_ stateCode: String) -> OctetRegion // OctetRegion.usState("CA") == OctetRegion.subdivision(isoCode: "US-CA") ``` ```kotlin @JvmStatic fun usState(stateCode: String): SubdivisionRegion // OctetRegion.usState("CA") == OctetRegion.subdivision("US-CA") ``` Takes a standard two-letter US postal abbreviation. ### `city` ```swift public static func city(name: String) -> OctetRegion // e.g. OctetRegion.city(name: "San Francisco") ``` ```kotlin @JvmStatic fun city(name: String): CityRegion // e.g. OctetRegion.city("San Francisco") ``` The SDK resolves `name` to an H3 polygon set internally. ### `disc` ```swift public static func disc(center: LatLon, radiusMeters: Meters) -> OctetRegion // e.g. OctetRegion.disc(center: LatLon(37.422, -122.084), radiusMeters: 250) ``` ```kotlin @JvmStatic fun disc(center: LatLon, radiusMeters: Meters): EllipseRegion // e.g. OctetRegion.disc(LatLon(37.422, -122.084), 250.0) ``` A disc is stored internally as a degenerate ellipse (equal axes, heading 0). `.toStr()` labels it "Disc". The JSON form encodes it as an ellipse (`"shape":"ellipse"`). See [Serialization](/api-reference/serialization). ### `ellipse` ```swift public static func ellipse( center: LatLon, semiMajorM: Meters, semiMinorM: Meters, headingDeg: Double // [0, 360), clockwise from north ) -> OctetRegion ``` ```kotlin @JvmStatic fun ellipse( center: LatLon, semiMajorM: Meters, semiMinorM: Meters, headingDeg: Double, // [0, 360), clockwise from north ): EllipseRegion ``` Pre-validated: `semiMinorM > 0`, `semiMajorM >= semiMinorM`, `headingDeg ∈ [0, 360)`. Only discs are answered in this release. A predicate on an ellipse with unequal axes returns `INDETERMINATE` with `NOT_YET_RELEASED`. ### `box3D` ```swift public static func box3D( latRange: ClosedRange, lonRange: ClosedRange, altRange: ClosedRange ) -> OctetRegion ``` ```kotlin @JvmStatic fun box3D( latRange: ClosedFloatingPointRange, lonRange: ClosedFloatingPointRange, altRange: ClosedFloatingPointRange, ): BoundingBox3DRegion ``` Latitude validated to `[-90, 90]`, longitude to `[-180, 180]`, altitude finite. ### `polygonSet` ```swift public static func polygonSet(cells: [H3Cell]) -> OctetRegion ``` ```kotlin @JvmStatic fun polygonSet(cells: List): PolygonSetRegion ``` Non-empty cell list. Free-form lat/lon polygons are not accepted. Quantize to H3 ahead of time. See [Regions](/concepts/regions) for why. ### `earth` ```swift public static func earth(maxAltitudeMeters: Meters = 10_000) -> OctetRegion ``` ```kotlin @JvmStatic fun earth(maxAltitudeMeters: Meters = 10_000.0): EarthRegion ``` `isWithin(.earth(...))` always returns `YES` if the SDK can produce any proof at all. The SDK does not test the device's altitude against `maxAltitudeMeters`, so the answer is `YES` at any altitude. ## Value types ```swift public struct LatLon: Sendable, Hashable { public let latitude: Double // [-90, 90] public let longitude: Double // [-180, 180] } public typealias Meters = Double ``` ```kotlin data class LatLon(val latitude: Double, val longitude: Double) { // validated [-90,90] and [-180,180] at construction } typealias Meters = Double @JvmInline value class H3Cell(val cellId: ULong) // 64-bit H3 cell index ``` ## Construction helpers (Android only) ### `RegionSpec` + `getRegion` ```kotlin sealed class RegionSpec { data class Country(val isoCode: String) : RegionSpec() data class Subdivision(val isoCode: String) : RegionSpec() data class City(val name: String) : RegionSpec() data class NamedZone(val id: String) : RegionSpec() } suspend fun getRegion(spec: RegionSpec): OctetRegion ``` `country` and `subdivision` are local and return immediately. `city` and `namedZone` are declared but currently throw `OctetFutureFlag` and are not available in this release. ### `buildRegion { … }` DSL ```kotlin val r = buildRegion { disc(center = LatLon(37.422, -122.084), radiusMeters = 250.0) } ``` Sugar over the static factories. Synchronous. Same validation rules. ## Inspecting a region Available on all platforms via the uniform `.toStr()` / `.toJson()` / `.toJsonl()` methods. See [Serialization](/api-reference/serialization). Android additionally exposes the free functions: ```kotlin fun whatisRegion(r: OctetRegion): String // human, NOT stable fun regionToStr(r: OctetRegion): String // machine, STABLE wire form ``` ## Decoding a region: `regionFromJson` New in 2.0. The inverse of `toJson()` / `toJsonl()`: it decodes the same tagged shape back into an `OctetRegion`, round-tripping every factory. It exists so a caller can hand a region across a language boundary (a server payload, a stored region) without re-modelling the shapes. ```swift public func regionFromJson(_ json: String) throws -> OctetRegion ``` ```kotlin fun regionFromJson(json: String): OctetRegion ``` Input is treated as untrusted. Unlike the region factories (which trap on malformed input), `regionFromJson` always throws `OctetRegion.DecodeError` on malformed, missing, wrong-typed, or out-of-range input. ## See also - [Regions](/concepts/regions). Why the taxonomy looks the way it does. - [Predicates](/api-reference/predicates). What consumes an `OctetRegion`. - [Serialization](/api-reference/serialization). `.toStr()` / `.toJson()` / `.toJsonl()` on every region. _Source: https://octetproof.com/docs/mobile/api-reference/octet-region/_ --- # `OctetVerdict` The outcome of a predicate call. Returned by `isWithin`, `isOutside`, `contains` on `OctetSdk.loc`. ## Shape ```swift public struct OctetVerdict: Sendable { public let result: Result public let reason: ReasonCode public let message: String public let proof: LocationProof? public let validity: Interval? public let queriedAt: Date public let confidence: ConfidenceSummary public let achievableLevel: ProofLevel? // non-nil only when reason == .insufficientPrecision public enum Result: Sendable, Hashable { case yes, no, indeterminate } public enum ReasonCode: Sendable, Hashable { case ok case noFix case futureTime case staleFix case noProofAtResolution case insufficientPrecision case attestationFailed case mockLocationDetected case sdkNotRunning case notYetReleased case invalidSessionNonce case invalidDecisionRef case invalidDecisionTime case spoofingDetected case tampering case regionUnresolved } } ``` ```kotlin data class OctetVerdict( val result: Result, val reason: ReasonCode, val message: String, val proof: LocationProof?, val validity: Interval?, val queriedAt: Instant, val confidence: ConfidenceSummary, val achievableLevel: ProofLevel? = null, // non-null only when reason == INSUFFICIENT_PRECISION ) { enum class Result { YES, NO, INDETERMINATE } enum class ReasonCode { OK, NO_FIX, FUTURE_TIME, STALE_FIX, NO_PROOF_AT_RESOLUTION, INSUFFICIENT_PRECISION, ATTESTATION_FAILED, MOCK_LOCATION_DETECTED, SDK_NOT_RUNNING, NOT_YET_RELEASED, INVALID_SESSION_NONCE, INVALID_DECISION_REF, INVALID_DECISION_TIME, SPOOFING_DETECTED, TAMPERING, REGION_UNRESOLVED, } } ``` ## Field invariants - **`proof` is `nil`/`null` iff `result == .indeterminate`/`INDETERMINATE`.** Enforced as a precondition / `require` at construction. A `YES` or `NO` always carries a proof. An `INDETERMINATE` never does. - **`validity` is non-nil exactly when `proof` is non-nil.** Both move together. - **`queriedAt` is the `atTime` that was passed in.** Echoed back so callers do not have to thread it themselves. - **`confidence` is always populated**, even for `INDETERMINATE` (with an empty summary in that case). - **`achievableLevel` is non-nil only when `reason == .insufficientPrecision`.** It names the best [proof level](/concepts/regions) the SDK could reach for this query right now. New in 1.1. The SDK never falls back to this level automatically. ## `result` | Value | Meaning | |---|---| | `.yes` / `YES` | Predicate holds. Proof attached. | | `.no` / `NO` | Predicate provably does not hold. Proof attached. | | `.indeterminate` / `INDETERMINATE` | SDK cannot answer. See `reason`. | Never collapse `.indeterminate` to `.no` silently. See [Verdicts](/concepts/verdicts) for the rationale. ## `reason` | Code | Result | Meaning | |---|---|---| | `ok` / `OK` | `YES` or `NO` | Proof covered `atTime`. Predicate evaluated cleanly. | | `noFix` / `NO_FIX` | `INDETERMINATE` | SDK started but no fix yet (also: running on simulator / emulator). | | `futureTime` / `FUTURE_TIME` | `INDETERMINATE` | `atTime` is in the future beyond ±2 s clock-skew tolerance. | | `staleFix` / `STALE_FIX` | `INDETERMINATE` | `atTime` falls outside the validity window of any cached proof. | | `noProofAtResolution` / `NO_PROOF_AT_RESOLUTION` | `INDETERMINATE` | Cached proof is too coarse for the query (country proof, city query). | | `insufficientPrecision` / `INSUFFICIENT_PRECISION` | `INDETERMINATE` | Conditions cannot support the requested precision. Read `achievableLevel`. | | `attestationFailed` / `ATTESTATION_FAILED` | `INDETERMINATE` | Device attestation did not pass. | | `mockLocationDetected` / `MOCK_LOCATION_DETECTED` | `INDETERMINATE` | OS flagged mocked location. | | `sdkNotRunning` / `SDK_NOT_RUNNING` | `INDETERMINATE` | `Octet.start(...)` was never called or session ended. | | `notYetReleased` / `NOT_YET_RELEASED` | `INDETERMINATE` | Predicate path declared by the API but not available in this SDK build. | | `invalidSessionNonce` / `INVALID_SESSION_NONCE` | `INDETERMINATE` | Supplied `sessionNonce` is empty or exceeds 512 bytes. No proof is produced. Added in 1.2. | | `invalidDecisionRef` / `INVALID_DECISION_REF` | `INDETERMINATE` | Supplied `decisionRef` exceeds 256 characters. No proof, no network call. Added in 2.0. | | `invalidDecisionTime` / `INVALID_DECISION_TIME` | `INDETERMINATE` | A query with `decisionRef` set was pinned to a past `atTime`. A decision ref asks for a fresh proof, so it can't answer a past moment. No proof, no network call. Leave out `decisionRef` to ask about a past time. Added in 2.0. | | `spoofingDetected` / `SPOOFING_DETECTED` | `INDETERMINATE` | The spoof pipeline flagged the fix. Adversarial. Added in 2.0. | | `tampering` / `TAMPERING` | `INDETERMINATE` | Device-integrity tampering (root / hook). Also an attached debugger or native tracer outside a sandbox session. Android refuses these since 3.0, as iOS already did. Adversarial. Added in 2.0. | | `regionUnresolved` / `REGION_UNRESOLVED` | `INDETERMINATE` | A country or subdivision predicate could not resolve the region at the required confidence. Since 3.0, also a pinned `claimedRegion` that is not an assigned ISO 3166 code. A benign measurement gap, not a detected spoof. Added in 2.0. | ### Adversarial and benign reasons Added in 2.0, the measurement-related `INDETERMINATE` reasons split into two classes so a policy layer can treat them differently. The remaining reasons come from the calling code or the SDK build: - **Adversarial** (a detected forgery or compromise): `spoofingDetected`, `mockLocationDetected`, `tampering`, `attestationFailed`. A policy layer should deny these. - **Benign** (the SDK could not measure): `regionUnresolved`, `insufficientPrecision`, `noFix`, `staleFix`, `futureTime`, `noProofAtResolution`. A policy layer can permit these with a step-up. `noProofAtResolution` means the proof on hand is too coarse for the question: ask again, or ask at the level the proof covers. - **Caller or availability** (fix the call, not the device): `sdkNotRunning`, `notYetReleased`, `invalidSessionNonce`, `invalidDecisionRef`, `invalidDecisionTime`. None of these is adversarial. Fix the calling code rather than stepping up the user. Both classes carry no proof. The distinction is in the reason, not the result. ## `message` `message` is a free-form string for log lines and debug UI. Its wording can change between SDK versions. Use `reason` for control flow, not `message`. ## `proof` `LocationProof` is the signed protobuf that a verifier checks against Octet's public keys. Its full shape is part of the protocol, not the SDK's public surface. See [Serialization](/api-reference/serialization) for the v1 alpha caveat about how `LocationProof` is currently rendered through `.toJson()`. For most integrators the proof is opaque. Forward it to your verifier as-is. ### The assurance tier New in 3.0. `LocationProof.spoofingVerdict` is the proof's assurance tier: `VERIFIED`, `PLAUSIBLE`, and so on. Read it directly, or as `spoofing_verdict` in the `proof` JSON, without decoding the proof bytes. Read the tier, not the region granularity, to decide what a `YES` is worth. | Tier | What a `YES` means | |---|---| | `VERIFIED` | Independent evidence supports the fix. | | `PLAUSIBLE` | The fix is consistent with your query but is not independently corroborated. | A disc query (`contains(...)` or a disc `isWithin(...)`) answers only from a `VERIFIED` proof unless you opt in with `OctetConfig.advanced.acceptPlausibleContainment`. See [Predicates](/api-reference/predicates#plausible-containment). To enforce a tier when verifying, use [`VerifyOptions.requireVerdict`](/api-reference/octet-verify). ```swift if verdict.result == .yes, let proof = verdict.proof, proof.spoofingVerdict == .verified { accept(proof) } ``` ```kotlin val proof = verdict.proof if (verdict.result == OctetVerdict.Result.YES && proof?.spoofingVerdict == LocationProofVerdict.VERIFIED) { accept(proof) } ``` ## `validity` The interval over which the underlying proof answers the predicate as stated. It does not predict how long the verdict will stay true. See [Time Semantics](/concepts/time-semantics). ```swift public struct Interval: Sendable, Hashable { public let from: Date public let to: Date } ``` ```kotlin data class Interval(val from: Instant, val to: Instant) ``` Inclusive-inclusive, `from <= to`. ## `confidence` `ConfidenceSummary` is always populated and carries a list of flags (mock-location detected, VPN active, GNSS anomaly, and so on). To decide what a `YES` is worth, read the [assurance tier](#the-assurance-tier) on the proof. A strict caller can accept only a `YES` whose proof is `VERIFIED`: ```swift let strictYes = verdict.result == .yes && verdict.proof?.spoofingVerdict == .verified ``` ```kotlin val strictYes = verdict.result == OctetVerdict.Result.YES && verdict.proof?.spoofingVerdict == LocationProofVerdict.VERIFIED ``` The SDK never applies this filter. The caller sets the policy. ## See also - [Verdicts](/concepts/verdicts). Why the trichotomy matters. - [Time Semantics](/concepts/time-semantics). How `validity` and `queriedAt` interact. - [Serialization](/api-reference/serialization). `.toStr()` / `.toJson()` / `.toJsonl()` on verdicts. _Source: https://octetproof.com/docs/mobile/api-reference/octet-verdict/_ --- # `Octet.verify` On-device proof verification. Static, synchronous, offline. Verifies a `LocationProof` (or its raw bytes) against the keys inside it, with no network call. See [On-device Verification](/concepts/on-device-verification) for what it can and cannot establish. ## Signature ```swift public enum Octet { public static func verify(_ proof: LocationProof, options: VerifyOptions = VerifyOptions()) -> ProofVerification public static func verify(proofBytes: Data, options: VerifyOptions = VerifyOptions()) -> ProofVerification } ``` ```kotlin object Octet { fun verify(proof: LocationProof, options: VerifyOptions = VerifyOptions()): ProofVerification fun verify(proofBytes: ByteArray, options: VerifyOptions = VerifyOptions()): ProofVerification } ``` The two overloads are equivalent: `verify(proof:)` reads `proof.proofBytes`. A structurally undecodable input returns `verdict == invalid` with a `wire-format` `FAIL` and does not throw. ## `VerifyOptions` ```swift public struct VerifyOptions: Sendable { public var expectedRegion: ProofRegion? // enables region checks; nil ⇒ NOT-CHECKED public var maxAgeSeconds: TimeInterval // freshness window; default 300 public var requireAttestation: Bool // fail-closed on hardware-attestation; default false public var attestationBundle: Data? // iOS: enrolment bundle to anchor assertion-only proofs public var requireVerdict: RequiredVerdict? // fail-closed on the signed verdict tier; default nil public init(expectedRegion: ProofRegion? = nil, maxAgeSeconds: TimeInterval = 300, requireAttestation: Bool = false, attestationBundle: Data? = nil, /* … */ requireVerdict: RequiredVerdict? = nil) } public enum RequiredVerdict: Sendable, Hashable { case verified // only a signed VERIFIED verdict passes case plausible // a signed VERIFIED or PLAUSIBLE verdict passes } ``` ```kotlin data class VerifyOptions( val expectedRegion: ProofRegion? = null, // enables region checks; null ⇒ NOT-CHECKED val maxAgeSeconds: Long = 300, // freshness window; default 300 val requireAttestation: Boolean = false, // fail-closed on hardware-attestation; default false val attestationBundle: ByteArray? = null, // iOS: enrolment bundle to anchor assertion-only proofs val requireVerdict: RequiredVerdict? = null, // fail-closed on the signed verdict tier; default null ) enum class RequiredVerdict { VERIFIED, // only a signed VERIFIED verdict passes PLAUSIBLE, // a signed VERIFIED or PLAUSIBLE verdict passes } ``` - **`expectedRegion`** turns on the `region-claim`, `region-type`, and `contains` checks. When `nil`/`null`, those report `NOT-CHECKED`. - **`maxAgeSeconds`** is the freshness window, judged against the signed timestamp. - **`requireAttestation`** adds the `attestation-required` gate: the result is `FAIL` unless `hardware-attestation` passed. - **`requireVerdict`** (new in 3.0) fails a proof whose signed [assurance tier](/api-reference/octet-verdict#the-assurance-tier) is below the one you require. It also fails closed when the verdict is not signed into the proof. `.verified` / `VERIFIED` refuses a `PLAUSIBLE` disc answer, which otherwise verifies like a `VERIFIED` one. It matches `--require-verdict` in `octet-verify` 1.6.0. - **`attestationBundle`** (iOS) is a serialized enrolment bundle (`attestationEnrolmentBundle().protoData()`) that anchors an assertion-only proof to Apple's App Attest root. On Android it is ignored, because every attested Android proof carries its own key-attestation chain. ## `ProofVerification` The result. A flat, log-safe record: no coordinates, no proof bytes echoed, no PII. | Field | Type | Meaning | |---|---|---| | `verdict` | `Verdict` | Tri-state headline: `valid` / `invalid` / `inconclusive`. | | `checks` | `[ProofCheck]` | Every check that ran, in recipe order. | | `isValid` | `Bool` | No check is `FAIL`. | | `isAuthentic` | `Bool` | `isValid` and `stage-signatures` is `PASS`. | | `spoofingVerdict` | `LocationProofVerdict?` | New in 3.0. The proof's signed assurance tier (`VERIFIED`, `PLAUSIBLE`, and so on), or `nil` / `null` when the tier is not signed into the proof. | A disc answer (`contains(...)` or a disc `isWithin(...)`) can be signed `VERIFIED` or `PLAUSIBLE`, and both verify. Base acceptance on `spoofingVerdict` or set `requireVerdict`, rather than on `isValid` alone. - **`Verdict`**: `valid` / `invalid` / `inconclusive` (`VALID` / `INVALID` / `INCONCLUSIVE` on Android). - **`ProofCheck`**: `name: String`, `status: CheckStatus`, `detail: String` (log-safe). `name` is a stable string from the check taxonomy below. Most names match `octet-verify`. The on-device `hardware-attestation` check covers what the CLI reports as `attestation-root` (Android) and `app-attest` (iOS). - **`CheckStatus`**: `pass` / `fail` / `warn` / `notChecked` (`PASS` / `FAIL` / `WARN` / `NOT_CHECKED` on Android). The wire tags are `PASS` / `FAIL` / `WARN` / `NOT-CHECKED`. ### Verdict rules These mirror `octet-verify` verbatim: - `isValid` is true when no check is `FAIL`. `NOT-CHECKED` and `WARN` never reject. - `isAuthentic` is true when `isValid` and `stage-signatures` is `PASS`. `NOT-CHECKED` never makes a proof authentic. - `verdict` is `invalid` if any check is `FAIL`, else `valid` if `isAuthentic`, else `inconclusive`. - A skipped check is always reported as `NOT-CHECKED`. ## Check taxonomy | Check | What it confirms | On-device | |---|---|---| | `wire-format` | The bytes decode to a well-formed proof with no smuggled duplicate fields. | PASS / FAIL | | `stage-signatures` | Every stage is signed by the hardware-backed P-256 key the proof carries. | PASS / FAIL | | `device-attestation-sig` | The device-key signature over the proof's committed fields. | PASS / FAIL | | `binding` / `freshness` | Field bindings match their signed hashes. The proof falls within `maxAgeSeconds`. | PASS / FAIL | | `region-claim` / `region-type` / `contains` | The claimed region matches `expectedRegion`. | PASS / FAIL when `expectedRegion` set, else NOT-CHECKED | | `hardware-attestation` | The device-attestation chain anchors to a bundled Apple / Google root, so the proof is hardware-attested. | PASS / FAIL when anchorable, else NOT-CHECKED | | `attestation-required` | Fail-closed gate on `hardware-attestation`. | PASS / FAIL only when `requireAttestation` | | `verdict-tier` | On a disc answer, the tier the verdict was signed with. New in 3.0. | Disc answers only | | replay-uniqueness, revocation | Backend-only state. | Always NOT-CHECKED on-device | ## Example ```swift var options = VerifyOptions(maxAgeSeconds: 120) options.requireVerdict = .verified // refuse a PLAUSIBLE disc answer let result = Octet.verify(verdict.proof!, options: options) if result.isAuthentic { accept(verdict.proof!) } else { for check in result.checks where check.status == .fail { log("verify failed: \(check.name) — \(check.detail)") } } ``` ```kotlin val result = Octet.verify( verdict.proof!!, VerifyOptions(maxAgeSeconds = 120, requireVerdict = RequiredVerdict.VERIFIED), ) if (result.isAuthentic) { accept(verdict.proof!!) } else { result.checks.filter { it.status == CheckStatus.FAIL } .forEach { log("verify failed: ${it.name} — ${it.detail}") } } ``` ## See also - [On-device Verification](/concepts/on-device-verification). What the verdict means and its limits. - [Verifying Proofs](/concepts/verifying-proofs). The `octet-verify` tool and the full trust model. - [`OctetVerdict`](/api-reference/octet-verdict). The proof you pass in rides on `verdict.proof`. _Source: https://octetproof.com/docs/mobile/api-reference/octet-verify/_ --- # License Types The public types backing [`Octet.start(...)`](/api-reference/octet-start) and `OctetSdk.licenseStatus`. See [License & Activation](/concepts/license-activation) for the timeline model. ## `LicenseStatus` Snapshot of the SDK's current license state. Read synchronously from `OctetSdk.licenseStatus`. ```swift public struct LicenseStatus: Sendable, Equatable { public let state: LicenseState public let activatedAt: Date? public let hardStopAt: Date? public let daysUntilHardStop: Int? public let tier: String public let upgradeRecommended: Bool // soft version hint; false until backend gating is on public let minSupportedVersion: String? // soft version hint; nil until backend gating is on } ``` ```kotlin data class LicenseStatus( val state: LicenseState, val activatedAt: Instant?, val hardStopAt: Instant?, val daysUntilHardStop: Int?, val tier: String, val upgradeRecommended: Boolean, // soft version hint; false until backend gating is on val minSupportedVersion: String?, // soft version hint; null until backend gating is on ) ``` | Field | Meaning | |---|---| | `state` | Coarse state that drives in-app UI. See `LicenseState` below. | | `activatedAt` | First successful activation timestamp. `nil` pre-activation, or in `INVALID`. | | `hardStopAt` | The license's `exp`. Octet maintains active licenses, so this value advances as the license renews. Do not design around a fixed cutoff. Identical for every device that activates this license. | | `daysUntilHardStop` | `ceil((hardStopAt - now) / 1 day)`. Useful for "X days left" banners. | | `tier` | Tier carried in the license token. | | `upgradeRecommended` | Soft version hint, added in 1.2. `true` when the backend reports this SDK version as behind the recommended floor. `false` until backend version gating is enabled. | | `minSupportedVersion` | Soft version hint, added in 1.2. The lowest SDK version the backend supports, or `nil`/`null` until gating is enabled. Pair it with `upgradeRecommended` to prompt an in-app update. | Android: in 3.0, `LicenseStatus` gained three trailing constructor parameters for Octet's internal use, each with a default. Source that calls its constructor or `copy` compiles unchanged, with positional or named arguments. Code compiled against 2.0.0 that calls the constructor or `copy` must be recompiled. ## `LicenseState` Octet maintains active licenses, so a license in normal use stays in `ACTIVE`. `RENEWAL_RECOMMENDED`, `GRACE_PERIOD`, and `EXPIRED` cover prolonged-offline and revocation handling, not an age-based trial clock. ```swift public enum LicenseState: String, Sendable { case notActivated = "NOT_ACTIVATED" case active = "ACTIVE" case renewalRecommended = "RENEWAL_RECOMMENDED" case gracePeriod = "GRACE_PERIOD" case expired = "EXPIRED" case invalid = "INVALID" } ``` ```kotlin enum class LicenseState { NOT_ACTIVATED, ACTIVE, RENEWAL_RECOMMENDED, GRACE_PERIOD, EXPIRED, INVALID, } ``` | State | Meaning | |---|---| | `NOT_ACTIVATED` | License verified, never activated. | | `ACTIVE` | Activated and current. The normal steady state for a maintained license. | | `RENEWAL_RECOMMENDED` | Activated, nearing the license `exp`. Maintained licenses renew automatically. Surface a banner only if you want to. | | `GRACE_PERIOD` | Cached activation past its `exp` but within the offline tolerance. The device could not reach the backend to refresh. SDK keeps working. | | `EXPIRED` | Cached activation past the offline tolerance with no backend reachable to refresh. SDK refused to start. Age alone no longer expires a maintained license. | | `INVALID` | Signature failed, server rejected, or malformed. SDK refused to start. | :::caution `LicenseState` drives in-app UI only. The activation token enforces the cryptographic gate, so a forged `state = ACTIVE` has no effect on it. ::: ## `LicenseError` Thrown by `Octet.start(...)` for every license-related failure. The SDK guarantees one of these subtypes. It never throws a raw `Error` / `Exception` for license reasons. ```swift public enum LicenseError: Error, Sendable, Equatable { case malformedKey case noActivation case expired case activationWindowClosed case revoked case network(message: String) case serverRejected(httpStatus: Int, reason: String) case upgradeRequired(minVersion: String, message: String) case bootstrapFailed(reason: BootstrapReason) } ``` ```kotlin sealed class LicenseError(message: String) : Exception(message) { object MalformedKey : LicenseError(...) object NoActivation : LicenseError(...) object Expired : LicenseError(...) object ActivationWindowClosed : LicenseError(...) object Revoked : LicenseError(...) data class Network(override val cause: Throwable) : LicenseError(...) data class ServerRejected(val httpStatus: Int, val reason: String) : LicenseError(...) data class UpgradeRequired(val minVersion: String, val message: String) : LicenseError(...) data class BootstrapFailed( val reason: BootstrapReason, val upgradeUrl: String? = null, // set for NewDevicesBlocked ) : LicenseError(...) } ``` | Case | When | Fix | |---|---|---| | `MalformedKey` | Local PASETO signature or structural failure on the license token the SDK obtained through attested bootstrap. | Confirm the SDK is a supported version. Contact support if it persists. | | `NoActivation` | No cached activation, and offline (the SDK can't reach the license backend to bootstrap). | Retry when network returns. | | `Expired` | Cached activation past the offline grace with the backend unreachable to refresh. | Reconnect so the SDK can re-activate. | | `ActivationWindowClosed` | Server returned `403 activation_window_closed`. Not returned under the current maintained-license model. Retained for compatibility. | Contact support. | | `Revoked` | Server returned `403 revoked` (admin revoke). | Contact support. | | `Network` | Transient network failure during activation. `message` (iOS) or `cause` (Android) carries the underlying error for diagnostics. | Retry. Do not parse the message for control flow. | | `ServerRejected` | Any other HTTP rejection (`app_blocked`, `ip_blocked`, …). | Inspect `reason`. | | `UpgradeRequired` | Backend rejected this SDK version as out of support (version gating, added in 1.2). | Update the SDK to `minVersion` or later. | | `BootstrapFailed` | Attested bootstrap failed. `reason` carries a `BootstrapReason`. Added in 2.0. | See the `BootstrapReason` table below. | ## `BootstrapReason` Carried by `bootstrapFailed`. Each case except `sandboxNotPermitted` maps to a typed `detail` code the license backend returns for `POST /v1/bootstrap`, identically on both platforms. `sandboxNotPermitted` is raised by the SDK before any request is sent. See [Attested bootstrap](/concepts/attested-bootstrap). | Reason | Meaning | |---|---| | `attestationInvalid` | The App Attest object / key-attestation chain did not verify against the platform root. | | `appNotRegistered` | The app identity is not registered to any account. | | `appDisputed` | The app identity is contested and not yet resolved. | | `accountDisabled` | The owning account is disabled. | | `bootstrapReplay` | The bootstrap challenge was already used, or was not one the server issued. | | `deviceLimitReached` | The license's device cap, set by Octet, is reached. Licenses have no cap by default. Devices that already activated are unaffected. | | `newDevicesBlocked` | Added in 3.0. The account accepts no new devices. Devices that already activated are unaffected. The error carries a server-signed upgrade link, `upgradeUrl`. | | `pendingRateLimit` | A newly registered app is still rate-limited. | | `seedInvalid` | The runtime seed (web / desktop channel) did not verify. | | `sandboxTokenInvalid` | The sandbox bypass token did not verify. | | `sandboxNotPermitted` | A sandbox token was presented from a store build. Raised locally by the SDK, never sent. | ### Handling `newDevicesBlocked` Send the user to `upgradeUrl`. The link is server-signed, so open it exactly as received and never build one yourself. On iOS the link is the case's associated value, `.newDevicesBlocked(upgradeUrl:)`. On Android it is `LicenseError.BootstrapFailed.upgradeUrl`, set when `reason` is `BootstrapReason.NewDevicesBlocked`. The value is `nil` / `null` only if the server sent no link. On Android, a `when (reason)` over `BootstrapReason` without an `else` branch no longer compiles against 3.0. Add the new case or an `else` branch. ```swift do { let sdk = try await Octet.start(config: config) } catch LicenseError.bootstrapFailed(reason: .newDevicesBlocked(let upgradeUrl)) { if let link = upgradeUrl, let url = URL(string: link) { await UIApplication.shared.open(url) // open exactly as received } } ``` ```kotlin try { val sdk = Octet.start(context = applicationContext, config = config) } catch (e: LicenseError.BootstrapFailed) { when (e.reason) { BootstrapReason.NewDevicesBlocked -> e.upgradeUrl?.let { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) } else -> showStartupError(e) } } ``` ## Reading the status at runtime ```swift guard let status = sdk.licenseStatus else { return } switch status.state { case .renewalRecommended: break // maintained licenses renew automatically case .gracePeriod: showBanner("Connect to the internet to refresh the license") case .expired, .invalid: showBlocker() case .active, .notActivated: break // happy path } ``` ```kotlin val status = sdk.licenseStatus ?: return when (status.state) { LicenseState.RENEWAL_RECOMMENDED -> { /* maintained licenses renew automatically */ } LicenseState.GRACE_PERIOD -> showBanner("Connect to the internet to refresh the license") LicenseState.EXPIRED, LicenseState.INVALID -> showBlocker() LicenseState.ACTIVE, LicenseState.NOT_ACTIVATED -> { // happy path } } ``` ## See also - [License & Activation](/concepts/license-activation). The timeline model and activation flow. - [`Octet.start(...)`](/api-reference/octet-start). The call that throws `LicenseError`. - [Troubleshooting FAQ](/troubleshooting/faq). What to do when activation fails. _Source: https://octetproof.com/docs/mobile/api-reference/license-types/_ --- # Serialization The core value types expose three uniform forms. [Where they're available](#where-theyre-available) lists them. ## The three methods | Method | Purpose | Stability | |---|---|---| | `.toStr()` | Single-line human-readable summary | **Not stable**. For logs and debug UI only. | | `.toJson()` | Pretty JSON, full structure | **Stable**. HTTP bodies, files, verifier input. | | `.toJsonl()` | Single-line JSON, no whitespace | **Stable**. Line-delimited log sinks, streaming. | ## Where they're available `.toStr()` / `.toJson()` / `.toJsonl()` are defined on: - `OctetRegion` - `OctetVerdict` - `LatLon` - `H3Cell` - `Interval` ## Examples ### `OctetRegion` ```swift let r = OctetRegion.disc(center: LatLon(37.422, -122.084), radiusMeters: 250) r.toStr() // "Disc at (37.4220, -122.0840), r=250 m" r.toJsonl() // {"shape":"ellipse","center":{"latitude":37.422,...},...} ``` ```kotlin val r = OctetRegion.disc(LatLon(37.422, -122.084), 250.0) r.toStr() // "Disc at (37.4220, -122.0840), r=250 m" r.toJsonl() // {"shape":"ellipse","center":{"latitude":37.422,...},...} ``` Discs round-trip as ellipses in the JSON form (the underlying type is `EllipseRegion` with equal axes). The human-readable `.toStr()` form keeps the "Disc" label for readability. ### `OctetVerdict` ```swift verdict.toStr() // "YES reason=OK atTime=2026-05-28T15:30:00.000Z validity=[...] conf=0.92" verdict.toJson() // { // "result": "YES", // "reason": "OK", // "message": "isWithin evaluated on cached proof", // "queried_at": "2026-05-28T15:30:00.000Z", // "validity": { "from": "...", "to": "..." }, // "proof": { "id": "...", ..., "proof_bytes_b64": "..." }, // "confidence": { "overall_score": 0.92, ... }, // "achievable_level": null // } ``` ```kotlin verdict.toStr() // "YES reason=OK atTime=2026-05-28T15:30:00.000Z validity=[...] conf=0.92" verdict.toJson() // { // "result": "YES", // ... // } ``` ## The `proof` object `.toJson()` emits the same `proof` object on iOS and Android. It carries the proof's identifying fields and the serialized proof itself: ```json "proof": { "id": "...", "level": "COUNTRY", "timestamp_ms": 1748462400000, "sdk_version": "3.0.0", "platform": "ios", "spoofing_verdict": "VERIFIED", "confidence": { "overall_score": 0.92, "spoofing_risk_score": 0.03, "flags": [] }, "position_commitment_b64": "...", "proof_bytes_b64": "..." } ``` `proof_bytes_b64` is the serialized proof (`LocationProof.proofBytes`), encoded as standard base64 with padding. You can forward the JSON as is. A verifier needs the decoded bytes, so base64-decode `proof_bytes_b64` before you pass it to [`octet-verify`](/concepts/verifying-proofs) or [`Octet.verify`](/api-reference/octet-verify). ## Changes in 3.0 - **`proof` JSON carries `spoofing_verdict`.** It is the proof's assurance tier (`VERIFIED`, `PLAUSIBLE`, and so on), the same value as `LocationProof.spoofingVerdict`, so you can read it without decoding the proof bytes. See [`OctetVerdict`](/api-reference/octet-verdict#the-assurance-tier). ## Changes in 2.0 - **`confidence.flags` JSON is `UPPER_SNAKE` on iOS.** iOS previously emitted Swift case names (`vpnActive`). It now emits `VPN_ACTIVE`, matching Android. This is a breaking change for code that parses `toJson()` / `toJsonl()` on iOS. The typed `ConfidenceSummary.flags` and `toStr()` are unchanged. - **Proofs commit a `location_verdict`.** Under semantic-binding v2 (the default from 2.0), a proof binds the device's own inside/outside answer for its claimed region, and the city and earth geometry. This lives inside `proofBytes`, checked by [`octet-verify`](/concepts/verifying-proofs) and [`Octet.verify`](/api-reference/octet-verify). It is not a separate JSON field. When a predicate runs with a `sessionNonce` (see [Proof Binding](/concepts/proof-binding)), the proof material commits a hash of that nonce. Only the hash is serialized, never the raw nonce. The stability commitment in the table above applies to non-proto fields today. The proto fields become stable when the proto-JSON path lands. Until then, to forward proof material to a verifier, pass the `LocationProof` value itself (it is a typed proto on both platforms), not `verdict.toJson()`. ## Helper free functions (Android) In addition to the methods, Android exposes: ```kotlin fun whatisRegion(r: OctetRegion): String // equivalent to r.toStr(); NOT stable fun regionToStr(r: OctetRegion): String // STABLE canonical machine form ``` `regionToStr` is the stable wire form behind `region.toJson()`'s `shape`, `iso_code`, and other fields. Same content, more compact representation, suitable for log diffs and idempotency keys. iOS exposes only the method form: `region.toStr()`. ## See also - [`OctetRegion`](/api-reference/octet-region) - [`OctetVerdict`](/api-reference/octet-verdict) - [License Types](/api-reference/license-types). License types have no `.toStr()`, `.toJson()` or `.toJsonl()`. Read fields directly. _Source: https://octetproof.com/docs/mobile/api-reference/serialization/_ --- # iOS Sample: OctetSample A SwiftUI demo of the public SDK surface, built around two tabs: **Generate** (produce a proof) and **Verify** (check one on-device with `Octet.verify`). It lives under [`octet-sdk/samples-public/ios-sample`](https://github.com/octetproof/octet-sdk/tree/main/samples-public/ios-sample). ## What it does **Generate.** Pick a region, then watch the proof pipeline run step by step (SDK initialized → sensors warmed up → location fixed → proof generated), with per-step timings and a map of your position. You get back the predicate answer from `sdk.loc.isWithin(...)`, and a result card showing the outcome, a confidence bucket, and the battery the run consumed. Generated proofs are kept in a sample-owned store. **Verify.** Run `Octet.verify(...)` on any stored or imported proof and read the grouped [on-device checks](/concepts/on-device-verification) (signature, freshness, hardware attestation, and the rest), each with a one-line note on any check that could not run and how to make it run. Pick a region to check the proof's claim against, and see the granularity the proof reveals (nothing finer than its level). Proofs export and import as `.octetproof` files over AirDrop, Files, or the share sheet. ## Setup The SDK activates by attesting the app instance. There is no license key to paste. On a real device it uses Apple App Attest and bootstraps its license on first launch. Copy the local config so the sample builds: ```bash cd samples-public/ios-sample cp LocalConfig.swift.example LocalConfig.swift ``` `LocalConfig.swift` is gitignored. Without it, the build fails with `cannot find 'LocalConfig' in scope`. Register the sample's bundle id (`com.octetproof.sample`, or your own) under your Octet account, as described in [Prerequisites](/getting-started/prerequisites), so its attestation resolves to a registered app. ## Build `project.yml` is the source, and the `.xcodeproj` is generated from it. Install [XcodeGen](https://github.com/yonaskolb/XcodeGen) once (`brew install xcodegen`), then: ```bash xcodegen generate open OctetSample.xcodeproj ``` In Xcode: set your **Apple Development Team** under *Signing & Capabilities*, and provision an App ID carrying the **App Attest** capability for the bundle id. The required `Info.plist` keys (`NSLocationWhenInUseUsageDescription`, `NSMotionUsageDescription`) are already in place. ## Run on a real device This sample needs a physical device. The simulator has no real motion, GNSS, or cellular hardware, and cannot attest the app instance, so it cannot generate proofs. ## Requirements - iOS 17+ on the device (the sample's deployment target) - Xcode with XcodeGen - An App ID with the App Attest capability, registered under your Octet account ## See also - [iOS Quick Start](/getting-started/ios-quickstart). Integrating the SDK from scratch. - [On-device Verification](/concepts/on-device-verification). What the Verify tab runs. - [Troubleshooting FAQ](/troubleshooting/faq). What to do when verdicts don't look right. _Source: https://octetproof.com/docs/mobile/samples/ios-toy-app/_ --- # Android Sample: OctetSample A Jetpack Compose demo of the public SDK surface, built around two tabs: **Generate** (produce a proof) and **Verify** (check one on-device with `Octet.verify`). It lives under [`octet-sdk/samples-public/android-sample`](https://github.com/octetproof/octet-sdk/tree/main/samples-public/android-sample). Its proof format is byte-compatible with the iOS build. ## What it does **Generate.** Pick a region, then watch the proof pipeline run step by step (SDK initialized → sensors warmed up → location fixed → proof generated), with per-step timings and a map of your position. You get back the predicate answer from `sdk.loc.isWithin(...)`, and a result card showing the outcome, a confidence bucket, and the battery the run consumed. Generated proofs are kept in a sample-owned store. **Verify.** Run `Octet.verify(...)` on any stored or imported proof and read the grouped [on-device checks](/concepts/on-device-verification) (signature, freshness, hardware attestation, and the rest), each with a one-line note on any check that could not run and how to make it run. Pick a region to check the proof's claim against, and see the granularity the proof reveals (nothing finer than its level). Proofs export and import as `.octetproof` files through the Android share and Storage-Access layer. The Compose UI is driven by a single `SampleViewModel` that owns the SDK lifecycle, the generate pipeline, verify, region selection, and the map location. ## Setup The SDK activates by attesting the app instance. There is no license key to paste. On a real device it uses hardware key attestation and bootstraps its license on first launch. Copy the local config so the sample builds: ```bash cd samples-public/android-sample cp local.properties.example local.properties ``` `local.properties` is gitignored. You can optionally set `octet.playIntegrityCloudProjectNumber` (your own Google Cloud project number) to bind the per-proof Play Integrity verdict to that project. It is not required to activate: the bootstrap uses hardware key attestation. Register the sample's package (`com.octetproof.sample`, or your own) under your Octet account, as described in [Prerequisites](/getting-started/prerequisites), so its attestation resolves to a registered app. ## Build This is a standalone Gradle project. The SDK (`com.octetproof:sdk`) resolves from the Octet Maven repository configured in `settings.gradle.kts`: ```bash ./gradlew :app:assembleDebug # build ./gradlew :app:installDebug # build + install on a connected device ``` The APK lands at `app/build/outputs/apk/debug/app-debug.apk`. Package: `com.octetproof.sample`. ## Run on a real device This sample needs a physical device with a hardware-backed keystore. An emulator has no real GNSS, cellular, Wi-Fi, or motion hardware, and cannot attest the device, so it cannot generate proofs. ## Requirements - Android 11 (API 30)+ on the device - A hardware-backed keystore (TEE or StrongBox) - Your package registered under your Octet account ## See also - [Android Quick Start](/getting-started/android-quickstart). Integrating the SDK from scratch. - [On-device Verification](/concepts/on-device-verification). What the Verify tab runs. - [Troubleshooting FAQ](/troubleshooting/faq). What to do when verdicts don't look right. _Source: https://octetproof.com/docs/mobile/samples/android-toy-app/_ --- # React Native Sample :::note[Coming soon] The React Native wrapper is not yet published to npm, so this sample cannot be built from a released package yet. See the [React Native](/getting-started/react-native) page for status. ::: A React Native sample lives under [`octet-sdk/samples-public/react-native-sample`](https://github.com/octetproof/octet-sdk). It exercises the same **Generate** and **Verify** tabs as the [iOS](/samples/ios-toy-app) and [Android](/samples/android-toy-app) samples over the `@octetproof/react-native` bridge. Until the wrapper publishes, use the native samples to see the SDK end to end. _Source: https://octetproof.com/docs/mobile/samples/react-native-sample/_ --- # Troubleshooting & FAQ Indexed by the symptom you see. Each entry gives the cause and the fix. Check the [service status page](https://api.octetproof.com/status) to see whether an Octet service is having problems. --- ## Verdict reasons ### `INDETERMINATE / NO_FIX` on emulator or simulator ``` result: INDETERMINATE reason: NO_FIX message: running on simulator — location proofs are unavailable in this environment (Android: "running on emulator — …") ``` **Why.** A simulator or emulator has no real GNSS, motion or cellular hardware and cannot attest the app. The SDK detects the developer environment and returns `NO_FIX` with this message instead of producing a proof. **Fix.** Run on a real device. A simulator or emulator never produces a proof, however long you wait. `verdict.message` omits the underlying tamper signals (mock-location flag, jailbreak / root indicators, GNSS anomalies, attestation results) on purpose. Exposing which signal fired is a security risk: it tells an attacker exactly what to defeat. --- ### `INDETERMINATE / NO_FIX` on a real device The SDK started but has not seen a usable fix yet. **Fix.** - **Indoors.** Step outside or near a window and try again. - **GPS disabled at the OS level.** Re-enable in Settings → Location. - **No fix yet.** The SDK has started but has not yet seen a usable fix. Retry after a few seconds, outdoors if you can. --- ### `INDETERMINATE / STALE_FIX` `atTime` falls outside the validity window of any cached proof. **Fix.** - **You're querying a past time.** If you need historical answers, query within a few minutes of when the user was there. The per-resolution windows are: country ±5 min, subdivision ±3 min, city ±1 min, finer shapes ±30 s. - **You're querying near-now.** Wait briefly. The SDK is likely producing a fresh proof on this exact call. If `STALE_FIX` persists, see `NO_FIX` above. --- ### `INDETERMINATE / FUTURE_TIME` `atTime > now + 2 s` (the SDK's clock-skew tolerance). **Fix.** Do not query the future. If you are seeing this with `atTime: Date()` or `Instant.now()` exactly, your device clock is significantly skewed relative to the SDK's internal clock. Re-sync the device clock. --- ### `INDETERMINATE / NO_PROOF_AT_RESOLUTION` The cached proof is too coarse for the query. For example, you asked `isWithin(city("San Francisco"))` and the most recent proof is at country level. **Fix.** The SDK should regenerate at the finer level on the next live query. If it persists indoors with no GPS, you may be in an environment where the finer resolution is not provable. Fall back to a coarser predicate (subdivision instead of city) or accept the indeterminate result. --- ### `INDETERMINATE / MOCK_LOCATION_DETECTED` The OS flagged the fix as mocked. The SDK refuses to issue proofs for mocked fixes. **Fix.** Disable any mock-location apps. On Android, also check Developer Options → "Select mock location app" and clear it. --- ### `INDETERMINATE / ATTESTATION_FAILED` The device or app failed platform integrity attestation. **Fix.** This usually means the device or build environment failed integrity checks: rooted device, custom ROM, modified APK or IPA, debugger attached in a way the attestation provider rejects. The SDK cannot override this. The device or build needs to pass platform integrity. --- ### `INDETERMINATE / TAMPERING` while debugging on Android Since 3.0, the SDK on Android refuses a proof with `TAMPERING` when a debugger or native tracer is attached, as on iOS. **Fix.** Debug against a sandbox session. With `OctetConfig.sandboxBypassToken` set, a proof is still allowed with a debugger attached. See [sandbox bypass token](/concepts/attested-bootstrap#devices-that-cannot-attest). --- ### `INDETERMINATE / REGION_UNRESOLVED` A country or state query could not resolve the region. A pinned `claimedRegion` that is not an assigned ISO 3166 code also produces it. In a [gateway mode](/concepts/gateway-routing#what-your-gateway-must-forward), a gateway that does not forward `/ext/geo/` makes country and subdivision proofs return `REGION_UNRESOLVED`. **Fix.** Treat it as a benign gap: retry later or apply a step-up check. In a gateway mode, forward `/ext/geo/`. --- ### `INDETERMINATE / SDK_NOT_RUNNING` You called a predicate before `Octet.start(...)` completed, or after the SDK session ended. **Fix.** Hold the `OctetSdk` handle that `start` returned and call predicates on it. Do not call into a nil or disposed reference. --- ### `INDETERMINATE / NOT_YET_RELEASED` The predicate path is declared by the v1 API but not available in this SDK build (e.g. some shape-pair containment combinations). **Fix.** Until the feature lands, fall back to a supported predicate. The combinations that currently produce `NOT_YET_RELEASED` are documented per release in the [`octet-sdk-ios` CHANGELOG](https://github.com/octetproof/octet-sdk-ios/blob/main/CHANGELOG.md) and the [`octet-sdk-android` CHANGELOG](https://github.com/octetproof/octet-sdk-android/blob/main/CHANGELOG.md). --- ## License errors ### `LicenseError.MalformedKey` The license token the SDK holds did not verify: the signature failed, the prefix is unknown, the `typ` claim is wrong, or a required claim is missing. **Fix.** Since 2.0, the SDK obtains its license through [attested bootstrap](/concepts/attested-bootstrap), so there is no key to paste or copy. This points to a corrupted or unsupported license token rather than anything you configure. Confirm the SDK is on a supported version, and contact support if it persists. --- ### `LicenseError.NoActivation` No cached activation token, and the device is offline. **Fix.** Connect to a network long enough for one successful activation. After that, the SDK works offline for up to 14 days on a cached activation. --- ### `LicenseError.Expired` The cached activation is past the offline grace and the SDK could not reach the backend to refresh. Age alone no longer expires a maintained key. **Fix.** Reconnect so the SDK can refresh the activation. If it persists, contact support. --- ### `LicenseError.ActivationWindowClosed` Not returned under the current maintained-license model. Octet maintains active licenses, so there is no activation window to close. **Fix.** If you hit this, contact support. --- ### `LicenseError.Revoked` The license was revoked by an Octet admin (typical reasons: key was leaked, abuse, payment dispute). **Fix.** Contact Octet support. --- ### `LicenseError.Network` Transient network failure during activation. The associated `message` (iOS) or `cause` (Android) carries the underlying error for diagnostics. Do not parse it for control flow. **Fix.** Retry. If a usable cached activation exists, the SDK never falls into this path on subsequent launches. --- ### `LicenseError.ServerRejected(httpStatus, reason)` The backend rejected the request for a reason that has no dedicated case (for example `app_blocked`, `ip_blocked`). **Fix.** The `reason` tag identifies the policy that fired. Contact support for `app_blocked` or `ip_blocked`. For other tags, check the release notes of the SDK version you are on. --- ### `LicenseError.BootstrapFailed(reason)` Attested bootstrap could not mint a license at `Octet.start`. The `reason` is a `BootstrapReason` naming the cause: the challenge was not recognized or expired, the attestation did not verify, or the app identity is not registered. **Fix.** Depends on the `reason`. - **App not registered.** Add the app's identity at [sdk.octetproof.com/apps](https://sdk.octetproof.com/apps), matching the bundle id and Apple Team ID (iOS) or package name and signing-certificate SHA-256 (Android) of the build you are running. A Debug build needs its Debug certificate registered. - **Attestation did not verify.** Run on a real device. An emulator, simulator, or CI runner has no hardware attestation. For those, register a Debug certificate and pass a [sandbox bypass token](/concepts/attested-bootstrap#devices-that-cannot-attest) as `OctetConfig.sandboxBypassToken`. - **Challenge expired.** On a slow or interrupted first launch, the single-use challenge can expire before it is used. Retry `start`. - **New devices blocked.** The account accepts no new devices. Open the error's `upgradeUrl` exactly as received, and never build the link yourself. Devices that already activated are unaffected. See [Handling `newDevicesBlocked`](/api-reference/license-types#handling-newdevicesblocked). See [Attested bootstrap](/concepts/attested-bootstrap) and [License Types](/api-reference/license-types). --- ## Platform-specific issues ### iOS: app crashes on launch with "privacy-sensitive data" error Missing `Info.plist` key. The error message names the missing key, usually `NSMotionUsageDescription` or `NSLocationWhenInUseUsageDescription`. See [Prerequisites](/getting-started/prerequisites). --- ### iOS: SDK works in foreground but stops generating proofs in background Background location requires two things. First, these `Info.plist` keys: ```xml NSLocationAlwaysAndWhenInUseUsageDescription … UIBackgroundModes location ``` Second, the user must grant `.authorizedAlways` (`.authorizedWhenInUse` is not enough). The SDK silently falls back to foreground-only if either is missing. --- ### Android: SDK refuses to start with location permission granted in manifest Manifest-level permission is necessary but not sufficient on Android 6 (API 23)+. You must request `ACCESS_FINE_LOCATION` at runtime and wait for the user to grant it before calling `Octet.start(...)`. --- ### Android: motion classification looks degraded `ACTIVITY_RECOGNITION` was likely not granted (Android 10+ / API 29+). The SDK keeps working, with lower confidence. Request the permission at runtime alongside location. --- ### Both platforms: `Octet.start(...)` is slow on first launch Expected on first launch. The first launch includes producing the platform attestation, the attested-bootstrap round-trip (`POST /v1/bootstrap/challenge` then `POST /v1/bootstrap`), and the proof pipeline bring-up. Subsequent launches reuse the cached lease and skip the network call. If first-launch time matters, call `start` from a coroutine or task on app launch and gate UI on its completion. --- ## Session binding ### A session-bound proof shows `NOT-CHECKED` for the session binding You called a predicate with a `sessionNonce`, but `octet-verify` reports the session binding as `NOT-CHECKED` instead of checking it. **Fix.** Build `octet-verify` at 1.5.0 or later, the version to run with SDK 3.0. A build older than 1.2.0 does not implement the session-binding stage, so it skips it and validates the rest of the proof. See [Proof Binding](/concepts/proof-binding) and the [Verifier Quick Start](/getting-started/verifier-quickstart). --- ## See also - [Verdicts](/concepts/verdicts). Full `ReasonCode` taxonomy. - [License & Activation](/concepts/license-activation). Timeline model and activation flow. - [License Types API Reference](/api-reference/license-types). Exact `LicenseError` cases. - [What's new in 3.0](/whats-new). The 3.0 release. - [Upgrading from 2.x](/getting-started/upgrading-from-2x). Breaking changes in 3.0. _Source: https://octetproof.com/docs/mobile/troubleshooting/faq/_ --- =================== OCTET BROWSER =================== # Octet Browser Octet Browser returns a country verdict for each web session: the country the session is operating from, a confidence score, and an alarm level. Your backend receives the verdict, and your policy decides what happens next. Each version's changes are listed in [Release Notes](/reference/release-notes). ## What the verdict tells you | Field | Meaning | |---|---| | `country` | The ISO 3166-1 alpha-2 code of the country the session is operating from, for example `"DE"`. Absent when Octet could not determine a country. | | `confidence` | A number from `0` to `1`. Higher means Octet is more certain of `country`. | | `alarm` | `none`, `low`, `medium` or `high`. How strongly the session's signals contradict `country` or suggest the connection is masked. It is also `high` for a session in a country under comprehensive sanctions. | See [Verdicts](/concepts/verdicts) for how to read each field, and [Verdict Reference](/reference/verdict) for the exact shape. ## Using the verdict - **Read the three fields together.** `confidence` and `alarm` tell you how much to rely on `country`. When `alarm` is `medium` or above, act on `alarm` rather than `country`. - **Your backend makes the decision.** Octet does not allow or block sessions. Your backend decides whether to allow, challenge or log each one. - **The verdict has a fourth field, `token`.** It is a signed copy of the verdict, with its `sessionRef` and the time it was issued, which you can verify and keep as a record. An auditor can check that record later against Octet's public key. See [Integrity and Audit](/concepts/integrity). ## No prompts for your users Octet Browser shows your users nothing. It never asks for permission to use location, camera, microphone or notifications, in any collection mode. ## The three parts of an integration ```mermaid flowchart LR A[Browser
collector] -->|HTTPS + WebSocket| B[Your edge
octet-edge] B -->|mutual TLS| C[Octet API] D[Your backend] -->|POST /v1/challenge
GET /v1/verdict/:ref| C ``` 1. **The collector** is a JavaScript file you serve from your own site. It runs in the user's browser and sends what it collects to your edge. 2. **The edge** is a single static Linux binary you run on your own infrastructure. It receives the browser's connection directly, signs what it forwards with its own key, and sends it to Octet over mutual TLS with your license token. 3. **Your backend** gets a one-time challenge from Octet for each page view, and later fetches the verdict directly from Octet. It uses a read token for both, then applies your policy. The browser never receives the verdict. Your backend is the only place it arrives. See [How It Works](/concepts/how-it-works). ## Start here - **Integrating for the first time:** [Quickstart](/getting-started/quickstart) - **Getting credentials:** [Credentials](/integration/credentials) - **Checking your CSP and firewall:** [Network and CSP](/reference/network) - **Something is failing:** [Errors](/reference/errors) and [Troubleshooting](/troubleshooting/faq) ## Access and support - **Request access:** apply at [browser.octetproof.com/signup](https://browser.octetproof.com/signup). Octet reviews each application and emails your license token when it is approved. - **Terms:** [Octet Browser terms](https://octetproof.com/terms/browser/). - **Pricing:** [octetproof.com/pricing](https://octetproof.com/pricing/). - **Integration support:** [developer@octetproof.com](mailto:developer@octetproof.com). Include the `sessionRef` and the time of the request. Never send a license token, read token or private key. _Source: https://octetproof.com/docs/browser/_ --- # Quickstart This quickstart takes you from an approved license to a verdict on your backend. It uses a small Node.js server with no dependencies, so you can see every step working before you change your own app. Each step links to the guide with the full detail. ## Before you start The quickstart needs all of these. Step 1 sets up the edge's client certificate and signing key, which takes about an hour the first time. - Your license token, emailed by Octet when your application was approved. To apply, see [Credentials](/integration/credentials#get-a-license-token). - A portal user with the owner or admin role on the license, to issue the edge's client certificate and register its signing key. - A Linux host for the edge, with a public IP address and a DNS name such as `octet.example.com`. See [Deploy the Edge](/integration/deploy-edge#before-you-start). - A client certificate for the edge, issued in the Octet portal. See [Get the edge's client certificate](/integration/credentials#get-the-edges-client-certificate). - A signing key for the edge, which you create on the edge host and register in the Octet portal. See [Register an edge signing key](/integration/credentials#register-an-edge-signing-key). - A read token, created in the Octet portal's **Verdict API** tab, under **Read tokens**. Your backend uses it to get challenges and to fetch verdicts. See [Create a read token](/integration/credentials#create-a-read-token). - Node.js 18 or later on the machine that runs the example server. ## 1. Run the edge Follow [Deploy the Edge](/integration/deploy-edge) on your Linux host. For this quickstart, set `ALLOWED_ORIGIN` to include the example server's origin: ```ini ALLOWED_ORIGIN=https://www.example.com,http://localhost:3000 ``` Check that the edge is up and that Octet accepts your license: ```bash curl -s https://octet.example.com/health curl -s -X POST https://octet.example.com/v1/signals -H 'content-type: application/json' -d '{}' ``` The first returns `{"ok":true,"role":"octet-edge"}`. The second returns `"reason":"unsealed_bundle"`, which means Octet accepted your license, your client certificate and your edge's signature. ## 2. Get the collector In a new directory, download `octet-collector.js` and `octet-collector.js.sri` from the [latest release](https://github.com/octetproof/octet-browser/releases/latest): ```bash curl -fLO https://github.com/octetproof/octet-browser/releases/latest/download/octet-collector.js curl -fLO https://github.com/octetproof/octet-browser/releases/latest/download/octet-collector.js.sri ``` ## 3. Create the example server Save this as `server.mjs` in the same directory: ```js // server.mjs: a minimal Octet Browser integration. Node.js 18 or later. import { createServer } from 'node:http'; import { randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; const EDGE_URL = process.env.OCTET_EDGE_URL; // for example https://octet.example.com const READ_TOKEN = process.env.OCTET_READ_TOKEN; const collector = readFileSync('octet-collector.js'); const sri = readFileSync('octet-collector.js.sri', 'utf8').trim(); const sessionRefs = new Map(); // browser session id -> sessionRef. Use your own session store. const page = (sessionRef, challenge) => `
`; async function getChallenge(sessionRef) { const res = await fetch('https://geo.octetproof.com/v1/challenge', { method: 'POST', headers: { Authorization: `Bearer ${READ_TOKEN}`, 'content-type': 'application/json' }, body: JSON.stringify({ sessionRef }), signal: AbortSignal.timeout(5000), }); if (!res.ok) throw new Error(`challenge failed: ${res.status} ${await res.text()}`); return (await res.json()).challenge; } async function fetchVerdict(sessionRef) { const url = `https://geo.octetproof.com/v1/verdict/${encodeURIComponent(sessionRef)}?waitMs=5000`; const res = await fetch(url, { headers: { Authorization: `Bearer ${READ_TOKEN}` }, signal: AbortSignal.timeout(8000), }); if (res.status === 404) return null; if (!res.ok) throw new Error(`verdict fetch failed: ${res.status} ${await res.text()}`); return res.json(); } createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/octet-collector.js') { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(collector); } if (req.method === 'GET' && req.url === '/') { const sid = randomBytes(16).toString('hex'); const sessionRef = randomBytes(32).toString('base64url'); let challenge; try { challenge = await getChallenge(sessionRef); } catch (err) { res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }); return res.end(`could not get a challenge from Octet: ${err.message}\n`); } sessionRefs.set(sid, sessionRef); res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'set-cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`, }); return res.end(page(sessionRef, challenge)); } if (req.method === 'POST' && req.url === '/withdraw') { const sid = /(?:^|;\s*)sid=([0-9a-f]+)/.exec(req.headers.cookie ?? '')?.[1]; const sessionRef = sessionRefs.get(sid); if (!sessionRef) { res.writeHead(400); return res.end('no session'); } const verdict = await fetchVerdict(sessionRef); const masked = verdict?.alarm === 'medium' || verdict?.alarm === 'high'; const decision = !verdict || masked ? 'require KYC' : 'allow'; res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); return res.end(`${decision}\n\n${JSON.stringify(verdict, null, 2)}\n`); } res.writeHead(404); res.end(); }).listen(3000, () => console.log('http://localhost:3000')); ``` The server gives each page view a new `sessionRef`, gets a one-time challenge for it from Octet, and keeps the `sessionRef` server-side against a cookie. It renders both into the page, and fetches the verdict when the form is submitted. Its policy requires KYC when there is no verdict or when `alarm` is `medium` or above. ## 4. Run it ```bash OCTET_EDGE_URL=https://octet.example.com OCTET_READ_TOKEN=octet_read_REPLACE_WITH_YOUR_READ_TOKEN node server.mjs ``` Open [http://localhost:3000](http://localhost:3000) and select **Withdraw**. Use that exact address. The collector runs only on an HTTPS page or on `localhost`, so it refuses to run on any other `http://` address, such as a LAN IP. The page shows the decision and the verdict: ```text allow { "country": "GB", "confidence": 0.9, "alarm": "none", "deviceKey": "dk1_…", "token": "eyJhbGciOiJFZERTQSIs..." } ``` Each challenge works for one collection. To run the check again, reload the page, which gets a new `sessionRef` and a new challenge. ## Next - Move each piece into your app: [Embed the Collector](/integration/embed-collector), including [Get a challenge](/integration/embed-collector#get-a-challenge), and [Fetch the Verdict](/integration/fetch-verdict). - Decide your policy: [Verdicts](/concepts/verdicts). - Verify and store the `token`: [Verify the Signed Token](/integration/verify-token). - Before launch, remove `http://localhost:3000` from `ALLOWED_ORIGIN` and work through the [Go-Live Checklist](/integration/go-live-checklist). _Source: https://octetproof.com/docs/browser/getting-started/quickstart/_ --- # Credentials Octet Browser uses four credentials. None of them may reach the browser. | Credential | Looks like | Goes in | Lifetime | Issued by | |---|---|---|---|---| | License token | `octet_live_v4.public.…` | Your edge's `LICENSE` setting | 365 days | Octet, by email | | Edge client certificate | `octet-edge-.crt`, with a private key that stays on the edge host | Your edge's `EDGE_CLIENT_CERT_FILE` and `EDGE_CLIENT_KEY_FILE` settings | 397 days | You, in the Octet portal, from a certificate request you create | | Edge signing key | A 32-byte key in base64, created on the edge host. Its public half and `kid` appear in the edge's startup log | Your edge's `EDGE_SIGNING_KEY` setting | Until you revoke it | You, on the edge host, registered in the Octet portal | | Read token | `octet_read_v4.public.…` | Your backend's `Authorization` header | 30, 90, 180 or 365 days | You, in the Octet portal | The license token lets your edge send sessions to Octet. The client certificate identifies your edge on its mutual TLS connection to Octet, and is bound to your license: Octet refuses a session whose client certificate belongs to another license. The signing key signs each session your edge forwards, and Octet checks that signature against the public keys registered for your license. The read token lets your backend request challenges for its page views and fetch the verdicts for those sessions. A read token reads only the verdicts that arrived under its own license. The license token, the read token, the edge's private key and the edge's signing key are secrets. The client certificate and the signing key's public half are not. In the Octet portal, each Octet Browser license has two tabs. **Edge configuration** is for your edge server, with **Certificates** for its client certificates and **Signing keys** for its signing keys. **Verdict API** is for your backend, with **Read tokens**. ## Get a license token 1. Apply at [browser.octetproof.com/signup](https://browser.octetproof.com/signup). 2. Octet reviews the application. When it is approved, Octet emails your license token and a link to the Octet portal. 3. Put the token in your edge's environment as `LICENSE`. See [Deploy the Edge](/integration/deploy-edge). The license token is valid for 365 days from the day it is issued. Ask Octet for a new one before it expires, at [developer@octetproof.com](mailto:developer@octetproof.com). ## Get the edge's client certificate The private key is created on the edge host and never leaves it. Octet sees only a certificate request: 1. Sign in to the Octet portal at [api.octetproof.com/portal/sign-in](https://api.octetproof.com/portal/sign-in), open your Octet Browser license and select the **Edge configuration** tab, then **Certificates**. The page shows your license ID, a lowercase UUID, and the commands with it filled in. 2. On the edge host, create a private key and a certificate request whose common name is exactly your license ID. 3. In the **Edge configuration** tab, under **Certificates**, paste the request, `client.csr`, or choose the file, and issue the certificate. 4. Download the certificate, `octet-edge-.crt`. It is valid for 397 days. Install it next to the key. The commands are in [Deploy the Edge](/integration/deploy-edge#4-install-the-client-certificate). Only portal users with the owner or admin role can issue or revoke edge certificates, and only while the license is active. Every portal user can see and download them. Each edge host gets its own key and certificate, all with your license ID as the common name. A license can have at most 3 live edge certificates at a time. The key must be ECDSA P-256 or P-384, or RSA of 2048 bits or more. The portal refuses a request with any other key, or with a common name other than your license ID. ## Renew an edge client certificate Because a license can hold 3 live edge certificates, you can renew with no gap: 1. Create a new key and certificate request on the edge host, as for the first certificate. 2. Issue and download the new certificate in the **Edge configuration** tab, under **Certificates**. 3. Install it, point `EDGE_CLIENT_CERT_FILE` and `EDGE_CLIENT_KEY_FILE` at the new files, and restart the edge. 4. Confirm that the [license check](/integration/deploy-edge#9-check-it) still returns `unsealed_bundle`. 5. Revoke the old certificate in the **Edge configuration** tab, under **Certificates**. Renew before the old certificate expires. An expired certificate gets `401` with `"reason":"client_cert_required"`. ## Register an edge signing key The signing key is created on the edge host and never leaves it. Octet sees only its public half: 1. On the edge host, create the key with `openssl rand -base64 32`, and set it as `EDGE_SIGNING_KEY` in the edge's environment file. 2. Restart the edge. Its startup log shows `S2 signing on: kid=… ed25519 public key=…`. 3. In the portal's **Edge configuration** tab, under **Signing keys**, paste the `kid` and the public key, and select **Register key**. 4. Select **Prove** next to the key, and run the command the portal asks for on the edge host. See [Prove you hold the signing key](/reference/edge-config#prove-you-hold-the-signing-key). The commands are in [Deploy the Edge](/integration/deploy-edge#5-create-the-signing-key). Only portal users with the owner or admin role can register or revoke signing keys, and only while the license is active. A license can have at most 3 active signing keys at a time. A signing key doesn't expire. ## Rotate an edge signing key Because a license can hold 3 active signing keys, you can rotate with no gap: 1. Create a new key on the edge host, set it as `EDGE_SIGNING_KEY`, and restart the edge. 2. Register the new `kid` and public key from the startup log. 3. Confirm that the [license check](/integration/deploy-edge#9-check-it) still returns `unsealed_bundle`. 4. Revoke the old key in the **Edge configuration** tab, under **Signing keys**. Sessions the edge forwards between steps 1 and 2 carry a signature from a key Octet doesn't know yet. Octet refuses those sessions with `401` and `edge_key_unknown`, so register the key straight after the restart. ## Create a read token 1. Sign in to the Octet portal at [api.octetproof.com/portal/sign-in](https://api.octetproof.com/portal/sign-in) with the email address your license was issued to. 2. Open your Octet Browser license and select the **Verdict API** tab, then **Read tokens**. 3. Choose a lifetime of 30, 90, 180 or 365 days. The default is 90. 4. Create the token and copy it straight into your backend's secret store. The portal shows it only once. Only portal users with the owner or admin role can create or revoke read tokens, and only while the license is active. A license can have at most 5 live read tokens at a time. ## Rotate a read token Because a license can hold 5 live read tokens, you can rotate with no gap: 1. Create a new read token. 2. Deploy it to your backend. 3. Confirm that verdict fetches succeed with the new token. 4. Revoke the old token in the **Verdict API** tab, under **Read tokens**. Rotate before the old token expires. An expired read token gets `401` with `"error": "expired"`. ## Revoke a credential - **Read token:** revoke it in the portal's **Verdict API** tab, under **Read tokens**. Octet refuses it within minutes. - **License token:** email [developer@octetproof.com](mailto:developer@octetproof.com). Octet revokes it and issues a new one. Once the old token is revoked, your edge's requests get `401` until you deploy the new one. - **Edge client certificate:** revoke it in the portal's **Edge configuration** tab, under **Certificates**, when an edge host is retired or its key may have been copied. Octet refuses it within about a minute, with `403` and `client_cert_revoked`. Your other certificates and your license keep working. - **Edge signing key:** revoke it in the portal's **Edge configuration** tab, under **Signing keys**, when an edge host is retired or the key may have been copied. Within about a minute Octet stops recognising it. Sessions signed with it get `401` and `edge_key_unknown`. ## Keep credentials out of the browser - Never put a token, the edge's private key or its signing key in page code, the collector's configuration, a URL, or a client-side bundle. - Store the tokens in your secret manager and pass them to the edge and backend as environment variables. Keep the edge's private key and signing key on the edge host, readable only by the edge's service user. - Don't send tokens to Octet support. Quote the `sessionRef` and the time of the request instead. _Source: https://octetproof.com/docs/browser/integration/credentials/_ --- # Deploy the Edge The edge is a single static Linux binary, `octet-edge`. It accepts the browser's connection, terminates its TLS, and forwards the collected data to Octet over mutual TLS with your license token. It signs each session it forwards with its own key. This guide sets it up on its own hostname, such as `octet.example.com`, with systemd. ## Before you start - A Linux host, `amd64` or `arm64`, with a public IP address. - A DNS name for the edge, such as `octet.example.com`, with an `A` record (and `AAAA` if you use IPv6) pointing straight at that host. - Inbound TCP 443 open to the internet, and TCP 80 open while you get the certificate. - Outbound TCP 443 to `geo.octetproof.com`. - Your license token, and your license ID from the Octet portal for the edge's client certificate. See [Credentials](/integration/credentials). ## Devices in front of the edge {#nothing-may-terminate-tls-or-tcp-in-front-of-the-edge} The edge must be the machine that accepts the browser's TCP connection and terminates its TLS. It needs the browser's own connection, including the user's IP address, and it ignores `X-Forwarded-For` and similar headers. Anything that terminates TCP or TLS in front of it replaces the browser's connection with its own, and verdicts become wrong. These are not supported in front of the edge: - A reverse proxy such as nginx, Caddy, Apache, Traefik or Envoy - HAProxy, in HTTP or TCP mode - An HTTP(S) or application load balancer, such as AWS ALB or a Google Cloud HTTPS load balancer - A Kubernetes Ingress controller - A CDN or proxy service, such as Cloudflare's proxy, CloudFront, Fastly or Akamai These are supported: - A public IP on the host itself, with an ordinary firewall or security group - A layer 4 load balancer in passthrough mode, which forwards packets without opening its own TCP connection and keeps the client's source IP. Examples are an AWS Network Load Balancer with a TCP listener and client IP preservation, or a Google Cloud passthrough Network Load Balancer. - DNS services that only answer queries, such as Cloudflare DNS with the proxy turned off If you run more than one edge behind a passthrough load balancer, turn on source IP affinity. A browser's WebSocket and its POST are separate connections, and both must reach the same edge. ## 1. Download and verify the binary Download the build for your CPU and the checksum file from the [latest release](https://github.com/octetproof/octet-browser/releases/latest). On an `arm64` host, use `octet-edge-linux-arm64` in each command. Note the release's version number in your deployment records. ```bash curl -fLO https://github.com/octetproof/octet-browser/releases/latest/download/octet-edge-linux-amd64 curl -fLO https://github.com/octetproof/octet-browser/releases/latest/download/SHA256SUMS ``` Check the binary against its checksum: ```bash sha256sum --ignore-missing -c SHA256SUMS ``` The output must read `octet-edge-linux-amd64: OK`. If it reads anything else, delete the file and download it again. To check that `SHA256SUMS` itself comes from the release workflow of [octetproof/octet-browser](https://github.com/octetproof/octet-browser), download its signature and certificate from the same release and verify them with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/): ```bash curl -fLO https://github.com/octetproof/octet-browser/releases/latest/download/SHA256SUMS.sig curl -fLO https://github.com/octetproof/octet-browser/releases/latest/download/SHA256SUMS.pem cosign verify-blob --signature SHA256SUMS.sig --certificate SHA256SUMS.pem \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp '^https://github.com/octetproof/octet-browser/\.github/workflows/sign-release\.yml@' \ SHA256SUMS ``` cosign prints `Verified OK`. Then install the binary: ```bash sudo install -m 0755 octet-edge-linux-amd64 /usr/local/bin/octet-edge ``` ## 2. Create a service user and config directory ```bash sudo useradd --system --no-create-home --shell /usr/sbin/nologin octet-edge sudo install -d -m 0750 -o root -g octet-edge /etc/octet-edge /etc/octet-edge/tls ``` ## 3. Get a TLS certificate The edge serves HTTPS itself, from certificate files you provide. It does not obtain or renew certificates. This example uses Let's Encrypt with certbot in standalone mode, which needs port 80 free while it runs: ```bash sudo certbot certonly --standalone -d octet.example.com ``` Install a deploy hook, so each issued or renewed certificate is copied to the edge and the edge restarts to load it: ```bash sudo tee /etc/letsencrypt/renewal-hooks/deploy/octet-edge.sh >/dev/null <<'EOF' #!/bin/sh set -e install -m 0640 -o root -g octet-edge "$RENEWED_LINEAGE/fullchain.pem" /etc/octet-edge/tls/fullchain.pem install -m 0640 -o root -g octet-edge "$RENEWED_LINEAGE/privkey.pem" /etc/octet-edge/tls/privkey.pem systemctl restart octet-edge || true EOF sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/octet-edge.sh sudo RENEWED_LINEAGE=/etc/letsencrypt/live/octet.example.com /etc/letsencrypt/renewal-hooks/deploy/octet-edge.sh ``` The edge loads its certificate once, at startup, which is why the hook restarts it. ## 4. Install the client certificate The edge connects to Octet only over mutual TLS, with a client certificate for your license. You issue it yourself in the Octet portal, in your Octet Browser license's **Edge configuration** tab, under **Certificates**. The certificate's common name must be exactly your license ID, which the page shows. Only a portal user with the owner or admin role can issue it. Create the private key and a certificate request on the edge host: ```bash LICENSE_ID=REPLACE_WITH_YOUR_LICENSE_ID sudo openssl ecparam -name prime256v1 -genkey -noout -out /etc/octet-edge/tls/client.key sudo chown root:octet-edge /etc/octet-edge/tls/client.key sudo chmod 0640 /etc/octet-edge/tls/client.key sudo openssl req -new -key /etc/octet-edge/tls/client.key -subj "/CN=$LICENSE_ID" -out client.csr ``` This makes an ECDSA P-256 key. The portal also accepts ECDSA P-384 and RSA keys of 2048 bits or more. The private key stays on this host. In the portal's **Edge configuration** tab, under **Certificates**, paste the contents of `client.csr` (`cat client.csr`) or choose the file, and issue the certificate. Download it, `octet-edge-.crt`, valid for 397 days, copy it to the edge host and install it next to the key: ```bash sudo install -m 0644 -o root -g octet-edge "octet-edge-$LICENSE_ID.crt" /etc/octet-edge/tls/client.crt ``` Each edge host needs its own key and certificate. Repeat this step on every host, with the same license ID. A license can have at most 3 live edge certificates. To renew or revoke one, see [Credentials](/integration/credentials#renew-an-edge-client-certificate). ## 5. Create the signing key The edge signs each session it forwards to Octet with its own Ed25519 key. The signature covers the collection and what the edge adds about the connection, so Octet can check that they reached it from your edge unchanged. The signing key is separate from the client certificate, and the edge needs both. Create the key on the edge host: ```bash openssl rand -base64 32 ``` The output is the private signing key. You put it in the environment file in the next step, as `EDGE_SIGNING_KEY`. Keep it on the edge host only, and never pass it on the command line. You register its public half in the portal once the edge is running, in step 8. ## 6. Write the environment file Create `/etc/octet-edge/edge.env`: ```ini PORT=443 OCTET_URL=https://geo.octetproof.com LICENSE=octet_live_v4.public.REPLACE_WITH_YOUR_LICENSE_TOKEN ALLOWED_ORIGIN=https://www.example.com EDGE_TLS_CERT_FILE=/etc/octet-edge/tls/fullchain.pem EDGE_TLS_KEY_FILE=/etc/octet-edge/tls/privkey.pem EDGE_CLIENT_CERT_FILE=/etc/octet-edge/tls/client.crt EDGE_CLIENT_KEY_FILE=/etc/octet-edge/tls/client.key EDGE_SIGNING_KEY=REPLACE_WITH_YOUR_SIGNING_KEY ``` Set `ALLOWED_ORIGIN` to the origin of the pages that load the collector. Separate several origins with commas, with no spaces. Then lock the file down: ```bash sudo chown root:octet-edge /etc/octet-edge/edge.env sudo chmod 0640 /etc/octet-edge/edge.env ``` Every setting is described in [Edge Configuration](/reference/edge-config). ## 7. Create the systemd unit Create `/etc/systemd/system/octet-edge.service`: ```ini [Unit] Description=Octet edge After=network-online.target Wants=network-online.target [Service] User=octet-edge Group=octet-edge EnvironmentFile=/etc/octet-edge/edge.env ExecStart=/usr/local/bin/octet-edge Restart=on-failure RestartSec=2 AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true [Install] WantedBy=multi-user.target ``` `CAP_NET_BIND_SERVICE` lets the service user listen on port 443. Start the edge: ```bash sudo systemctl daemon-reload sudo systemctl enable --now octet-edge sudo journalctl -u octet-edge -n 20 ``` The log shows `listening :443 (TLS)` when TLS is configured. If it shows `plain HTTP`, the certificate settings are missing. If `OCTET_URL` isn't `https://`, or the client certificate or key setting is missing, the edge exits and logs what to fix, for example `mTLS to octet is required, set EDGE_CLIENT_CERT_FILE, EDGE_CLIENT_KEY_FILE`. ## 8. Register the signing key At startup the edge logs the signing key's ID, `kid`, and its public key: ```text [octet-edge] S2 signing on: kid=edge-… ed25519 public key=… (register it in the licence portal) ``` In the Octet portal, open your Octet Browser license and select the **Edge configuration** tab, then **Signing keys**. Paste the `kid` and the public key from that line, and select **Register key**. Only a portal user with the owner or admin role can register a key. The public key is not a secret. If the log shows `WARNING: EDGE_SIGNING_KEY is unset` instead, the edge forwards sessions without a signature. Set `EDGE_SIGNING_KEY` and restart the edge. If the edge exits with `EDGE_SIGNING_KEY must be a base64 32-byte Ed25519 seed`, the value isn't the output of the `openssl` command above. Octet checks the signature on every session that carries one, and refuses a session whose signature fails the check. From v1.5.0, Octet also refuses a session with no signature, or one signed by a key that isn't registered for your license, with `401`. To rotate or revoke a signing key, see [Credentials](/integration/credentials#rotate-an-edge-signing-key). ## 9. Check it From any machine: ```bash curl -s https://octet.example.com/health ``` The edge answers: ```json {"ok":true,"role":"octet-edge"} ``` A healthy `/health` shows that the edge is up and serving TLS. It does not contact Octet. To check that Octet accepts your license token, send an empty body to `/v1/signals`: ```bash curl -s -X POST https://octet.example.com/v1/signals -H 'content-type: application/json' -d '{}' ``` An edge whose license, client certificate and signature Octet accepts gets `400` with `"reason":"unsealed_bundle"`, and a refused license gets `401`. A `401` with `"reason":"client_cert_required"` means Octet didn't receive a client certificate it issued: check `EDGE_CLIENT_CERT_FILE` and `EDGE_CLIENT_KEY_FILE`, and that the certificate hasn't expired. A `403` with `"reason":"client_cert_licence_mismatch"` means the certificate was issued for a different license than the one in `LICENSE`. A `403` with `"reason":"client_cert_revoked"` means the certificate was revoked in the portal: install a new one. A `401` with a reason that starts with `edge_signature` or `edge_key` means Octet refused the edge's signature. See [Errors](/reference/errors#reasons-octet-gives-your-edge), and [Go-Live Checklist](/integration/go-live-checklist) for the full test plan. ## Update the edge Download and verify the new release's binary as in step 1, install it over `/usr/local/bin/octet-edge`, and restart the service: ```bash sudo systemctl restart octet-edge ``` The new binary uses the same environment file, so it keeps its client certificate and signing key, and the key you registered stays valid. ## Next [Embed the Collector](/integration/embed-collector) on your pages, with `apiUrl: 'https://octet.example.com'`. _Source: https://octetproof.com/docs/browser/integration/deploy-edge/_ --- # Embed the Collector The collector is a JavaScript file you serve from your own site. Your backend gets a one-time challenge from Octet for each page view. The page calls `start()` with it when the page loads, and `ready()` when the user acts. This guide assumes your edge runs at `https://octet.example.com`. See [Deploy the Edge](/integration/deploy-edge). ## 1. Install the collector Every Octet Browser file is a release asset in the public repository [octetproof/octet-browser](https://github.com/octetproof/octet-browser/releases). No registry account or access token is needed. The commands in this guide download the [latest release](https://github.com/octetproof/octet-browser/releases/latest). Note its version number, and install the collector and the edge from the same release. ### As a script tag Download the script and its Subresource Integrity hash from the release: ```bash curl -fLO https://github.com/octetproof/octet-browser/releases/latest/download/octet-collector.js curl -fLO https://github.com/octetproof/octet-browser/releases/latest/download/octet-collector.js.sri ``` Serve `octet-collector.js` from your own origin, and put the contents of `octet-collector.js.sri` in the `integrity` attribute: ```html ``` Serving the file yourself keeps the collector first-party and covered by your own `script-src`. Don't point the `src` at the GitHub release URL. GitHub serves release assets as downloads, without the headers a browser needs to run a cross-origin script with an `integrity` check, so the script won't load. The script defines a global `octet` object with the functions `octet.start()`, `octet.ready()` and `octet.verify()`. ### With npm or a bundler Install the package straight from the release. The package file name carries the version, so set `VERSION` to the latest release's version number, without the `v`: ```bash VERSION=REPLACE_WITH_THE_LATEST_VERSION npm install "https://github.com/octetproof/octet-browser/releases/download/v$VERSION/octetproof-collector-$VERSION.tgz" ``` npm records the URL in your `package.json`, so every install gets the same version. Then import the functions: ```js import { start, ready } from '@octetproof/collector'; ``` The package includes TypeScript types. ## 2. Create a `sessionRef` and get a challenge For each page view, your backend creates a `sessionRef` and gets a challenge for it from Octet. The collector needs both. ### Create the `sessionRef` Each page view needs its own `sessionRef`. Your backend fetches the verdict with it, so it must be unguessable and must never be reused. Generate at least 128 random bits and encode them URL-safe: ```js // Node.js import { randomBytes } from 'node:crypto'; const sessionRef = randomBytes(32).toString('base64url'); ``` Store the `sessionRef` in the user's server-side session. The rules for session references, and why they matter, are in [The session reference](/concepts/how-it-works#the-session-reference). ### Get a challenge A challenge is a one-time token from Octet, bound to your license and to one `sessionRef`. Octet accepts a collection only when it carries a valid challenge. Your backend requests one with your read token: ```bash curl -s -X POST https://geo.octetproof.com/v1/challenge \ -H "Authorization: Bearer $OCTET_READ_TOKEN" \ -H 'content-type: application/json' \ -d '{"sessionRef":"SESSION_REF_FROM_YOUR_BACKEND"}' ``` | Field | Required | Description | |---|---|---| | `sessionRef` | Yes | The `sessionRef` you created for this page view. A string of 1 to 256 characters. | | `clientIp` | No | The user's IP address as your backend saw it. If your backend runs behind a proxy or load balancer, send the client address it reports, for example from the `X-Forwarded-For` entry your own proxy added. With it, the verdict token carries `clientNetMatch`. See [Check the user's network](/integration/verify-token#check-the-users-network). | The response is: ```json { "challenge": "v4.public.…", "exp": 1791000600 } ``` `exp` is when the challenge expires, in Unix seconds. A challenge is valid for 10 minutes and works for one collection. Treat it as an opaque string and pass it to the page unchanged. If your backend can't reach `geo.octetproof.com`, send the same request to your edge at `https://octet.example.com/v1/challenge`. The edge forwards it to Octet unchanged, with your `Authorization` header, and returns Octet's response. In Node.js: ```js export async function getChallenge(sessionRef, clientIp) { const res = await fetch('https://geo.octetproof.com/v1/challenge', { method: 'POST', headers: { Authorization: `Bearer ${process.env.OCTET_READ_TOKEN}`, 'content-type': 'application/json', }, body: JSON.stringify({ sessionRef, ...(clientIp ? { clientIp } : {}) }), signal: AbortSignal.timeout(5000), }); if (!res.ok) throw new Error(`octet challenge failed: ${res.status} ${await res.text()}`); return (await res.json()).challenge; } ``` Render the `sessionRef` and the challenge into the page. To check later that a verdict answers this challenge, store the challenge with the `sessionRef`. See [The chain](/integration/verify-token#the-chain). Every error this request can return is in [Errors](/reference/errors#from-post-v1challenge). ## 3. Start at page load, wait at the moment of action Call `start()` as soon as the page loads, so the result is ready by the time the user acts. Call `ready()` when the user acts: ```html ``` `ready()` resolves once your edge has passed the session to Octet, which means your backend can now fetch the verdict. Fetch it within 30 seconds of `ready()` resolving. When `ready()` resolves, the verdict can already be up to 90 seconds old, and verdicts expire at 2 minutes. See [Fetch the Verdict](/integration/fetch-verdict). Each challenge works for one collection. `ready()` reuses the result of the last collection for 90 seconds. After that, or after a collection whose POST to your edge failed, `ready()` rejects with `octet: this challenge was already used`. To collect again, get a new challenge from your backend and call `start()` with it. To start over with a new `sessionRef`, get a challenge for it and call `start()` again. `ready()` resolves with `{ response, issuedAtMs, expiresAtMs, elapsedMs }`. `response` is `{ "ok": true }`. It never contains the verdict. See [Collector API](/reference/collector-api). ## 4. Choose a mode The `mode` option sets how much network measurement the collector does. The default is `full`. | Mode | What it does | Network requests beyond your edge | Typical time to `ready()` | |---|---|---|---| | `full` | Everything, for the strongest results | HTTPS and UDP 3478 to three Octet network hosts | Up to about 1.5 s, or up to about 4.5 s on some connections | | `lite` | Skips the HTTPS requests to the Octet network hosts | UDP 3478 to three Octet network hosts | Up to about 1 s, or up to about 3 s on some connections | | `passive` | No network measurement and no WebRTC | None | The time of one request to your edge | In every mode the collector sends one POST to your edge. In `full` and `lite` it also opens a WebSocket to your edge. Every host the collector contacts is run by you or by Octet. `lite` and `passive` give weaker results: more masked sessions come back as `none` or `low`. ```js octet.start({ apiUrl: 'https://octet.example.com', sessionRef, challenge, mode: 'lite' }); ``` Each mode's hosts, ports and CSP entries are in [Network and CSP](/reference/network). ## 5. Rules the collector enforces - **`apiUrl` must be `https://`.** On any other scheme, `start()` throws and `verify()` rejects at once. The only exception is `http://localhost`, `http://127.0.0.1` or `http://[::1]`, for local development. - **The WebSocket URL follows `apiUrl`.** It is `wss://` on the same host, at `/v1/ws`. - **`challenge` is required.** Without it, `start()` throws and `verify()` rejects at once. If a challenge the collector has already sent is used again, `ready()` or `verify()` rejects before anything is sent. - **Only the POST to your edge must succeed.** A measurement that fails or is blocked is left out, and the collector sends what it has. Of the collector's requests, only a failed POST to your edge makes `ready()` or `verify()` reject. ## Cancel a collection Pass an `AbortSignal` to stop a collection, for example when the user leaves the page: ```js const controller = new AbortController(); octet.start({ apiUrl: 'https://octet.example.com', sessionRef, challenge, signal: controller.signal }); // later controller.abort(); ``` Aborting stops the collection and cancels the POST to your edge if it has started. `ready()` and `verify()` then reject with an `AbortError`, or with your own reason if you passed one to `abort()`. If the abort comes before the POST reaches your edge, no verdict is produced for that run. ## One-shot collection `verify()` runs one collection and resolves with your edge's reply. Use it where there is no separate moment of action, for example on a sign-in page: ```js await octet.verify({ apiUrl: 'https://octet.example.com', sessionRef, challenge }); ``` `verify()` takes the same options as `start()`. Each call needs a new challenge. _Source: https://octetproof.com/docs/browser/integration/embed-collector/_ --- # Fetch the Verdict Your backend fetches each session's verdict directly from Octet with a read token. See [Credentials](/integration/credentials) for how to create one. ## The request ```bash curl -s "https://geo.octetproof.com/v1/verdict/$SESSION_REF?waitMs=5000" \ -H "Authorization: Bearer $OCTET_READ_TOKEN" ``` | Part | Value | |---|---| | Method and path | `GET /v1/verdict/{sessionRef}` on `https://geo.octetproof.com` | | `Authorization` | `Bearer octet_read_…`, your read token | | `waitMs` | Optional. How long Octet waits for the verdict to arrive before answering, in milliseconds, from `0` to `10000`. Values above `10000` are treated as `10000`. The default is `0`, which answers at once. | ## The responses **`200`**: the verdict. ```json { "country": "DE", "confidence": 0.91, "alarm": "none", "deviceKey": "dk1_bvlgyviLtYS6pbE9nt7wA11rwpiH73CPbsBOhOQOEWg", "token": "eyJhbGciOiJFZERTQSIsInR5cCI6Im9jdGV0LWJyb3dzZXItdmVyZGljdCtqd3Q7dj0zIi..." } ``` Every field is described in [Verdict Reference](/reference/verdict). `token` is the signed copy of the verdict. See [Verify the Signed Token](/integration/verify-token). **`404`**: no verdict for this `sessionRef`. ```json { "status": "pending", "ref": "Vq3n0m6c2x6J5aZ8yP1tQw" } ``` Octet answers `404` in any of these cases: - The session hasn't reached Octet yet. Retry with a `waitMs`. - The verdict is more than 2 minutes old and has been discarded. - The `sessionRef` is wrong, or the collector never ran. - The session arrived under a different license from your read token's license. **`401`**: the read token was refused. The body names the reason, for example `{ "error": "expired" }`. See [Errors](/reference/errors). **`503`**: `{ "error": "verdict_signing_unavailable" }`. Octet could not sign the verdict. Retry after a short wait. ## How long a verdict lasts Octet holds each verdict for 2 minutes after the session reaches it. Within that time you can fetch it as often as you like. Repeated fetches can return the same `token`, which stays valid until its `exp`. After 2 minutes the fetch returns `404`. Octet stores the first verdict for a `sessionRef` and keeps it until it expires. A second collection with the same `sessionRef` within those 2 minutes doesn't replace it. Use a new `sessionRef` for each page view. See [The session reference](/concepts/how-it-works#the-session-reference). ## Node.js Node.js 18 or later, with no dependencies: ```js const OCTET = 'https://geo.octetproof.com'; export async function fetchVerdict(sessionRef) { const url = `${OCTET}/v1/verdict/${encodeURIComponent(sessionRef)}?waitMs=5000`; const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.OCTET_READ_TOKEN}` }, signal: AbortSignal.timeout(8000), }); if (res.status === 404) return null; // no verdict: treat as unknown if (!res.ok) throw new Error(`octet verdict fetch failed: ${res.status} ${await res.text()}`); return res.json(); } ``` ## Python Python 3.9 or later, with `requests`: ```python import os import urllib.parse import requests OCTET = "https://geo.octetproof.com" def fetch_verdict(session_ref: str): url = f"{OCTET}/v1/verdict/{urllib.parse.quote(session_ref, safe='')}" res = requests.get( url, params={"waitMs": 5000}, headers={"Authorization": f"Bearer {os.environ['OCTET_READ_TOKEN']}"}, timeout=8, ) if res.status_code == 404: return None # no verdict: treat as unknown res.raise_for_status() return res.json() ``` Set your HTTP client's timeout longer than `waitMs`, as both examples do. ## Apply your policy Decide what a missing verdict means before you go live. For a regulated action, treat it like a masked connection. See [Verdicts](/concepts/verdicts) for a policy example. _Source: https://octetproof.com/docs/browser/integration/fetch-verdict/_ --- # Verify the Signed Token Every verdict your backend fetches carries a `token`: a copy of the verdict signed by Octet. Verify it whenever a verdict passes through a system you don't fully control, such as a queue or another service. Store it with your decision record, so you can later show exactly what Octet returned. The token reaches only your backend. The browser never receives it. This page covers the check at fetch time. To keep tokens for an auditor and check them after they expire, see [Keep Verdicts for Audit](/integration/keep-verdicts). ## Format The token is a compact JWS (RFC 7515). Octet changes its signing key on 19 October 2026 at 15:00 UK time: | Tokens signed | `alg` | `kid` | Key | |---|---|---|---| | Before 19 October 2026, 15:00 UK time | `EdDSA` (Ed25519, RFC 8037) | `octet-browser-verdict-2026-09` | An `OKP` Ed25519 key | | From 19 October 2026, 15:00 UK time | `ES256` (ECDSA P-256, RFC 7518) | `octet-browser-verdict-2026-10` | An `EC` P-256 key, held in a hardware security module | Accept both. Take the algorithm from the key the `kid` names, never from the token alone: `ES256` for an `EC` key, `EdDSA` for an `OKP` key. The examples on this page do this, so they verify tokens from before and after the change. The header of a token signed from 19 October 2026 is: ```json { "alg": "ES256", "typ": "octet-browser-verdict+jwt;v=3", "kid": "octet-browser-verdict-2026-10" } ``` An `ES256` signature is the raw 64-byte `r` and `s`, as RFC 7518 sets for JWS, not a DER-encoded signature. The `v` in `typ` is the claim-set version. Version 2 added `deviceKey`, and version 3 added `chain`, `chain_root` and `clientNetMatch` and removed `clientNet`. Accept `;v=1`, `;v=2` and `;v=3`, so that tokens you stored before an update still verify. Check `typ` against those three values exactly. That check stops another kind of JWT from being accepted in its place. ## Claims | Claim | Type | Meaning | |---|---|---| | `iss` | string | Always `"octet-browser"`. | | `iat` | number | When the token was signed, in Unix seconds. | | `exp` | number | When the token expires, in Unix seconds. Always `iat` plus 300, so 5 minutes. | | `sessionRef` | string | The `sessionRef` the verdict belongs to. | | `country` | string | As in the verdict. Absent when the verdict has no `country`. | | `confidence` | number | As in the verdict. | | `alarm` | string | As in the verdict. | | `deviceKey` | string | As in the verdict. See [Verdict Reference](/reference/verdict#fields). From version 2. | | `clientNetMatch` | boolean | Whether the collection came from the network your backend saw. Present only when your backend sent `clientIp` with the challenge request. See [Check the user's network](#check-the-users-network). From version 3. | | `chain` | object | A record of the signed steps of the session. Opaque, except `chain.s0`. See [The chain](#the-chain). From version 3. | | `chain_root` | string | Opaque. Store it with the token. See [The chain](#the-chain). From version 3. | | `modelVersion` | string | An opaque version string for the Octet release that produced the verdict. | Absent values are left out, never sent as `null`. The claims repeat the verdict fields beside the token in the same response. After verifying, use the claims and not the unsigned fields. Ignore claims you don't recognise. ## The key set Octet publishes its verification keys as a JSON Web Key Set: ```text https://geo.octetproof.com/.well-known/browser-verdict-jwks.json ``` From 19 October 2026, 15:00 UK time, it lists the `ES256` key: ```json { "keys": [ { "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", "kid": "octet-browser-verdict-2026-10", "x": "...", "y": "...", "octet_purpose": "browser-verdict" } ] } ``` Before then, it lists the Ed25519 key, which has `"kty": "OKP"`, `"crv": "Ed25519"`, `"alg": "EdDSA"`, `"kid": "octet-browser-verdict-2026-09"`, and a single `x`. Pick the key whose `kid` matches the token header. Its key type sets the algorithm: `EdDSA` for an `OKP` Ed25519 key and `ES256` for an `EC` P-256 key. Refuse a token whose `alg` doesn't match its key. Cache the key set for up to 5 minutes, and fetch it again when a token carries a `kid` you haven't seen. That is how you pick up a new key without a restart. ### When the key changes The key set lists the key Octet signs with now. For 5 minutes after a key change, the token lifetime, it also lists the key before it, so a token signed just before the change still verifies until it expires. After that, the old key leaves the key set. The old key stays in the key archive, at `https://geo.octetproof.com/.well-known/browser-verdict-jwks-archive.json`, which lists every key Octet has signed verdicts with and never drops one. After the change on 19 October 2026, the archive lists both `octet-browser-verdict-2026-09` and `octet-browser-verdict-2026-10`, so tokens you stored before the change stay verifiable. See [Keep Verdicts for Audit](/integration/keep-verdicts#1-keep-every-octet-key-you-verify-with). Every key in the archive is also listed in a manifest signed by Octet's offline root keys. See [Check a key against Octet's root keys](#check-a-key-against-octets-root-keys). ## What to check 1. The header's `typ` is `octet-browser-verdict+jwt;v=3`, `;v=2` or `;v=1`. 2. The key for the header's `kid` is an `OKP` Ed25519 key and `alg` is `EdDSA`, or it is an `EC` P-256 key and `alg` is `ES256`. 3. The signature verifies with that key. 4. `iss` is `octet-browser`. 5. `exp` has not passed. Allow up to 30 seconds of clock difference. 6. `sessionRef` equals the `sessionRef` you created for this user's session. This stops a valid token from another session being replayed as this one. Recommended: the first time you meet a `kid`, also check that Octet's root-signed manifest lists its key. See [Check a key against Octet's root keys](#check-a-key-against-octets-root-keys). These checks are for a token you have just fetched. A stored token is already past its `exp`, so it gets a different time check. See [Check a stored token](/integration/keep-verdicts#3-check-a-stored-token). ## Node.js Node.js 18 or later, with no dependencies. Save this as `verify-verdict.mjs`: ```js // verify-verdict.mjs: verify an Octet verdict token. Node.js 18 or later, no dependencies. import { createPublicKey, verify } from 'node:crypto'; const JWKS_URL = process.env.OCTET_JWKS_URL ?? 'https://geo.octetproof.com/.well-known/browser-verdict-jwks.json'; const TYPS = [ 'octet-browser-verdict+jwt;v=3', 'octet-browser-verdict+jwt;v=2', 'octet-browser-verdict+jwt;v=1', ]; const LEEWAY_S = 30; // allowed clock difference, in seconds let cache = { fetchedAt: 0, keys: [] }; async function getJwk(kid) { const stale = Date.now() - cache.fetchedAt > 300_000; let jwk = stale ? undefined : cache.keys.find((k) => k.kid === kid); if (!jwk) { // Refetch when the cache is older than 5 minutes or the kid is new. const res = await fetch(JWKS_URL, { signal: AbortSignal.timeout(5000) }); if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`); cache = { fetchedAt: Date.now(), keys: (await res.json()).keys }; jwk = cache.keys.find((k) => k.kid === kid); } if (!jwk) throw new Error(`unknown key id: ${kid}`); return jwk; } const decode = (part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); /** * Checks the signature of a token with a JWK from Octet's key set. The key type * sets the algorithm: EdDSA for an OKP Ed25519 key, ES256 for an EC P-256 key. */ export function checkSignature(token, jwk) { const parts = token.split('.'); if (parts.length !== 3) throw new Error('malformed token'); const [h, p, s] = parts; const header = decode(h); if (!TYPS.includes(header.typ)) throw new Error('not an Octet verdict token'); if (header.kid !== jwk.kid) throw new Error('key id does not match the token'); const input = Buffer.from(`${h}.${p}`); const sig = Buffer.from(s, 'base64url'); let ok; if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519' && header.alg === 'EdDSA') { const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }); ok = verify(null, input, key, sig); } else if (jwk.kty === 'EC' && jwk.crv === 'P-256' && header.alg === 'ES256') { const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }, format: 'jwk' }); ok = verify('sha256', input, { key, dsaEncoding: 'ieee-p1363' }, sig); } else { throw new Error('alg does not match the key'); } if (!ok) throw new Error('bad signature'); return decode(p); } /** Returns the verified claims, or throws. */ export async function verifyVerdictToken(token, expectedSessionRef) { const parts = token.split('.'); if (parts.length !== 3) throw new Error('malformed token'); const jwk = await getJwk(decode(parts[0]).kid); const claims = checkSignature(token, jwk); const now = Math.floor(Date.now() / 1000); if (claims.iss !== 'octet-browser') throw new Error('wrong issuer'); if (typeof claims.exp !== 'number' || now > claims.exp + LEEWAY_S) throw new Error('expired'); if (claims.sessionRef !== expectedSessionRef) throw new Error('sessionRef does not match'); return claims; } ``` Use it with the `sessionRef` you stored for this user: ```js import { verifyVerdictToken } from './verify-verdict.mjs'; const claims = await verifyVerdictToken(verdict.token, sessionRef); // claims.country, claims.confidence, claims.alarm ``` ## Python Python 3.9 or later, with PyJWT 2.6 or later. Install it with `pip install "pyjwt[crypto]>=2.6"` and save this as `verify_verdict.py`: ```python # verify_verdict.py: verify an Octet verdict token. Python 3.9 or later. # pip install "pyjwt[crypto]>=2.6" import os import jwt JWKS_URL = os.environ.get( "OCTET_JWKS_URL", "https://geo.octetproof.com/.well-known/browser-verdict-jwks.json" ) TYPS = ( "octet-browser-verdict+jwt;v=3", "octet-browser-verdict+jwt;v=2", "octet-browser-verdict+jwt;v=1", ) LEEWAY_S = 30 # allowed clock difference, in seconds # Caches the keys for 5 minutes and refetches when it meets a new key id. _jwks = jwt.PyJWKClient(JWKS_URL, cache_keys=True, lifespan=300) def check_header(token: str, key: "jwt.PyJWK") -> None: """Checks typ, and that alg is the one the key's type sets: EdDSA for OKP Ed25519, ES256 for EC P-256.""" header = jwt.get_unverified_header(token) if header.get("typ") not in TYPS: raise ValueError("not an Octet verdict token") if header.get("kid") != key.key_id: raise ValueError("key id does not match the token") if key.algorithm_name not in ("EdDSA", "ES256") or header.get("alg") != key.algorithm_name: raise ValueError("alg does not match the key") def verify_verdict_token(token: str, expected_session_ref: str) -> dict: """Returns the verified claims, or raises.""" key = _jwks.get_signing_key(jwt.get_unverified_header(token)["kid"]) check_header(token, key) claims = jwt.decode( token, key.key, algorithms=[key.algorithm_name], issuer="octet-browser", leeway=LEEWAY_S, options={"require": ["iss", "iat", "exp", "sessionRef"]}, ) if claims["sessionRef"] != expected_session_ref: raise ValueError("sessionRef does not match") return claims ``` Use it with the `sessionRef` you stored for this user: ```python from verify_verdict import verify_verdict_token claims = verify_verdict_token(verdict["token"], session_ref) # claims["country"], claims["confidence"], claims["alarm"] ``` ## Check the user's network `clientNetMatch` tells you whether the collection reached your edge from the same network your backend saw. To get it, send the user's IP address as `clientIp` when your backend requests the challenge. See [Get a challenge](/integration/embed-collector#2-create-a-sessionref-and-get-a-challenge). Octet compares the network of that address with the network your edge saw. The token carries only the result: | Value | Meaning | |---|---| | `true` | The collection came from the same network as `clientIp`. | | `false` | The collection came from a different network. | | Absent | Your backend sent no `clientIp`. | Don't block on `false` alone. A user can reach your edge and your backend over different networks, for example over IPv6 to one and IPv4 to the other. Record the mismatch and weigh it with the verdict. ## The chain From version 3, the token carries `chain` and `chain_root`. They record the signed steps Octet checked for the session. They contain no collected data and no IP address. Treat both as opaque, and store them with the token for audit. The token's signature already covers them. You can check one link yourself. Keep the challenge token your backend received from `POST /v1/challenge`. `chain.s0` equals the SHA-256 of that token's exact string, base64url-encoded without padding: ```js import { createHash } from 'node:crypto'; const s0 = createHash('sha256').update(challengeToken).digest('base64url'); if (claims.chain?.s0 !== s0) throw new Error('verdict does not answer this challenge'); ``` The challenge's public key is at `https://geo.octetproof.com/.well-known/browser-challenge-jwks.json`. The chain shows which signed inputs the verdict was computed from. It doesn't show that what the browser reported is true. See [Integrity and Audit](/concepts/integrity). ## Check a key against Octet's root keys The key set, the key archive and the challenge key set are all served by `geo.octetproof.com`. To check a key without relying on that server, check it against the manifest that Octet's offline root keys sign. The manifest is a compact JWS at: ```text https://geo.octetproof.com/.well-known/octet-browser-manifest.json.jws ``` Its header is `{ "alg": "ES256", "typ": "octet-root+jws;v=1", "kid": "" }`, and its payload is the manifest JSON. `https://geo.octetproof.com/.well-known/octet-browser-manifest.json` serves the payload alone. Pin these two root public keys in your code. Copy them from this page, and never fetch a root key from the server whose keys you are checking: ```json { "roots": [ { "kty": "EC", "crv": "P-256", "kid": "octet-root-a-2026-10", "x": "-TTm69g0eOIcWb-I9gVwsZCsSNTSPJIjmuJQetUVO7o", "y": "SfDnpa7Jtnw8FDYvcPnZUJZN8D2wQSztUMlucUHyM6A", "octet_purpose": "octet-root" }, { "kty": "EC", "crv": "P-256", "kid": "octet-root-b-2026-10", "x": "Nl3Zsqh92UZPPCVGqpQYPP-pZoKI7_YNhGG4qaS4qM8", "y": "6MdPuYVrKIeDV4_gSDfqL5EnABIwXSyjo8-gd3i9TtM", "octet_purpose": "octet-root" } ] } ``` The private halves of the root keys are kept offline, never on a server. One signature from either root key is enough. ### What the manifest lists | Field | Meaning | |---|---| | `typ`, `v` | Always `"octet-browser-manifest"` and `1`. | | `seq` | The manifest's number. Each new manifest has a higher `seq`. | | `iat`, `exp` | When the manifest was signed, and when it expires, in Unix seconds. | | `verdictKeys` | Every key Octet has signed verdict tokens with, exactly as the key archive lists them, with `octet_not_before`, `octet_not_after` and any revocation. | | `challengeKeys` | The keys that sign challenges. | | `roots` | The `kid`s of the root keys that may sign the next manifest. | From v1.6.0 the manifest also lists Octet's license keys and other keys Octet's own services check. Its `typ` and `v` are unchanged. Ignore the fields you don't use, so a manifest that adds fields doesn't break your check. ### Check the manifest To trust a verdict key: 1. Verify the manifest JWS with the pinned root key named by its `kid`. The algorithm is `ES256`, and the signature is the raw 64-byte `r` and `s`. 2. Check that the payload has `"typ": "octet-browser-manifest"` and `"v": 1`, and that its `exp` has not passed. 3. Remember the highest `seq` you have seen, and refuse a manifest with a lower one. This stops an older manifest, signed before a key was revoked, from being served to you again. 4. Accept the token's `kid` only if the manifest's `verdictKeys` lists it with the same key. A challenge key is checked the same way, against `challengeKeys`. Octet signs a new manifest, with `seq` one higher, whenever a key changes, and at least once a year. Octet's servers sign with a verdict key only while the manifest they serve lists it, so the manifest lists `octet-browser-verdict-2026-10` from the moment that key signs its first token, on 19 October 2026 at 15:00 UK time. Because `roots` names the root keys that may sign the next manifest, Octet can replace a root key without you pinning again. Check the manifest when your verifier meets a `kid` it hasn't checked before, not on every token. Keep the keys it confirmed, and the highest `seq`. In Node.js 18 or later, with no dependencies. Save this as `check-manifest.mjs`: ```js // check-manifest.mjs: check Octet's keys against its root-signed manifest. Node.js 18 or later, no dependencies. import { createPublicKey, verify } from 'node:crypto'; const MANIFEST_URL = process.env.OCTET_MANIFEST_URL ?? 'https://geo.octetproof.com/.well-known/octet-browser-manifest.json.jws'; // Octet's root public keys, pinned from the docs. Never fetch these from Octet's servers. const ROOTS = [ { kty: 'EC', crv: 'P-256', kid: 'octet-root-a-2026-10', x: '-TTm69g0eOIcWb-I9gVwsZCsSNTSPJIjmuJQetUVO7o', y: 'SfDnpa7Jtnw8FDYvcPnZUJZN8D2wQSztUMlucUHyM6A', }, { kty: 'EC', crv: 'P-256', kid: 'octet-root-b-2026-10', x: 'Nl3Zsqh92UZPPCVGqpQYPP-pZoKI7_YNhGG4qaS4qM8', y: '6MdPuYVrKIeDV4_gSDfqL5EnABIwXSyjo8-gd3i9TtM', }, ]; const decode = (part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); /** * Fetches the manifest, verifies it with a pinned root key, and returns its payload. * Pass the highest seq you have stored, and store the returned seq. */ export async function fetchManifest(highestSeqSeen = 0) { const res = await fetch(MANIFEST_URL, { signal: AbortSignal.timeout(5000) }); if (!res.ok) throw new Error(`manifest fetch failed: ${res.status}`); const parts = (await res.text()).trim().split('.'); if (parts.length !== 3) throw new Error('malformed manifest'); const [h, p, s] = parts; const header = decode(h); if (header.alg !== 'ES256' || header.typ !== 'octet-root+jws;v=1') { throw new Error('not an Octet root manifest'); } const root = ROOTS.find((r) => r.kid === header.kid); if (!root) throw new Error(`manifest signed by an unpinned key: ${header.kid}`); const key = createPublicKey({ key: root, format: 'jwk' }); const ok = verify('sha256', Buffer.from(`${h}.${p}`), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(s, 'base64url')); if (!ok) throw new Error('bad manifest signature'); const manifest = decode(p); if (manifest.typ !== 'octet-browser-manifest' || manifest.v !== 1) { throw new Error('unknown manifest format'); } if (typeof manifest.exp !== 'number' || Date.now() / 1000 > manifest.exp) { throw new Error('manifest expired'); } if (typeof manifest.seq !== 'number' || manifest.seq < highestSeqSeen) { throw new Error('manifest is older than one already seen'); } return manifest; } const sameKey = (a, b) => a.kid === b.kid && a.kty === b.kty && a.crv === b.crv && a.x === b.x && (a.y ?? null) === (b.y ?? null); /** Returns the manifest's entry for a verdict key, or throws if the manifest doesn't list that key. */ export function listedVerdictKey(manifest, jwk) { const entry = manifest.verdictKeys.find((k) => sameKey(k, jwk)); if (!entry) throw new Error(`verdict key not in Octet's manifest: ${jwk.kid}`); return entry; } ``` In Python 3.9 or later, with PyJWT 2.6 or later. Save this as `check_manifest.py`: ```python # check_manifest.py: check Octet's keys against its root-signed manifest. Python 3.9 or later. # pip install "pyjwt[crypto]>=2.6" import json import os import time import urllib.request import jwt MANIFEST_URL = os.environ.get( "OCTET_MANIFEST_URL", "https://geo.octetproof.com/.well-known/octet-browser-manifest.json.jws" ) # Octet's root public keys, pinned from the docs. Never fetch these from Octet's servers. ROOTS = { "octet-root-a-2026-10": { "kty": "EC", "crv": "P-256", "kid": "octet-root-a-2026-10", "x": "-TTm69g0eOIcWb-I9gVwsZCsSNTSPJIjmuJQetUVO7o", "y": "SfDnpa7Jtnw8FDYvcPnZUJZN8D2wQSztUMlucUHyM6A", }, "octet-root-b-2026-10": { "kty": "EC", "crv": "P-256", "kid": "octet-root-b-2026-10", "x": "Nl3Zsqh92UZPPCVGqpQYPP-pZoKI7_YNhGG4qaS4qM8", "y": "6MdPuYVrKIeDV4_gSDfqL5EnABIwXSyjo8-gd3i9TtM", }, } def fetch_manifest(highest_seq_seen: int = 0) -> dict: """Fetches the manifest, verifies it with a pinned root key, and returns its payload. Pass the highest seq you have stored, and store the returned seq.""" with urllib.request.urlopen(MANIFEST_URL, timeout=5) as res: jws = res.read().decode().strip() header = jwt.get_unverified_header(jws) if header.get("alg") != "ES256" or header.get("typ") != "octet-root+jws;v=1": raise ValueError("not an Octet root manifest") root = ROOTS.get(header.get("kid")) if root is None: raise ValueError(f"manifest signed by an unpinned key: {header.get('kid')}") payload = jwt.api_jws.decode(jws, jwt.PyJWK(root, "ES256").key, algorithms=["ES256"]) manifest = json.loads(payload) if manifest.get("typ") != "octet-browser-manifest" or manifest.get("v") != 1: raise ValueError("unknown manifest format") if not isinstance(manifest.get("exp"), int) or time.time() > manifest["exp"]: raise ValueError("manifest expired") if not isinstance(manifest.get("seq"), int) or manifest["seq"] < highest_seq_seen: raise ValueError("manifest is older than one already seen") return manifest def listed_verdict_key(manifest: dict, jwk: dict) -> dict: """Returns the manifest's entry for a verdict key, or raises if the manifest doesn't list that key.""" fields = ("kid", "kty", "crv", "x", "y") for entry in manifest["verdictKeys"]: if all(entry.get(f) == jwk.get(f) for f in fields): return entry raise ValueError(f"verdict key not in Octet's manifest: {jwk.get('kid')}") ``` Use it with the key your verifier picked for the token's `kid`, before you trust that key for the first time: ```js import { fetchManifest, listedVerdictKey } from './check-manifest.mjs'; const manifest = await fetchManifest(highestSeqSeen); listedVerdictKey(manifest, jwk); // throws if Octet's root keys didn't sign this key highestSeqSeen = manifest.seq; // store it ``` ```python from check_manifest import fetch_manifest, listed_verdict_key manifest = fetch_manifest(highest_seq_seen) listed_verdict_key(manifest, jwk) # raises if Octet's root keys didn't sign this key highest_seq_seen = manifest["seq"] # store it ``` For a stored token, use the entry `listedVerdictKey` returns: its `octet_not_before`, `octet_not_after` and any `octet_revoked_at` are the dates for the check in [Confirm each key](/integration/keep-verdicts#confirm-each-key). ## Test the examples You can test your verifier before you have live traffic, with a token signed by a local test key. 1. Put `verify-verdict.mjs` or `verify_verdict.py` in an empty directory, and save this script next to it as `make-test-token.mjs`: ```js // make-test-token.mjs: sign a test verdict token with a local test key. Node.js 18 or later. // Usage: node make-test-token.mjs [ageSeconds] // Writes test-key.pem (reused on later runs) and .well-known/browser-verdict-jwks.json. import { createPrivateKey, createPublicKey, generateKeyPairSync, sign } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; const [sessionRef = 'test-session-ref', age = '0'] = process.argv.slice(2); if (!existsSync('test-key.pem')) { const { privateKey } = generateKeyPairSync('ed25519'); writeFileSync('test-key.pem', privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }); } const privateKey = createPrivateKey(readFileSync('test-key.pem')); const kid = 'test-key'; const { x } = createPublicKey(privateKey).export({ format: 'jwk' }); mkdirSync('.well-known', { recursive: true }); writeFileSync('.well-known/browser-verdict-jwks.json', JSON.stringify({ keys: [{ kty: 'OKP', crv: 'Ed25519', use: 'sig', alg: 'EdDSA', kid, x }] })); const b64 = (v) => Buffer.from(JSON.stringify(v)).toString('base64url'); const iat = Math.floor(Date.now() / 1000) - Number(age); const header = b64({ alg: 'EdDSA', typ: 'octet-browser-verdict+jwt;v=3', kid }); const claims = b64({ iss: 'octet-browser', iat, exp: iat + 300, sessionRef, country: 'DE', confidence: 0.9, alarm: 'none', modelVersion: 'test', }); const signature = sign(null, Buffer.from(`${header}.${claims}`), privateKey).toString('base64url'); console.log(`${header}.${claims}.${signature}`); ``` 2. Create three tokens: a valid one, one signed 10 minutes ago, and one for a different `sessionRef`: ```bash node make-test-token.mjs ref-1 > good.txt node make-test-token.mjs ref-1 600 > expired.txt node make-test-token.mjs ref-2 > other.txt ``` 3. Serve the test key set and point the verifier at it: ```bash python3 -m http.server 8766 --bind 127.0.0.1 & export OCTET_JWKS_URL=http://127.0.0.1:8766/.well-known/browser-verdict-jwks.json ``` 4. Save one of these checks next to the verifier and run it. Each also builds a fourth token by editing `alarm` in the valid token's payload. ```js // check.mjs: run with node check.mjs import { readFileSync } from 'node:fs'; import { verifyVerdictToken } from './verify-verdict.mjs'; const read = (f) => readFileSync(f, 'utf8').trim(); const [h, p, s] = read('good.txt').split('.'); const edited = JSON.parse(Buffer.from(p, 'base64url').toString()); edited.alarm = 'high'; const tampered = `${h}.${Buffer.from(JSON.stringify(edited)).toString('base64url')}.${s}`; const cases = { good: read('good.txt'), tampered, expired: read('expired.txt'), other: read('other.txt') }; for (const [name, token] of Object.entries(cases)) { try { await verifyVerdictToken(token, 'ref-1'); console.log(name, 'accepted'); } catch (e) { console.log(name, 'rejected:', e.message); } } ``` ```python # check.py: run with python3 check.py import base64 import json from verify_verdict import verify_verdict_token def read(f): return open(f).read().strip() h, p, s = read("good.txt").split(".") edited = json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4))) edited["alarm"] = "high" tampered = ".".join([h, base64.urlsafe_b64encode(json.dumps(edited).encode()).decode().rstrip("="), s]) cases = {"good": read("good.txt"), "tampered": tampered, "expired": read("expired.txt"), "other": read("other.txt")} for name, token in cases.items(): try: verify_verdict_token(token, "ref-1") print(name, "accepted") except Exception as e: print(name, "rejected:", e) ``` The output shows `good accepted`, and `tampered`, `expired` and `other` each rejected. Unset `OCTET_JWKS_URL` before you deploy, so the verifier uses Octet's key set. _Source: https://octetproof.com/docs/browser/integration/verify-token/_ --- # Keep Verdicts for Audit This guide sets up the records an auditor needs to check your verdicts later, and shows how to check a stored token after it has expired. It builds on `verify-verdict.mjs` or `verify_verdict.py` from [Verify the Signed Token](/integration/verify-token). Why the check works is in [Integrity and Audit](/concepts/integrity). Octet holds each verdict and its token for 2 minutes and keeps neither after that, so your records are the only copy you can verify. The [device record](/concepts/how-it-works#device-record) that Understone Ltd keeps for your license holds each session's `country`, `confidence` and `alarm`, but no token, so it can't stand in for your records. ## 1. Keep every Octet key you verify with Octet's key set lists the key Octet signs with today. After Octet moves to a new key, the old key leaves the key set within 5 minutes. Octet keeps every key it has signed verdicts with in its key archive, at `https://geo.octetproof.com/.well-known/browser-verdict-jwks-archive.json`. Keep your own copy as well, so that a check of your records doesn't depend on reaching Octet: the first time a token carries a `kid` you haven't stored, store that key's entry from the key set. Tokens signed before 19 October 2026, 15:00 UK time, carry the `kid` `octet-browser-verdict-2026-09`, an Ed25519 key. Tokens signed from then carry `octet-browser-verdict-2026-10`, an ECDSA P-256 key. Keep both. The archive keeps `octet-browser-verdict-2026-09` after it leaves the key set, so if you didn't store it, take it from the archive. See [When the key changes](/integration/verify-token#when-the-key-changes). In Node.js, with `octetKeys` standing for your own storage: ```js const JWKS_URL = process.env.OCTET_JWKS_URL ?? 'https://geo.octetproof.com/.well-known/browser-verdict-jwks.json'; /** Stores the key set entry for a token's kid, the first time you see that kid. */ export async function keepKey(token) { const { kid } = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8')); if (await octetKeys.has(kid)) return; const res = await fetch(JWKS_URL, { signal: AbortSignal.timeout(5000) }); if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`); const jwk = (await res.json()).keys.find((k) => k.kid === kid); if (!jwk) throw new Error(`unknown key id: ${kid}`); await octetKeys.insert({ kid, jwk, firstSeen: new Date().toISOString() }); } ``` In Python: ```python import json import os import urllib.request from datetime import datetime, timezone import jwt JWKS_URL = os.environ.get( "OCTET_JWKS_URL", "https://geo.octetproof.com/.well-known/browser-verdict-jwks.json" ) def keep_key(token: str) -> None: """Stores the key set entry for a token's kid, the first time you see that kid.""" kid = jwt.get_unverified_header(token)["kid"] if octet_keys.has(kid): return with urllib.request.urlopen(JWKS_URL, timeout=5) as res: keys = json.load(res)["keys"] jwk = next((k for k in keys if k["kid"] == kid), None) if jwk is None: raise ValueError(f"unknown key id: {kid}") octet_keys.insert(kid=kid, jwk=jwk, first_seen=datetime.now(timezone.utc).isoformat()) ``` Call it only after the token has passed `verifyVerdictToken`, so that you store only keys that verified a real token. ## 2. Store a record for each decision When your backend acts on a verdict, verify the token, keep its key, and store these fields together: | Field | Where it comes from | Why | |---|---|---| | `token` | The verdict response | The signed verdict. Every claim is inside it. | | `sessionRef` | The user's server-side session | Links the token to the session. | | The user and the action | Your application | Links the session to a person and a decision, which the token doesn't record. | | `decision` | Your policy | What you did, for example allow, require KYC, or block. | | `policyVersion` | Your deployment | Which version of your policy made the decision. | | `decidedAt` | Your server's clock | When you acted. It falls between the token's `iat` and `exp`. | Store `token` exactly as Octet returned it. Any change to the string, including re-encoding it, makes the signature fail. In Node.js: ```js import { verifyVerdictToken } from './verify-verdict.mjs'; const claims = await verifyVerdictToken(verdict.token, sessionRef); await keepKey(verdict.token); await auditLog.insert({ token: verdict.token, sessionRef, userId: user.id, action: 'withdrawal', decision: decide(claims), policyVersion: POLICY_VERSION, decidedAt: new Date().toISOString(), }); ``` In Python: ```python from datetime import datetime, timezone from verify_verdict import verify_verdict_token claims = verify_verdict_token(verdict["token"], session_ref) keep_key(verdict["token"]) audit_log.insert( token=verdict["token"], session_ref=session_ref, user_id=user.id, action="withdrawal", decision=decide(claims), policy_version=POLICY_VERSION, decided_at=datetime.now(timezone.utc).isoformat(), ) ``` `auditLog`, `user` and `POLICY_VERSION` stand for your own storage and request data, and `decide` for your policy, such as the one in [Verdicts](/concepts/verdicts#choosing-a-policy). Keep the records for as long as your obligations require. ## 3. Check a stored token At fetch time, `exp` stops an old token from being accepted. A stored token is already past its `exp`, so this check compares the token's lifetime with your record instead: the decision time must fall between `iat` and `exp`. The other checks are the same as at fetch time, run with the key you kept. Node.js 18 or later, with no dependencies. It uses `checkSignature` from `verify-verdict.mjs`. Save this as `verify-stored.mjs` in the same directory: ```js // verify-stored.mjs: check a stored Octet verdict token. Node.js 18 or later, no dependencies. import { checkSignature } from './verify-verdict.mjs'; const LEEWAY_S = 30; // allowed clock difference between your servers and Octet's, in seconds /** The kid in a token's header. */ export const kidOf = (token) => JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8')).kid; /** * Checks a token from your records with the key you kept for it. * Returns the claims, or throws. */ export function verifyStoredToken(token, { jwk, sessionRef, decidedAt }) { // Checks typ, the key id, that alg matches the key type, and the signature. const claims = checkSignature(token, jwk); if (claims.iss !== 'octet-browser') throw new Error('wrong issuer'); if (claims.sessionRef !== sessionRef) throw new Error('sessionRef does not match the record'); const t = Math.floor(new Date(decidedAt).getTime() / 1000); if (Number.isNaN(t)) throw new Error('decidedAt is not a date'); if (t < claims.iat - LEEWAY_S || t > claims.exp + LEEWAY_S) { throw new Error('decision time is outside the token lifetime'); } return claims; } ``` Python 3.9 or later, with PyJWT. It uses `check_header` from `verify_verdict.py`. Save this as `verify_stored.py` in the same directory: ```python # verify_stored.py: check a stored Octet verdict token. Python 3.9 or later. # pip install "pyjwt[crypto]" from datetime import datetime import jwt from verify_verdict import check_header LEEWAY_S = 30 # allowed clock difference between your servers and Octet's, in seconds def kid_of(token: str) -> str: """The kid in a token's header.""" return jwt.get_unverified_header(token)["kid"] def verify_stored_token(token: str, jwk: dict, session_ref: str, decided_at: datetime) -> dict: """Checks a token from your records with the key you kept for it. Returns the claims, or raises.""" key = jwt.PyJWK(jwk) check_header(token, key) # typ, the key id, and that alg matches the key type claims = jwt.decode( token, key.key, algorithms=[key.algorithm_name], issuer="octet-browser", options={"verify_exp": False, "require": ["iss", "iat", "exp", "sessionRef"]}, ) if claims["sessionRef"] != session_ref: raise ValueError("sessionRef does not match the record") t = decided_at.timestamp() if t < claims["iat"] - LEEWAY_S or t > claims["exp"] + LEEWAY_S: raise ValueError("decision time is outside the token lifetime") return claims ``` Pass `decided_at` as a timezone-aware `datetime`, for example `datetime.fromisoformat(record.decided_at)` for a value stored with its UTC offset. Run it for one record: ```js import { kidOf, verifyStoredToken } from './verify-stored.mjs'; const { jwk } = await octetKeys.get(kidOf(record.token)); const claims = verifyStoredToken(record.token, { jwk, sessionRef: record.sessionRef, decidedAt: record.decidedAt, }); // claims.country, claims.confidence, claims.alarm, claims.iat ``` If the claims have `clientNetMatch`, it records whether the collection came from the network your backend saw. `false` alone isn't a failure. See [Check the user's network](/integration/verify-token#check-the-users-network). ## 4. Test the check This test uses the files from [Test the examples](/integration/verify-token#test-the-examples) on the Verify the Signed Token page. 1. Put `verify-stored.mjs` or `verify_stored.py` in the same directory as `make-test-token.mjs` and the verifier from that page, and create a token: ```bash node make-test-token.mjs ref-1 > good.txt ``` 2. Save one of these checks next to it and run it. Each builds four failing cases from the valid token: an edited payload, a different key, a different `sessionRef`, and a decision recorded an hour after the token was signed. ```js // check-stored.mjs: run with node check-stored.mjs import { generateKeyPairSync } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { verifyStoredToken } from './verify-stored.mjs'; const token = readFileSync('good.txt', 'utf8').trim(); const [jwk] = JSON.parse(readFileSync('.well-known/browser-verdict-jwks.json', 'utf8')).keys; const [h, p, s] = token.split('.'); const claims = JSON.parse(Buffer.from(p, 'base64url').toString()); const tampered = `${h}.${Buffer.from(JSON.stringify({ ...claims, alarm: 'high' })).toString('base64url')}.${s}`; const otherKey = { ...jwk, x: generateKeyPairSync('ed25519').publicKey.export({ format: 'jwk' }).x }; const at = (seconds) => new Date((claims.iat + seconds) * 1000).toISOString(); const cases = { good: [token, jwk, 'ref-1', at(60)], tampered: [tampered, jwk, 'ref-1', at(60)], 'other key': [token, otherKey, 'ref-1', at(60)], 'other session': [token, jwk, 'ref-2', at(60)], 'late decision': [token, jwk, 'ref-1', at(3600)], }; for (const [name, [t, key, sessionRef, decidedAt]] of Object.entries(cases)) { try { verifyStoredToken(t, { jwk: key, sessionRef, decidedAt }); console.log(name, 'accepted'); } catch (e) { console.log(name, 'rejected:', e.message); } } ``` ```python # check_stored.py: run with python3 check_stored.py import base64 import json from datetime import datetime, timezone from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from verify_stored import verify_stored_token def b64(data: bytes) -> str: return base64.urlsafe_b64encode(data).decode().rstrip("=") token = open("good.txt").read().strip() jwk = json.load(open(".well-known/browser-verdict-jwks.json"))["keys"][0] h, p, s = token.split(".") claims = json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4))) tampered = ".".join([h, b64(json.dumps({**claims, "alarm": "high"}).encode()), s]) other_x = Ed25519PrivateKey.generate().public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) other_key = {**jwk, "x": b64(other_x)} def at(seconds: int) -> datetime: return datetime.fromtimestamp(claims["iat"] + seconds, tz=timezone.utc) cases = { "good": (token, jwk, "ref-1", at(60)), "tampered": (tampered, jwk, "ref-1", at(60)), "other key": (token, other_key, "ref-1", at(60)), "other session": (token, jwk, "ref-2", at(60)), "late decision": (token, jwk, "ref-1", at(3600)), } for name, (t, key, session_ref, decided_at) in cases.items(): try: verify_stored_token(t, key, session_ref, decided_at) print(name, "accepted") except Exception as e: print(name, "rejected:", e) ``` The output shows `good accepted`, and the other four cases each rejected. ## For your auditor Give your auditor: - The records under review, each with its `token`. - The Octet keys you kept. - `verify-stored.mjs` or `verify_stored.py`, or a link to this page so they can write their own check from the rules in step 3. The auditor needs no Octet account, and no access to your systems beyond these files. ### Confirm each key A token that verifies with a key taken only from your records does not show that Octet signed it, because whoever could replace a token could also replace the key stored with it. The auditor confirms each key against Octet before relying on it: 1. Fetch `https://geo.octetproof.com/.well-known/browser-verdict-jwks-archive.json` directly, find the entry with the stored key's `kid`, and compare its key values with the stored key. 2. Check that the token's `iat` falls between the entry's `octet_not_before` and `octet_not_after`, allowing 60 seconds either side. The current key has no `octet_not_after`. 3. If the entry has `"octet_status": "revoked"`, distrust any token whose `iat` is after its `octet_revoked_at`. The archive is served by the same Octet server that serves the key set. To confirm a key without relying on that server, the auditor checks it against the manifest signed by Octet's root keys, using the root keys pinned in [Check a key against Octet's root keys](/integration/verify-token#check-a-key-against-octets-root-keys). The manifest's `verdictKeys` carries the same dates as the archive, so the checks in steps 2 and 3 can use the manifest's entry instead. ### Compare with your application For a sample of records, the auditor compares the user and action in each record with your application's own logs. This comparison links each verified verdict to a user and a decision in your application. The token doesn't carry that link. _Source: https://octetproof.com/docs/browser/integration/keep-verdicts/_ --- # Go-Live Checklist Work through the checklist, then run the four tests. The examples use `octet.example.com` for your edge and `www.example.com` for your site. ## Checklist **Edge** - [ ] The edge runs on its own hostname, with a DNS record pointing straight at the host or at a passthrough layer 4 load balancer. See [Deploy the Edge](/integration/deploy-edge). - [ ] No proxy, application load balancer or CDN terminates TCP or TLS in front of the edge. - [ ] `https://octet.example.com/health` returns `{"ok":true,"role":"octet-edge"}`. - [ ] The license check below returns `unsealed_bundle`. - [ ] The edge binary matches its checksum in the release's `SHA256SUMS`, and your deployment records note the version and checksum you installed. - [ ] `EDGE_CLIENT_CERT_FILE` and `EDGE_CLIENT_KEY_FILE` point at the certificate issued for this host in the portal's **Edge configuration** tab, under **Certificates**, and its key, and the key is readable only by root and the `octet-edge` group. - [ ] `EDGE_SIGNING_KEY` is set, the edge's startup log shows `S2 signing on`, and that line's `kid` and public key are registered under **Signing keys** in the portal's **Edge configuration** tab. - [ ] `ALLOWED_ORIGIN` lists exactly the origins that load the collector. - [ ] `EDGE_DEBUG`, `EDGE_EXPOSE_VERDICT` and `EXIT_IP` are unset. See [Edge Configuration](/reference/edge-config). - [ ] Certificate renewal restarts the edge. Check with `sudo certbot renew --dry-run --run-deploy-hooks`. **Page** - [ ] You serve `octet-collector.js` from your own origin with its `integrity` hash, or install the package tarball, from the same release as your edge. - [ ] Your Content Security Policy allows the hosts for your mode and `worker-src blob:`. See [Network and CSP](/reference/network). - [ ] If your `script-src` uses a nonce, you pass it as `cspNonce`. If you use Trusted Types with a `trusted-types` list, it includes `octet-collector`. See [The collector's check frame](/reference/network#the-collectors-check-frame). - [ ] Each page view gets a new random `sessionRef`, created and stored on your backend, and a new challenge for it from `POST /v1/challenge`. - [ ] `start()` runs at page load with the `sessionRef` and the challenge, and `ready()` runs at the moment of action. - [ ] Any code path that collects again, for example a retry, gets a new challenge and calls `start()` with it. **Backend** - [ ] The read token comes from your secret store, never from code. - [ ] Your HTTP client's timeout is longer than `waitMs`. - [ ] Your policy says what happens when the fetch returns `404`. - [ ] Your policy acts on `alarm` of `medium` or above, as well as on `country`. See [Verdicts](/concepts/verdicts). - [ ] Your token verifier accepts `typ` `;v=1`, `;v=2` and `;v=3`, and both `EdDSA` and `ES256`, with the algorithm taken from the key. Tokens are `ES256` from 19 October 2026, 15:00 UK time. See [Verify the Signed Token](/integration/verify-token). - [ ] Recommended: your verifier pins Octet's two root keys, and accepts a new `kid` only when the root-signed manifest lists it. See [Check a key against Octet's root keys](/integration/verify-token#check-a-key-against-octets-root-keys). - [ ] If you store verdicts, you verify and store the `token`, and keep a copy of each Octet key you verify with. See [Keep Verdicts for Audit](/integration/keep-verdicts). **Operations** - [ ] Your calendar has reminders before the read token expires, before the edge's client certificate expires, and before the license token's 365 days are up. - [ ] Your privacy notice covers the data in [Privacy and Data](/concepts/privacy). ## License check This request proves that your edge can reach Octet, and that Octet accepts your license token, your edge's client certificate and its signature. It sends an empty body: ```bash curl -s -X POST https://octet.example.com/v1/signals -H 'content-type: application/json' -d '{}' ``` When all three pass, the response is `400`, because an empty body isn't a sealed collection: ```json {"error":"octet_rejected","reason":"unsealed_bundle","status":400} ``` A `401` or `403` with a different `reason` means the license, the client certificate or the signature was refused. See [Errors](/reference/errors). ## Test 1: an honest session 1. On a normal connection with no VPN, open a page that runs the collector. 2. Trigger the action that calls `ready()`. 3. On your backend, fetch the verdict for that `sessionRef`. **Expect:** `200`, `country` set to the country you are in, and `alarm` of `none`. A `low` alarm is also normal, for example when your device is set up for another country. Verify the `token` with the code from [Verify the Signed Token](/integration/verify-token) and check that it passes. ## Test 2: a session through a VPN 1. Connect to a commercial VPN that exits in a different country from the one you are in. 2. Repeat test 1 in the default `full` mode. **Expect:** `alarm` of `medium` or `high`. Octet does not flag every masked session. If a VPN session keeps coming back as `none`, check that nothing terminates TCP or TLS in front of your edge, and that the page is running in `full` mode. ## Test 3: an expired or revoked token 1. In the portal's **Verdict API** tab, under **Read tokens**, create a 30-day read token, then revoke it. 2. Wait a few minutes, then fetch a verdict with it. **Expect:** `401` with `{"error":"revoked"}`. An expired read token gets `{"error":"expired"}`, and your backend should handle both the same way: alert, and switch to a live token. To test an expired verdict token, run the `expired` case from [Test the examples](/integration/verify-token#test-the-examples). Your verifier must reject it. ## Test 4: a rejected license Run this on a test edge, never on your production edge. 1. Set `LICENSE=octet_live_invalid` in the edge's environment file and restart the edge. 2. Run the license check above. 3. Load a page that runs the collector against this edge. **Expect:** the license check returns `401` with `"reason":"malformed_token"`. In the browser, `ready()` rejects with `signal report failed: 401`. Your backend's fetch for that `sessionRef` returns `404`, because no verdict was produced. Restore the real license token and restart the edge when you are done. _Source: https://octetproof.com/docs/browser/integration/go-live-checklist/_ --- # How It Works This page follows one session from page load to your policy decision. A collection is one run of the collector: what it gathers in the browser and posts to your edge. Each page view has one `sessionRef`, and each collection needs its own challenge. ## One session, in order ```mermaid sequenceDiagram participant B as Browser (collector) participant E as Your edge participant O as Octet API participant S as Your backend S->>O: POST /v1/challenge { sessionRef } + read token O-->>S: { challenge, exp } S->>B: page with the sessionRef and challenge B->>B: octet.start({ apiUrl, sessionRef, challenge }) B->>E: WebSocket /v1/ws B->>E: POST /v1/signals E->>O: POST /v1/signals + license token (mutual TLS) O-->>E: accepted E-->>B: { "ok": true } B->>S: user acts (for example, submits a withdrawal) S->>O: GET /v1/verdict/:sessionRef + read token O-->>S: { country, confidence, alarm, token } S->>S: apply your policy ``` 1. Your backend creates a new, unguessable `sessionRef` for the page view, gets a one-time challenge for it from Octet with your read token, and renders both into the page. 2. The page calls `octet.start()` as soon as it loads. The collector reads the browser's configuration, measures the network, and opens a WebSocket to your edge. In `passive` mode it does neither. 3. The collector encrypts what it collected to Octet, and posts it to your edge at `/v1/signals`. 4. The edge adds what it sees on the connection itself, including the user's IP address, signs the result with its signing key, and forwards everything to Octet over mutual TLS, with your license token. 5. Octet refuses a copy of a collection it accepted recently, and a collection without a valid, unused challenge for its `sessionRef` and your license. It computes the verdict and holds it for 2 minutes, keyed by `sessionRef`. The edge answers the browser with `{ "ok": true }` and nothing else. 6. When the user acts, the page awaits `octet.ready()`, which resolves once step 5 has happened, and then tells your backend. 7. Your backend fetches the verdict from Octet with your read token and applies your policy. ## The session reference The session reference, `sessionRef`, links each verdict to the page view that produced it. Your backend creates it, and it travels this way: 1. Your backend creates a `sessionRef` for the page view and stores it in the user's server-side session. 2. Your backend requests a challenge for that `sessionRef`. The challenge is bound to the `sessionRef` and your license, works for one collection, and expires after 10 minutes. 3. The page passes the `sessionRef` and the challenge to `octet.start()`, and the collector sends both with the collection. 4. Octet stores the verdict under that `sessionRef` for 2 minutes. 5. Your backend fetches the verdict with the `sessionRef` from the user's session. 6. The signed token carries the same `sessionRef`, so you can check that a token belongs to this session. Four rules keep `sessionRef` safe: - **Make it unguessable.** Use at least 128 random bits, encoded URL-safe, because it goes in the fetch URL. Octet keeps the first verdict it receives for a `sessionRef`, so someone who could guess a user's `sessionRef` could send their own collection under it first. - **Create a new one for each page view.** A second collection under the same `sessionRef` within 2 minutes doesn't replace the first verdict. - **Fetch with the value from your server-side session.** Don't use a `sessionRef` that the browser sends back to you. - **Keep it in your records with the verdict.** Octet support needs the `sessionRef` and the time to look into a session. [Embed the Collector](/integration/embed-collector#2-create-a-sessionref-and-get-a-challenge) shows how to generate one and get its challenge. ## Why the edge runs on your infrastructure The collector loads from your own site and sends what it collects only to your edge. No Octet script loads from an Octet domain, and the collector never sends data to the Octet API directly. In `full` and `lite` mode the browser also contacts three Octet hosts. See [Network and CSP](/reference/network). The edge has to be the machine that accepts the browser's TCP connection and terminates its TLS. It needs the browser's own connection, including the user's IP address. A proxy, load balancer or CDN that terminates TCP or TLS in front of the edge replaces that connection with its own, and verdicts become wrong. See [Deploy the Edge](/integration/deploy-edge). ## What stays on Octet's servers The collector and the edge only collect and forward data. The logic that turns that data into a verdict runs only on Octet's servers, and the verdict is the only result that leaves them. ## Why the browser never gets the verdict Anything that reaches the browser can be read and changed by the user. So the edge returns only `{ "ok": true }` to the browser, and your backend fetches the verdict from Octet directly. Each verdict also carries a signed token, which lets you check later that a stored verdict came from Octet unchanged. See [Verify the Signed Token](/integration/verify-token), and [Integrity and Audit](/concepts/integrity) for how each step of a session is protected. ## What Octet keeps Octet holds each session's full data in memory for 2 minutes so that your backend can fetch the verdict, and then discards it. What it keeps is a [device record](#device-record) for each browser on your license. See also [Privacy and Data](/concepts/privacy). ### Device record To recognise a returning browser, Understone Ltd, Octet's UK subsidiary, keeps a device record for each browser on your license, under the browser's `deviceKey`. It adds a session to the record only when the collection was signed by the browser's device key. The record is there so that a browser's past sessions on your license can be used to avoid false alarms in your verdicts. Device records start for your license from 1 December 2026, once your license has accepted v12.01.2026 of the Octet Browser terms. Until then, `GET` and `DELETE /v1/device/{deviceKey}` answer `404` with `device_records_not_configured`. For each session, the record holds: - the time of the session - the verdict's `country`, `confidence` and `alarm` - the country of the user's IP address - which of Octet's server-side checks the session passed - a keyed tag of the user's network, which can't be turned back into an address without Octet's secret For the browser, the record holds: - when the browser was first and last seen - up to 20 hashes each of how the device draws graphics and produces audio, stored in a form keyed separately for each license - the highest processor count it reported - whether a known privacy protection was recognised in the browser The record holds no IP address, no user agent and no other collected data. It keeps the most recent 200 sessions. A record is held only for your license. The `deviceKey`, the network tag and the stored hashes are derived separately for each license, and records are never combined or linked across licenses or partners. Records are used only for your license, and never for marketing, analytics or advertising, or to improve Octet's products or train its models. You are the controller of the device records, and Understone Ltd is your processor under [Annex A (Data Processing)](https://octetproof.com/terms/browser/#sec-annex-a-data-processing) of the Octet Browser terms. It keeps records in the United Kingdom, in Google Cloud's `europe-west2` (London) region. Google Cloud is the only sub-processor for device records. Understone Ltd deletes a record 12 months after the browser was last seen, each session in it 12 months after the session, and each stored hash 12 months after it was last seen. A deletion job runs every day. When your license ends, it deletes all of the license's records within 30 days. When a browser's device key changes, for example after the user clears your site's data, the browser gets a new `deviceKey` and a new record, and the old record is deleted 12 months after it was last seen. ### Get or delete a device record You handle your users' requests about their device records, for example to see or erase their data. Octet helps through two calls, each with your read token and the browser's `deviceKey`, and doesn't handle the requests for you. If Octet receives such a request directly, it refers the user to you, where it can tell that you are the controller. To get one browser's record: ```bash curl "https://geo.octetproof.com/v1/device/$DEVICE_KEY" \ -H "Authorization: Bearer $OCTET_READ_TOKEN" ``` To delete it: ```bash curl -X DELETE "https://geo.octetproof.com/v1/device/$DEVICE_KEY" \ -H "Authorization: Bearer $OCTET_READ_TOKEN" ``` A read token reaches only its own license's records. A deletion removes the record and every session and hash in it before Octet answers. Octet logs each deletion with the license, the `deviceKey`, the time and whether a record was deleted, never the record's contents, and keeps the log for 90 days. The responses are in [Errors](/reference/errors#from-get-v1devicedevicekey). Keep the `deviceKey` with each verdict you store, so that you can find a user's record when they ask. See [Device key](/reference/verdict#device-key). _Source: https://octetproof.com/docs/browser/concepts/how-it-works/_ --- # Verdicts A verdict covers one session: the country the session is operating from, how certain Octet is of that country, and how strongly the session's signals contradict it or suggest the connection is masked. The exact field list is in [Verdict Reference](/reference/verdict). ## `country` `country` is an ISO 3166-1 alpha-2 code, for example `"GB"`. Territories come back as their own codes, not their parent country's. Puerto Rico is `"PR"`, not `"US"`, and Guam is `"GU"`. If your policy covers a country and its territories, list every code. `country` is absent when Octet could not determine one. Treat an absent `country` as unknown, not as a pass. ## `confidence` `confidence` is a number from `0` to `1`. It measures how certain Octet is of `country`. To judge whether the connection is masked, use `alarm`, not `confidence`. ## `alarm` `alarm` has four levels: | Level | Meaning | |---|---| | `none` | Nothing contradicts `country`. | | `low` | A minor inconsistency with no sign of masking, such as a traveller or a device set up for another country. Use `country` as returned. | | `medium` | The connection appears masked, for example by a VPN or proxy. Don't trust `country`. | | `high` | The connection is masked, or Octet determines the device is being used in a country under comprehensive sanctions, masked or not. `country` is still the country the user is in. Octet does not reliably detect sanctioned regions inside a country, such as Crimea. See [Sanctioned regions](#sanctioned-regions). | At `high`, `country` is the country the user is in, as Octet determines it. It can differ from the country of their IP address. Sessions through iCloud Private Relay keep the user's own country, and aren't treated as masked. ## Sanctioned regions Some sanctions cover a region inside a country rather than the whole country. Examples are Crimea and the so-called Donetsk and Luhansk People's Republics, all in Ukraine. `country` is a country-level result, so a session in one of these regions comes back as `"UA"`. The verdict does not reliably tell you whether a session is inside such a region. Placing a session on one side of a boundary inside a country is less certain than placing it on one side of an international border. If your obligations cover a sanctioned region, don't rely on the verdict alone. ## Choosing a policy You decide what each verdict means for your service. A common starting point for a regulated action: ```js const RESTRICTED = new Set(['US', 'PR', 'GU', 'VI', 'AS', 'MP', 'UM']); function needsKyc(verdict) { const masked = verdict.alarm === 'medium' || verdict.alarm === 'high'; const restricted = verdict.country === undefined || RESTRICTED.has(verdict.country); return masked || restricted; } ``` `needsKyc` returns `true` when `country` is on your list, when there is no `country`, or when `alarm` is `medium` or above. Adjust the list and the action to your own obligations. Octet makes no decision for you. A few rules hold for any policy: - Read the verdict on your backend. Never act on anything the browser reports. - Treat `medium` and `high` as masked connections, except that `high` also marks a session in a country under comprehensive sanctions. At `medium`, don't rely on `country`. At `high`, `country` still tells you where the user is. - Don't block on `low` alone. It is how honest travellers usually appear. _Source: https://octetproof.com/docs/browser/concepts/verdicts/_ --- # Integrity and Audit :::note[In short] Each step from the user's browser to your records is protected by the party that handles it, and Octet signs every verdict. An auditor checks a stored verdict against Octet's public key, so the check rests on the signature and not on the system that stored the verdict. ::: ## Why a signed verdict A compliance review asks what you knew about a session when you acted on it. A verdict stored as plain JSON is only as trustworthy as the database that holds it: anyone with write access could change `alarm` from `high` to `none`, and nothing in the record would show it. Octet signs each verdict with a private key that only Octet holds, and publishes the matching public key. The signature covers `country`, `confidence`, `alarm` and the details of the session. Changing any of them makes the signature fail. An auditor who checks the signature doesn't need to trust your database to know that a stored verdict is unchanged. ## The path of one session ```mermaid flowchart LR A[Browser
collector] -->|encrypted to Octet| B[Your edge] B -->|mutual TLS| C[Octet API
signs the verdict] C -->|signed token| D[Your backend
stores token and key] D --> E[Your auditor
checks the signature] ``` | Step | What protects it | Who can check it | |---|---|---| | The collector and edge files you run | Each release's `SHA256SUMS` is signed with cosign, and the collector has a Subresource Integrity hash. | You and your auditor, against the public release. | | Browser to Octet, through your edge | The collector encrypts each collection to Octet. Your edge forwards it without being able to read it. | Octet. | | Copies of a collection | Each collection carries a key kept in the browser. Octet refuses a copy of a collection it accepted recently, through any edge. | Octet. | | Which session a collection answers | Each collection carries a one-time challenge your backend got from Octet for its `sessionRef`. Octet accepts the challenge once, within 10 minutes, and only for that `sessionRef` and your license. | Octet, and you, from the `chain` in the token. | | Your edge to Octet | Mutual TLS with a client certificate Octet issued for your license, plus your license token. | Octet. | | What your edge adds about the connection | Your edge signs each session it forwards, covering the collection and what it adds, with a signing key you register for your license. | Octet. | | The verdict | Octet signs the verdict and the session details into the token. | You, your auditor, and anyone else with Octet's public key. | | Your decision record | Your own storage. | You. | Octet accepts a collection only on a connection that presents a client certificate issued for the same license as the license token. ### What the browser reports The collector encrypts each collection to Octet before it leaves the browser. Your edge forwards it without being able to read it, and Octet can tell if it was changed on the way. Each collection also carries a key kept in the browser, which lets Octet refuse a copy of a collection it has already accepted. None of this shows that what the browser reported is true, because the user controls the browser. Scripts on your own page can also read a collection before it is encrypted, because the collector runs in the page. ## What a stored token shows With a stored token and Octet's public key, an auditor can confirm that: - **Octet issued the verdict.** The signature verifies with Octet's key for the `kid` in the token header. - **The verdict is unchanged.** `country`, `confidence` and `alarm` are exactly what Octet signed. - **It belongs to one session.** `sessionRef` is the session reference you created. - **It was issued at a known time.** `iat` is when Octet signed it, and your decision falls between `iat` and `exp`. - **Whether the user's network matched.** When your backend sent `clientIp` with the challenge request, `clientNetMatch` records whether the collection came from the same network. The token carries no network or address. - **Which challenge the verdict answers.** `chain.s0` is a hash of the challenge token your backend received, so you can match the verdict to it. See [The chain](/integration/verify-token#the-chain). - **It came from a known Octet release.** `modelVersion` names the release that produced the verdict. ## What a stored token doesn't show - **Which user or action the session belonged to.** Your records hold that link, so store the `sessionRef` with the user and the action. - **That your records are complete.** The token shows that a stored verdict is genuine. It doesn't show that you kept every verdict you fetched. - **That your edge ran an unmodified Octet release.** Keep the version you installed and its checksum from `SHA256SUMS` with your deployment records. - **Where the user was.** The verdict records what Octet concluded from the evidence it received. ## What your auditor relies on An auditor who checks your verdicts relies on two things: 1. **Octet's public key.** The auditor confirms a stored copy against Octet's key archive, or against the manifest signed by Octet's root keys. See [Keep Verdicts for Audit](/integration/keep-verdicts#confirm-each-key). 2. **Your records linking each `sessionRef` to a user and an action.** The auditor doesn't need to rely on your database, your queues, or any other system the token passed through. None of them can change a signed verdict without the signature failing. ## Where to go next - [Verify the Signed Token](/integration/verify-token): check each token when your backend fetches it. - [Keep Verdicts for Audit](/integration/keep-verdicts): store records an auditor can check, and check them later. - [How It Works](/concepts/how-it-works): one session, from page load to your policy. _Source: https://octetproof.com/docs/browser/concepts/integrity/_ --- # Privacy and Data This page lists the categories of data Octet Browser processes, where each is collected, and how long it is held. Use it for your privacy notice, your records of processing, and your data processing agreement with Octet. ## Data collected in the browser The collector reads these categories from the user's browser and sends them to your edge: | Category | Examples | |---|---| | Device and browser configuration | Time zone, language and regional format settings, screen properties, hardware properties such as processor count | | Device rendering characteristics | Hashes of how the device draws graphics and produces audio, including a small canvas check that shows whether a privacy protection is active. The collector draws the same test image on the page and in a Web Worker. The check doesn't identify a person. | | Browser environment | Permission states the browser reports without prompting, signs of automation tools, and signs of browser extensions | | Network information | Network addresses seen by the browser, including IP addresses | | Connection timing | How long network requests take, in `full` and `lite` mode | | A device key | The public half of a key pair the collector keeps in the browser. Octet uses it to check the collection and to refuse copies of it. Octet derives the verdict's `deviceKey` from it with a secret, separately for each license. The device key itself is not returned to you. See [Device key](/reference/verdict#device-key). | The collector never asks for permission to use location, camera, microphone or notifications. It reads no cookies, no local storage and no page content. In `passive` mode the collector makes no network measurements and collects no network addresses. The other categories are still collected. ## Data stored in the browser The collector stores one item in the browser: the device key pair, in the IndexedDB database `octet` on your origin (store `keys`, entry `device-v1`). The private key is non-extractable, so page scripts can use it but can't read it out. It stays until the browser or the user clears your site's data, and some browsers clear script-written storage sooner. If the browser blocks storage, the collector keeps a key in memory for that page load and stores nothing. ## Data added by your edge Your edge adds data about the connection itself: | Category | Examples | |---|---| | IP address | The user's public IP address, taken from the TCP connection | | Request headers | User agent, accepted languages, and browser client hints | | Connection properties | Timing and other properties of the browser's TCP and TLS connections to the edge. From v1.7.0, when your edge serves HTTPS itself, also two numbers about how steady the browser's connection was during the check, with no personal data | | Connection fingerprints | When your edge serves HTTPS itself: a fingerprint of the browser's TLS connection (JA4, the encryption settings the browser offers), and from v1.6.0 a fingerprint of how the browser opens its TCP and HTTP/2 connections. They describe the browser and its network software, contain no personal data, and don't identify a person. | ## Where the data goes 1. The collector encrypts the browser's data to Octet and sends it to your edge over HTTPS. Your edge can't read it. Scripts on your own page can read it before it is encrypted, because the collector runs in the page. 2. Your edge adds its own data about the connection, and forwards everything to the Octet API over mutual TLS. 3. Octet computes a verdict and returns only the verdict to your backend. If your backend sends the user's IP address as `clientIp` when it requests a challenge, Octet puts only a keyed tag of the address's network into the challenge, and stores nothing. The verdict token carries only whether the networks matched. In `full` and `lite` mode the browser also sends requests to three Octet network hosts, which see the user's IP address. See [Network and CSP](/reference/network). ## How long data is held | Where | What | How long | |---|---|---| | The user's browser | The device key pair | Until the browser or the user clears your site's data | | Your edge | Network timing for a session | In memory, up to 60 seconds, deleted when forwarded | | Octet API | The session's data and its verdict | In memory, up to 2 minutes | | Octet API | A hash of each accepted collection, to refuse copies of it | In memory, up to 1 hour | | Octet API | The location and network type found for the user's network, with no IP address | In memory, up to 1 hour | | Octet network hosts | A keyed tag of each network that contacted them, with no IP address | In memory, up to 1 minute | | Your backend | The verdict you fetched | Your choice | Octet keeps no per-user record and builds no profile across sessions. By default your edge logs no end-user data. Its diagnostic setting, `EDGE_DEBUG`, logs end-user IP addresses, so keep it off in production. See [Edge Configuration](/reference/edge-config). ## Roles You decide to use Octet and what to do with each verdict. Describe Octet Browser in your privacy notice as a fraud and compliance check that determines the country a session operates from. The [Octet Browser terms](https://octetproof.com/terms/browser/) set out Octet's obligations. For questions about data processing, write to [privacy@octetproof.com](mailto:privacy@octetproof.com). _Source: https://octetproof.com/docs/browser/concepts/privacy/_ --- # Collector API The collector exports three functions. With npm, import them from `@octetproof/collector`. With the script tag, they are properties of the global `octet`. ```js import { start, ready, verify } from '@octetproof/collector'; ``` ## `start(config)` ```ts start(config: CollectorConfig): void ``` Begins a collection in the background and returns at once. Call it at page load. Calling `start()` again replaces the configuration, discards any stored result, and begins a new collection. Throws at once if `config.challenge` is missing, if `config.apiUrl` is not a valid `https://` URL, or if `config.mode` is not `'full'`, `'lite'` or `'passive'`. See [`CollectorConfig`](#collectorconfig). ## `ready()` ```ts ready(): Promise ``` Resolves once your edge has passed the latest collection to Octet, which means your backend can fetch the verdict. Call it at the moment of action. - If the collection that `start()` began is still running, `ready()` waits for it. - If that collection finished less than 90 seconds ago, `ready()` resolves at once with its result. - Otherwise, `ready()` needs a new collection. The challenge from `start()` has already been sent, so `ready()` rejects with `octet: this challenge was already used`. Get a new challenge from your backend and call `start()` with it. Rejects if `start()` was never called, if the POST to your edge fails, or if the collection was aborted. A failed collection is not stored. If the collection finished, its challenge counts as used, even if the POST never started, so call `start()` with a new challenge before the next `ready()`. ## `verify(config)` ```ts verify(config: CollectorConfig): Promise ``` Runs one collection and resolves with your edge's reply. It stores nothing, so each call collects again and needs a new challenge. Use it where there is no separate moment of action. It rejects at once for a missing `challenge` or an invalid `apiUrl` or `mode`, and later if the challenge was already used, if the POST to your edge fails, or if the collection is aborted. ## `CollectorConfig` | Field | Type | Required | Description | |---|---|---|---| | `apiUrl` | `string` | Yes | Your edge's origin, for example `https://octet.example.com`. Must be `https://`. `http://` is accepted only for `localhost`, `127.0.0.1` and `[::1]`. | | `sessionRef` | `string` | Yes | The unguessable reference your backend created for this page view. Your backend fetches the verdict with it. | | `challenge` | `string` | Yes | The one-time challenge your backend got from Octet for this `sessionRef` with `POST /v1/challenge`. Each challenge works for one collection, within 10 minutes. See [Get a challenge](/integration/embed-collector#get-a-challenge). | | `mode` | `'full' \| 'lite' \| 'passive'` | No | How much network measurement to do. The default is `'full'`. See [Modes](#modes). | | `wsUrl` | `string` | No | The WebSocket URL for your edge. The default is `apiUrl` with `https` replaced by `wss`, plus `/v1/ws`. Set it only if your edge's WebSocket has a different address. | | `signal` | `AbortSignal` | No | Cancels the collection. See [Cancelling](#cancelling). | | `cspNonce` | `string` | No | Your page's CSP nonce, if your `script-src` uses one. Base64 or base64url characters, at most 256. Without it, or if the value isn't valid, the collector uses the nonce of a script already on the page. Since v1.8.0. See [Network and CSP](/reference/network#content-security-policy). | ## Modes | Mode | What it does | Network requests beyond your edge | |---|---|---| | `full` | Everything, for the strongest results | HTTPS and UDP 3478 to three Octet network hosts | | `lite` | Skips the HTTPS requests to the Octet network hosts | UDP 3478 to three Octet network hosts | | `passive` | No network measurement and no WebRTC | None | No mode shows a permission prompt. The hosts are listed in [Network and CSP](/reference/network). ## `ReadyResult` | Field | Type | Description | |---|---|---| | `response` | `EdgeReply` | Your edge's reply to the POST. | | `issuedAtMs` | `number` | When the edge replied, in milliseconds since the Unix epoch. | | `expiresAtMs` | `number` | When `ready()` stops reusing this result, in milliseconds since the Unix epoch. `issuedAtMs` plus 90 seconds. | | `elapsedMs` | `number` | Milliseconds from the start of collection to the edge's reply. | ## `EdgeReply` In production your edge always replies with: ```json { "ok": true } ``` The reply never contains the verdict. Your backend fetches it. See [Fetch the Verdict](/integration/fetch-verdict). Only the testing setting `EDGE_EXPOSE_VERDICT` changes this reply. See [Edge Configuration](/reference/edge-config#testing-only). ## Cancelling Pass an `AbortSignal` in the configuration. Aborting stops the collection and cancels the POST to your edge if it has started. `ready()` or `verify()` then rejects with the signal's abort reason, which is a `DOMException` named `AbortError` unless you pass your own reason to `abort()`. If the abort comes before the POST reaches your edge, no verdict is produced for that collection. ```js const controller = new AbortController(); octet.start({ apiUrl: 'https://octet.example.com', sessionRef, challenge, signal: controller.signal }); window.addEventListener('pagehide', () => controller.abort()); ``` ## Errors | Thrown or rejected with | When | |---|---| | ``Error: octet: `challenge` is required. …`` | `challenge` is missing or empty. Thrown by `start()`, rejected by `verify()`. | | `Error: octet: this challenge was already used. …` | The collector already sent this challenge. Rejected by `ready()` or `verify()`. Get a new challenge and call `start()` or `verify()` with it. | | `Error: octet: invalid apiUrl "…"` | `apiUrl` is not a URL. Thrown by `start()`, rejected by `verify()`. | | `Error: octet: apiUrl must be https://…` | `apiUrl` uses another scheme. Thrown by `start()`, rejected by `verify()`. | | `Error: octet: unknown mode "…"` | `mode` is not `'full'`, `'lite'` or `'passive'`. Thrown by `start()`, rejected by `verify()`. | | `Error: octet: the collector needs a secure context (https) for crypto.subtle` | The page isn't served over HTTPS or from `localhost`, `127.0.0.1` or `[::1]`. | | `Error: octet: call start() before ready()` | `ready()` was called before `start()`. | | `Error: signal report failed: ` | Your edge answered the POST with an error status. See [Errors](/reference/errors). | | `TypeError` from `fetch` | The POST to your edge failed at the network level, for example because of CORS or DNS. | | `DOMException` named `AbortError`, or your own abort reason | The collection was aborted. | A measurement that fails or is blocked never causes an error. The collector leaves it out and sends the rest. _Source: https://octetproof.com/docs/browser/reference/collector-api/_ --- # Verdict Reference This is the body of a `200` response from `GET /v1/verdict/{sessionRef}`. [Verdicts](/concepts/verdicts) explains how to read them. ```json { "country": "DE", "confidence": 0.91, "alarm": "none", "deviceKey": "dk1_bvlgyviLtYS6pbE9nt7wA11rwpiH73CPbsBOhOQOEWg", "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6Im9jdGV0LWJyb3dzZXItdmVyZGljdCtqd3Q7dj0zIi..." } ``` ## Fields | Field | Type | Always present | Description | |---|---|---|---| | `country` | string | No | ISO 3166-1 alpha-2 code, uppercase. Territories have their own codes, such as `PR` and `GU`. Absent when Octet could not determine a country. | | `confidence` | number | Yes | From `0` to `1`. How certain Octet is of `country`. | | `alarm` | string | Yes | One of `none`, `low`, `medium`, `high`. | | `deviceKey` | string | No | A key for the user's browser, scoped to your license. See [Device key](#device-key). | | `token` | string | Yes | The signed copy of the verdict, with its `sessionRef` and the time it was issued. See [Verify the Signed Token](/integration/verify-token). | Ignore fields you don't recognise, so that fields added in a later version don't break your code. ## Device key `deviceKey` lets you recognise a returning browser. It has the form `_`: a short lowercase prefix, an underscore, and 43 base64url characters, for example `dk1_…`. - The same browser gets the same `deviceKey` on your license, and a different one on every other license, so it can't link users across Octet customers. - It changes when the browser's own key changes, for example after the user clears your site's data, in a private window, or after Safari's 7-day limit on script-written storage. - It changes for every browser at once if Octet replaces the secret it is derived from. The prefix changes too, so you can tell. - Understone Ltd, Octet's UK subsidiary, keeps a device record for each `deviceKey` on your license, as your processor and only for your license. To get one, send `GET /v1/device/{deviceKey}` with your read token. To delete one, send `DELETE /v1/device/{deviceKey}`. See [Device record](/concepts/how-it-works#device-record). The signed token carries the same value from token version 2. See [Verify the Signed Token](/integration/verify-token#claims). ## Alarm levels | Level | Meaning | |---|---| | `none` | Nothing contradicts `country`. | | `low` | A minor inconsistency, for example a traveller. Use `country` as returned. | | `medium` | The connection appears masked, for example by a VPN or proxy. Don't trust `country`. | | `high` | The connection is masked, or Octet determines the device is being used in a country under comprehensive sanctions, masked or not. `country` is still the country the user is in. Octet does not reliably detect sanctioned regions inside a country, such as Crimea. See [Sanctioned regions](/concepts/verdicts#sanctioned-regions). | ## Token claims The token's claims are listed in [Verify the Signed Token](/integration/verify-token#claims). _Source: https://octetproof.com/docs/browser/reference/verdict/_ --- # Edge Configuration The edge reads all of its configuration from environment variables at startup. Restart it after any change. ## Settings | Variable | Set in production | Default | Description | |---|---|---|---| | `OCTET_URL` | Yes | A local address | The Octet API. Set it to `https://geo.octetproof.com`. If it is unset, the edge still starts, but every session fails with `502` `octet_unreachable`. | | `LICENSE` | Yes | Unset | Your license token, `octet_live_v4.public.…`. See [Credentials](/integration/credentials). | | `ALLOWED_ORIGIN` | Yes | `*` | The origins allowed to call the edge from a browser, comma-separated with no spaces, for example `https://www.example.com,https://app.example.com`. Applies to both the POST and the WebSocket. The default `*` allows any origin, so always set it in production. | | `PORT` | Yes | `8080` | The TCP port to listen on. Use `443` in production. | | `EDGE_TLS_CERT_FILE` | Yes | Unset | Path to your TLS certificate chain in PEM format, such as a Let's Encrypt `fullchain.pem`. | | `EDGE_TLS_KEY_FILE` | Yes | Unset | Path to the matching private key in PEM format, such as `privkey.pem`. | | `EDGE_DEBUG` | No | Off | `1` or `true` logs one line per request, including the end-user's IP address. Use it only for short diagnostic sessions, and never leave it on in production. | | `EDGE_CLIENT_CERT_FILE` | Yes | Unset | Mutual TLS to Octet. Path to your edge's client certificate, downloaded from your license's **Edge configuration** tab in the Octet portal, under **Certificates**. | | `EDGE_CLIENT_KEY_FILE` | Yes | Unset | Mutual TLS to Octet. Path to the client certificate's private key. | | `EDGE_SIGNING_KEY` | Yes | Unset | The edge's signing key: a base64 32-byte Ed25519 key, from `openssl rand -base64 32`. The edge signs each session it forwards with it. Set only one of `EDGE_SIGNING_KEY`, `EDGE_SIGNING_KEY_FILE` and `EDGE_SIGNING_KMS`. If none is set, the edge forwards sessions unsigned and logs a warning at startup. See [Edge signature](#edge-signature). | | `EDGE_SIGNING_KEY_FILE` | No | Unset | From v1.7.0. Path to a file holding the signing key, instead of `EDGE_SIGNING_KEY`, for example a systemd credential sealed to the host's TPM. See [Where to keep the signing key](#where-to-keep-the-signing-key). | | `EDGE_SIGNING_KMS` | No | Unset | From v1.7.0. Sign in your cloud key service instead of on the host: `aws:` for AWS KMS or `gcp:` for Google Cloud KMS. The key must be Ed25519. See [Where to keep the signing key](#where-to-keep-the-signing-key). | | `EDGE_SIGNING_KID` | No | Derived from the public key | The signing key's ID, `kid`, which you register with the public key. Up to 64 letters, digits, `.`, `_` or `-`. The default is `edge-` followed by a hash of the public key. | | `OCTET_CA_FILE` | No | Unset | Path to a CA certificate that Octet's server certificate must chain to. If it is unset, the edge checks Octet's server certificate against the host's system trust store. | The edge serves HTTPS only when both `EDGE_TLS_CERT_FILE` and `EDGE_TLS_KEY_FILE` are set. Without them it serves plain HTTP, which browsers will refuse to use from an HTTPS page. It accepts TLS 1.2 and 1.3, with modern AEAD cipher suites only. From v1.6.0 it serves HTTP/2 to browsers that offer it, and HTTP/1.1 to the rest. There is nothing to configure. It loads the certificate at startup, so restart it after each renewal. ## Mutual TLS The edge connects to Octet only over mutual TLS. It refuses to start if `EDGE_CLIENT_CERT_FILE` or `EDGE_CLIENT_KEY_FILE` is unset, or if `OCTET_URL` isn't `https://`. The one exception is an `OCTET_URL` on a loopback address, which is for local development. The log line names what is missing, for example `OCTET_URL must be https:// (mTLS to octet is required)`. To get the certificate, see [Deploy the Edge](/integration/deploy-edge#4-install-the-client-certificate). Octet's server certificate is a public certificate, so the edge verifies it with the host's system trust store. You don't need Octet's client CA certificate. Set `OCTET_CA_FILE` only if your security policy requires pinning Octet's server certificate to a specific CA. The edge loads the client certificate and key at startup, so restart it after you renew them. ## Edge signature With `EDGE_SIGNING_KEY` set, the edge signs each session it forwards to Octet. The signature covers the collection and everything the edge adds about the connection. At startup the edge logs the key's `kid` and public key: ```text [octet-edge] S2 signing on: kid=edge-… ed25519 public key=… (register it in the licence portal) ``` Register both in your license's **Edge configuration** tab in the Octet portal, under **Signing keys**. If the value isn't a base64 32-byte key, the edge exits and logs `EDGE_SIGNING_KEY must be a base64 32-byte Ed25519 seed`. Octet refuses a session whose signature fails its check. From v1.5.0, Octet also refuses an unsigned session, or one signed by a key that isn't registered for your license. The reason codes are in [Errors](/reference/errors#reasons-octet-gives-your-edge). The signing key and the client certificate are separate credentials, and the edge needs both. See [Credentials](/integration/credentials#register-an-edge-signing-key). ### Where to keep the signing key Anyone who has the signing key can sign sessions for your license until you revoke it. From v1.7.0 the edge can read it from three places. Set only one: | Setting | Where the key is | Use it for | |---|---|---| | `EDGE_SIGNING_KEY` | In the edge's environment file, in plain text | Development and tests | | `EDGE_SIGNING_KEY_FILE` | In a file, for example a systemd credential encrypted to the host's TPM, which only the edge's service can read while it runs | Production edges. This is the recommended setting | | `EDGE_SIGNING_KMS` | In AWS KMS or Google Cloud KMS. It never reaches the host | When your policy allows no private keys on hosts | Keep the client certificate's key, `EDGE_CLIENT_KEY_FILE`, under the same protection as the signing key. Octet needs both, so protecting only one protects less than it seems. `EDGE_CLIENT_KEY_FILE` and `EDGE_TLS_KEY_FILE` are paths too, so they can be systemd credentials as well. **A key sealed to the TPM.** This needs systemd 250 or later and a TPM 2.0. On cloud VMs that means a GCP Shielded VM, AWS NitroTPM or Azure Trusted Launch. The encrypted file can't be used on any other machine. As root on the edge host: ```bash mkdir -p /etc/credstore.encrypted openssl rand -base64 32 | systemd-creds encrypt --with-key=tpm2 --name=edge-s2-key - /etc/credstore.encrypted/edge-s2-key ``` Then remove the `EDGE_SIGNING_KEY=` line from the environment file, add this with `systemctl edit octet-edge`, and restart the edge: ```ini [Service] LoadCredentialEncrypted=edge-s2-key:/etc/credstore.encrypted/edge-s2-key Environment=EDGE_SIGNING_KEY_FILE=edge-s2-key ``` The startup log shows `S2 signing on (file …): kid=… ed25519 public key=…`. A new key needs registering in the portal. A TPM reset or a new VM means a new key. Don't run `systemd-creds decrypt` on the host, because it prints the key. **A key in your cloud key service.** The edge uses the instance's own identity: - **AWS KMS:** create an asymmetric key with key spec `ECC_NIST_EDWARDS25519` and usage `SIGN_VERIFY`, in the edge's region. Give the instance role `kms:Sign` and `kms:GetPublicKey` on it. Set `EDGE_SIGNING_KMS=aws:arn:aws:kms:::key/`. - **Google Cloud KMS:** create a key with purpose `ASYMMETRIC_SIGN` and algorithm `EC_SIGN_ED25519`, in the edge's region. Give the VM's service account `roles/cloudkms.signerVerifier` on it. Set `EDGE_SIGNING_KMS=gcp:projects/

/locations//keyRings//cryptoKeys//cryptoKeyVersions/`. At startup the edge fetches the public key, checks that it is Ed25519 and that it may sign with it, and logs the `kid` and public key to register. Each session waits for one call to the key service, so keep the key in the edge's region. If a call fails or takes longer than 1 second, the edge answers the browser with `503` and `edge_signing_failed`, and sends nothing to Octet. It never forwards a session unsigned. ### Prove you hold the signing key From v1.7.0 the Octet portal asks you to prove each signing key you register. In your license's **Edge configuration** tab, under **Signing keys**, select **Prove** next to the key. The portal shows a one-time challenge, valid for 30 minutes. On the edge host, with the same key settings as the running edge, run: ```bash octet-edge prove-key --challenge ``` It prints one line. Paste it into the portal. The command doesn't start the edge. It refuses a challenge for another key, or one that has expired, and says why. Keys you registered before v1.7.0 can be proven the same way. Today a key that isn't proven still works. Octet will refuse sessions signed with an unproven key from a date it announces in advance, so prove each key now. ## Testing only The edge also reads two settings meant for testing. Leave both unset in production. | Variable | Effect | |---|---| | `EDGE_EXPOSE_VERDICT` | `1` or `true` makes the edge answer the collector's POST with the verdict's `country`, `confidence` and `alarm` instead of `{"ok":true}`. The signed token is never included. | | `EXIT_IP` | Replaces the user's IP address with this value for every session. It is for local tests where the browser reaches the edge over loopback. | ## Endpoints | Method | Path | Called by | Response | |---|---|---|---| | `GET` | `/health` | You, for monitoring | `200` `{"ok":true,"role":"octet-edge"}`. Does not contact Octet. | | `POST` | `/v1/signals` | The collector | `200` `{"ok":true}` when Octet accepted the session. Errors are in [Errors](/reference/errors). | | `GET` | `/v1/ws` | The collector | A WebSocket upgrade. It stays open for at most 15 seconds. | | `OPTIONS` | `/v1/signals` | The browser, for CORS | `204` with the CORS headers. | The edge serves every path at its root, so `apiUrl` is the edge's origin with no path, for example `https://octet.example.com`. ## Ports and connections | Direction | Protocol and port | Peer | |---|---|---| | Inbound | TCP 443 | Browsers, for HTTPS and the WebSocket | | Outbound | TCP 443 | `geo.octetproof.com` | The edge must accept the browser's TCP connection directly. See [Deploy the Edge](/integration/deploy-edge#nothing-may-terminate-tls-or-tcp-in-front-of-the-edge). ## Binaries Each [release](https://github.com/octetproof/octet-browser/releases) contains static Linux binaries with no runtime dependencies: | File | Platform | |---|---| | `octet-edge-linux-amd64` | Linux on x86-64 | | `octet-edge-linux-arm64` | Linux on 64-bit ARM | Check each binary against the release's `SHA256SUMS` before you install it. See [Deploy the Edge](/integration/deploy-edge#1-download-and-verify-the-binary). The edge runs only on Linux. _Source: https://octetproof.com/docs/browser/reference/edge-config/_ --- # Network and CSP This page lists every host the collector contacts in each mode, and the Content Security Policy entries that allow them. Every host is run by you or by Octet. ## Hosts by mode | Destination | Protocol | `full` | `lite` | `passive` | |---|---|---|---|---| | Your edge, for example `octet.example.com` | HTTPS POST to `/v1/signals` | Yes | Yes | Yes | | Your edge | WebSocket (`wss://`) to `/v1/ws` | Yes | Yes | No | | `lon1.octetproof.com`, `chs1.octetproof.com`, `sin1.octetproof.com` | HTTPS | Yes | No | No | | `lon1.octetproof.com`, `chs1.octetproof.com`, `sin1.octetproof.com` | UDP 3478 (WebRTC) | Yes | Yes | No | In `full` and `lite` mode the collector contacts these three Octet hosts. They receive no cookies and no credentials. ## Content Security Policy The collector needs `connect-src` entries for your mode, and a `worker-src` entry in every mode. Add them to your existing policy. **`full`**, the default: ```text connect-src 'self' https://octet.example.com wss://octet.example.com https://lon1.octetproof.com https://chs1.octetproof.com https://sin1.octetproof.com; ``` **`lite`**: ```text connect-src 'self' https://octet.example.com wss://octet.example.com; ``` **`passive`**: ```text connect-src 'self' https://octet.example.com; ``` Replace `octet.example.com` with your edge's hostname. List `wss://` for your edge explicitly, even if the edge shares your page's origin, because some browsers don't treat `'self'` as covering WebSockets. In every mode the collector also starts a Web Worker from a `blob:` URL. Allow it for the best results: ```text worker-src blob:; ``` If the Worker is blocked, the collector carries on without it and never throws an error because of it. If your policy has no `worker-src` directive, browsers apply to workers the first of `child-src`, `script-src` and `default-src` that your policy sets. Adding `worker-src` replaces that rule for workers, so also list any sources your own workers use. If you serve `octet-collector.js` from your own origin, `script-src 'self'` covers it. The collector needs no `unsafe-eval`. ### The collector's check frame From v1.8.0 the collector runs a short check in a hidden, sandboxed `srcdoc` frame with a small inline script, and removes the frame within a second. What your policy needs depends on how it allows scripts: | Your policy | What happens | What to do | |---|---|---| | No CSP, or `script-src` with `'unsafe-inline'` | The check runs | Nothing | | `script-src` with a nonce, with or without `'strict-dynamic'` | The check needs your page's nonce | Pass it as [`cspNonce`](/reference/collector-api#collectorconfig), especially if your page removes its nonced scripts after load | | `script-src` with hashes only | The check is skipped quietly. The browser may send a CSP violation report | Add a nonce, or accept that the check is skipped | | Trusted Types, with a `trusted-types` list | The policy `octet-collector` is refused once, then the check is skipped | Add `octet-collector` to `trusted-types` | | `frame-src` or `child-src` | Not affected | Nothing | Without the nonce, the collector uses the nonce of a script already on the page. In every case nothing else changes: no error, no prompt, and the verdict still arrives. ## WebRTC and firewalls CSP does not control WebRTC. In `full` and `lite` mode the browser sends UDP packets to port 3478 on the three Octet network hosts. Corporate firewalls and some networks block outbound UDP. If yours might, allow outbound UDP 3478 to `lon1.octetproof.com`, `chs1.octetproof.com` and `sin1.octetproof.com`. ## If a host is blocked The collector still works. It leaves out any measurement it could not make, sends the rest to your edge, and never throws an error because of it. The verdict is weaker, so more masked sessions come back as `none` or `low`. The one request that must succeed is the POST to your edge. If it is blocked, `ready()` and `verify()` reject, and no verdict is produced. ## Your edge | Direction | Protocol and port | Peer | |---|---|---| | Inbound | TCP 443 | Browsers | | Outbound | TCP 443 | `geo.octetproof.com` | ## Your backend | Direction | Protocol and port | Peer | |---|---|---| | Outbound | TCP 443 | `geo.octetproof.com`, for challenges, verdicts and the key sets | If your backend can't reach `geo.octetproof.com`, see [Get a challenge](/integration/embed-collector#get-a-challenge) for requesting challenges through your edge. _Source: https://octetproof.com/docs/browser/reference/network/_ --- # Errors Every error body is JSON. This page lists each status you can see, where you see it, and what to do. ## In the browser The collector reports errors by throwing or by rejecting the promise from `ready()` or `verify()`: | Error | Cause | What to do | |---|---|---| | ``octet: `challenge` is required. …`` | `start()` or `verify()` got no `challenge`. | Get a challenge on your backend and pass it in. See [Get a challenge](/integration/embed-collector#get-a-challenge). | | `octet: this challenge was already used. …` | The collector already sent this challenge. `ready()` reruns the collection 90 seconds after the last one, or after a failed POST, and a second `verify()` with the same config does the same. | Get a new challenge from your backend and call `start()` or `verify()` with it. | | `octet: invalid apiUrl "…"` | `apiUrl` is not a URL. | Set `apiUrl` to your edge's origin. | | `octet: apiUrl must be https://…` | `apiUrl` is not `https://`. | Use `https://`. `http://` works only for `localhost`, `127.0.0.1` and `[::1]`. | | `octet: the collector needs a secure context (https) for crypto.subtle` | The page isn't served over HTTPS, so the browser offers no WebCrypto, which the collector needs to encrypt the collection. | Serve the page over HTTPS. `http://` works only for `localhost`, `127.0.0.1` and `[::1]`. | | `octet: call start() before ready()` | `ready()` ran before `start()`. | Call `start()` at page load. | | `octet: unknown mode "…"` | `mode` is not `'full'`, `'lite'` or `'passive'`. | Fix the value, or leave `mode` out to get `full`. | | `signal report failed: ` | Your edge answered with an error status. | Look up the status under [From your edge](#from-your-edge). | | `TypeError` from `fetch` | The browser could not complete the POST. Usually CORS, DNS, TLS, or the edge is down. | Check the browser console, `ALLOWED_ORIGIN`, and the edge's `/health`. | | `AbortError`, or your own abort reason | Your code aborted the collection. | None. If the abort came before the POST reached your edge, no verdict was produced. | Blocked or failed measurements never cause an error. If the WebSocket to your edge fails, the collector sends what it has and results are weaker. The usual causes are a Content Security Policy without `wss://` for your edge, and networks that block WebSockets. ## From your edge These are the responses to `POST /v1/signals`, which the browser sees: | Status | Body | Cause | What to do | |---|---|---|---| | `200` | `{"ok":true}` | Octet accepted the session. | None. | | `400` | `{"error":"read_failed"}` | The edge could not read the request body. | Retry. | | `405` | `{"error":"method_not_allowed"}` | A method other than `POST` or `OPTIONS`. | Use `POST`. | | `502` | `{"error":"octet_unreachable"}` | The edge could not reach Octet. | Check `OCTET_URL`, outbound TCP 443, and DNS on the edge host. If you set `OCTET_CA_FILE`, check that Octet's server certificate chains to it. The edge's log has the detail. | | `503` | `{"error":"edge_signing_failed"}` | From v1.7.0. The edge couldn't sign the session, for example because its cloud key service was unreachable. It sent nothing to Octet. | Check the key service and the edge's access to it. The edge's log has the detail. See [Where to keep the signing key](/reference/edge-config#where-to-keep-the-signing-key). | | Octet's status | `{"error":"octet_rejected","reason":"","status":}` | Octet refused the session. The edge passes Octet's status and reason code through. | Look up the reason below. | When Octet refuses a session, the edge logs one line with the status and reason, with no end-user data. `reason` is `unknown` when Octet's reply carries no plain reason code. ### Reasons Octet gives your edge | Status | Reason | Cause | What to do | |---|---|---|---| | `401` | `missing_license` | `LICENSE` is not set on the edge. | Set `LICENSE` and restart. | | `401` | `bad_vendor_prefix`, `malformed_token`, `bad_footer`, `bad_payload`, `bad_signature` | `LICENSE` is not a valid license token. It may be truncated, or be a read token. | Copy the license token again from Octet's email. | | `401` | `wrong_typ`, `wrong_issuer`, `product_not_licensed`, `platform_not_licensed` | The token is valid but not an Octet Browser license. | Use the license token issued for Octet Browser. | | `401` | `expired`, `not_yet_valid`, `missing_exp`, `missing_iat_and_nbf` | The license token is outside its validity period, or the edge host's clock is wrong. | Check the host's clock. If it is right, ask Octet for a new license token. | | `401` | `revoked` | Octet revoked the license token. | Deploy the replacement Octet sent you, or contact Octet. | | `401` | `unknown`, or any reason not listed on this page | Octet could not check the license. | Retry. If it persists, contact [developer@octetproof.com](mailto:developer@octetproof.com). | | `401` | `client_cert_required` | The edge presented no client certificate, or one Octet didn't issue. | Check `EDGE_CLIENT_CERT_FILE` and `EDGE_CLIENT_KEY_FILE`, and that the certificate hasn't expired. See [Deploy the Edge](/integration/deploy-edge#4-install-the-client-certificate). | | `403` | `client_cert_licence_mismatch` | The client certificate was issued for a different license than the one in `LICENSE`. | Use a certificate issued for this license, from its **Edge configuration** tab in the Octet portal, under **Certificates**. | | `403` | `client_cert_revoked` | The client certificate was revoked in the Octet portal. | Issue a new certificate in the **Edge configuration** tab, under **Certificates**, install it and restart the edge. See [Credentials](/integration/credentials#get-the-edges-client-certificate). | | `401` | `edge_signature_required` | Octet requires a signature from your edge, and the session had none, because `EDGE_SIGNING_KEY` is unset. | Set `EDGE_SIGNING_KEY`, restart the edge and register its key. See [Deploy the Edge](/integration/deploy-edge#5-create-the-signing-key). | | `401` | `edge_key_unknown` | Octet requires a signature from your edge, and the key that signed the session isn't registered for this license, or was revoked. | Register the `kid` and public key from the edge's startup log in the **Edge configuration** tab, under **Signing keys**. See [Credentials](/integration/credentials#register-an-edge-signing-key). | | `401` | `edge_signature_stale` | The signature's time is out of range, usually because the edge host's clock is wrong. | Check the edge host's clock, for example that NTP is running. | | `401` | `edge_signature_mismatch` | The session Octet received differs from the one your edge signed. Something between your edge and Octet changed the request. | Make sure nothing between the edge and Octet, such as an outbound proxy, rewrites the request. | | `401` | `edge_signature_invalid` | The edge's signature doesn't verify, or its key is registered for a different license. | Update the edge to the latest release and restart it. If it persists, contact [developer@octetproof.com](mailto:developer@octetproof.com). | | `401` | `challenge_required` | The collection carries no challenge. A collector older than v1.5.0 gets this. | Update the collector, and pass it a challenge from your backend. See [Get a challenge](/integration/embed-collector#get-a-challenge). | | `401` | `challenge_invalid` | The challenge is not one Octet issued, or it was changed. | Pass the challenge from `POST /v1/challenge` to the page unchanged. | | `401` | `challenge_expired` | The challenge is more than 10 minutes old. | Get a new challenge and call `start()` again. Get the challenge when you render the page, not earlier. | | `401` | `challenge_mismatch` | The challenge was issued for a different `sessionRef`, or under a different license from the edge's. | Request the challenge with the same `sessionRef` you pass to `start()`, with a read token from the same license as the edge's `LICENSE`. | | `409` | `challenge_spent` | Octet already accepted a collection with this challenge. | Get a new challenge for each collection. | | `400` | `invalid_json` | The body is not JSON. | Send the collector's POST unchanged. | | `400` | `unsealed_bundle` | The body isn't a sealed collection. A collector older than v1.3.0 gets this, and so does an empty `{}` body. | Update the collector. | | `400` | `unknown_seal_key` | The collector is sealed to a key Octet no longer accepts, because the collector is too old. | Update the collector. See [Release Notes](/reference/release-notes#collector-support). | | `400` | `bad_seal`, `bad_signature`, `bad_payload` | The sealed collection doesn't open, or its contents don't check out. This usually means a proxy or a script on the page changed the body. | Send the collector's POST unchanged. | | `400` | `missing_fields` | The sealed collection opened, but isn't a complete collection. | Send the collector's POST unchanged. | | `409` | `replayed_bundle` | Octet accepted this collection recently, through this edge or another. A copy of a collection is refused. | None for the original session, whose verdict stands. | | `413` | `payload_too_large` | The body is larger than Octet accepts. | Send the collector's POST unchanged. | A `401` with `bad_signature` or `bad_payload` concerns the license token, and a `400` with the same reason concerns the collection. ## From `POST /v1/challenge` These are the responses to `POST /v1/challenge`, which your backend sees. If you send the request through your edge, the edge returns Octet's response unchanged. | Status | Body | Cause | What to do | |---|---|---|---| | `200` | `{"challenge":"…","exp":…}` | Octet issued a challenge. | Pass `challenge` to the page. | | `400` | `{"error":"invalid_json"}` | The body is not JSON. | Send `{"sessionRef":"…"}` with `content-type: application/json`. | | `400` | `{"error":"invalid_session_ref"}` | `sessionRef` is missing, not a string, empty, or longer than 256 characters. | Send the `sessionRef` you created for the page view. | | `400` | `{"error":"invalid_client_ip"}` | `clientIp` is present but isn't an IPv4 or IPv6 address. | Send the user's IP address as a string, or leave `clientIp` out. | | `401` | The same bodies as for `GET /v1/verdict` | The read token was refused. | See [From the Octet API](#from-the-octet-api). | | `413` | `{"error":"payload_too_large"}` | The body is larger than 4 KB. | Send only `sessionRef` and `clientIp`. | | `429` | `{"error":"rate_limited"}` | Your license requested too many challenges in one minute. The limit is far above any real page-view rate. | Retry after a short wait. If it persists, check that your backend requests one challenge per page view, and that your read token hasn't leaked. | Through your edge, you can also get the edge's own errors: `405` with `method_not_allowed` for a method other than `POST`, `400` with `read_failed`, and `502` with `octet_unreachable`. See [From your edge](#from-your-edge). ## From the Octet API These are the responses to `GET /v1/verdict/{sessionRef}`, which your backend sees: | Status | Body | Cause | What to do | |---|---|---|---| | `200` | The verdict | See [Verdict Reference](/reference/verdict). | Apply your policy. | | `404` | `{"status":"pending","ref":"…"}` | No verdict for this `sessionRef` yet, the verdict is more than 2 minutes old, the `sessionRef` is wrong, or the session arrived under another license. | Retry with `waitMs`. If it persists, check that the page passes the same `sessionRef` to `start()`. | | `401` | `{"error":"unauthorized"}` | No `Authorization: Bearer octet_read_…` header. A license token sent as the bearer also gets this. | Send your read token. | | `401` | `{"error":"expired"}` | The read token has expired. | Create a new one. See [Credentials](/integration/credentials). | | `401` | `{"error":"revoked"}` | The read token, or its license, has been revoked. | Create a new one, or contact Octet if the license was revoked. | | `401` | `{"error":"not_yet_valid"}` | The read token isn't valid yet. | Check your server's clock. | | `401` | `malformed_token`, `bad_footer`, `bad_payload`, `bad_signature`, `unknown_kid:…` | The read token is damaged or incomplete. | Copy it again, or create a new one. | | `401` | `wrong_typ`, `wrong_issuer`, `wrong_audience`, `missing_scope`, `wrong_env`, `missing_lid`, `missing_exp`, `missing_iat_and_nbf`, `unsupported_schema:…` | The token is not an Octet Browser read token. | Create a read token under **Read tokens** in the **Verdict API** tab of your Octet Browser license. | | `401` | Any reason not listed on this page | Octet could not check the read token. | Retry. If it persists, contact [developer@octetproof.com](mailto:developer@octetproof.com). | | `503` | `{"error":"verdict_signing_unavailable"}` | Octet could not sign the verdict. | Retry after a short wait. Octet holds the verdict for 2 minutes. | The `401` reason codes from `GET /v1/verdict` appear exactly as listed, and some carry a suffix after a colon. ## From `GET /v1/device/{deviceKey}` These are the responses to `GET /v1/device/{deviceKey}`, which returns one browser's [device record](/concepts/how-it-works#get-or-delete-a-device-record): | Status | Body | Cause | What to do | |---|---|---|---| | `200` | The record | Your license has a record under this `deviceKey`. See below. | Pass it on to your user, for example to answer an access request. | | `400` | `{"error":"invalid_device_key"}` | The path doesn't hold a `deviceKey` in the form `_`. | Send the `deviceKey` exactly as the verdict carried it. | | `401` | The same bodies as for `GET /v1/verdict` | The read token was refused. | See [From the Octet API](#from-the-octet-api). | | `404` | `{"error":"device_record_not_found"}` | Your license has no record under this `deviceKey`, for example because it was deleted. A `deviceKey` from another license gets the same answer. | Nothing. There is no record for this browser on your license. | | `404` | `{"error":"device_records_not_configured"}` | This Octet server keeps no device records. Until device records start for your license (from 1 December 2026), every request gets this. | Nothing. There is no record to return. | The response carries `Cache-Control: no-store`. A `200` body has these fields. Times are ISO 8601 in UTC, and a value Octet doesn't have is `null`. | Field | Description | |---|---| | `lid` | Your license identifier. | | `deviceKey` | The `deviceKey` the record is held under. | | `firstSeen`, `lastSeen` | When the browser was first and last seen. | | `privacyProtection` | The privacy protection recognised in the browser, for example `brave`, or `null`. | | `maxProcessorCount` | The highest processor count the browser reported, or `null`. | | `canvasHashes`, `audioHashes` | Up to 20 each, most often seen first. Each has `hash`, `count` and `lastSeen`. `hash` is keyed for your license: it matches nothing outside your license's records. | | `sessions` | Up to 200, oldest first. Each has `time`, `verdict` (`country`, `confidence`, `alarm`), `ipCountry` (the country of the user's IP address, never the address), `checks` (one `true` or `false` for each of Octet's server-side checks) and `networkTag` (the keyed tag of the user's network). | Field names don't change. Later versions may add fields, so ignore fields you don't recognise. ## From `DELETE /v1/device/{deviceKey}` These are the responses to `DELETE /v1/device/{deviceKey}`, which deletes one browser's [device record](/concepts/how-it-works#get-or-delete-a-device-record): | Status | Body | Cause | What to do | |---|---|---|---| | `200` | `{"deleted":true}` | Octet deleted the record. | Nothing. | | `200` | `{"deleted":false}` | Your license has no record under this `deviceKey`, for example because it was already deleted. | Nothing. | | `400` | `{"error":"invalid_device_key"}` | The path doesn't hold a `deviceKey` in the form `_`. | Send the `deviceKey` exactly as the verdict carried it. | | `401` | The same bodies as for `GET /v1/verdict` | The read token was refused. | See [From the Octet API](#from-the-octet-api). | | `404` | `{"error":"device_records_not_configured"}` | This Octet server keeps no device records. Until device records start for your license (from 1 December 2026), every request gets this. | There is no record to delete. | ## Key sets Each of these returns `200` with a JSON document: | URL on `https://geo.octetproof.com` | What it is | |---|---| | `/.well-known/browser-verdict-jwks.json` | The verdict key set. Cache it for up to 5 minutes. See [Verify the Signed Token](/integration/verify-token#the-key-set). | | `/.well-known/browser-verdict-jwks-archive.json` | Every key Octet has signed verdicts with. See [Keep Verdicts for Audit](/integration/keep-verdicts#confirm-each-key). | | `/.well-known/browser-challenge-jwks.json` | The challenge key set. See [The chain](/integration/verify-token#the-chain). | | `/.well-known/octet-browser-manifest.json.jws` | The manifest signed by Octet's root keys. See [Check a key against Octet's root keys](/integration/verify-token#check-a-key-against-octets-root-keys). | _Source: https://octetproof.com/docs/browser/reference/errors/_ --- # Release Notes ## Release assets Every file is a release asset in [octetproof/octet-browser](https://github.com/octetproof/octet-browser), under the [latest release](https://github.com/octetproof/octet-browser/releases/latest). All versions and their changelogs are on the [releases page](https://github.com/octetproof/octet-browser/releases). Each release has these assets: | Asset | What it is | |---|---| | `octet-collector.js` | The collector, as a script that defines the global `octet`. Serve it from your own origin. | | `octet-collector.js.sri` | The Subresource Integrity hash of `octet-collector.js`, for the `integrity` attribute. | | `octet-collector.mjs` | The collector as an ES module. | | `octetproof-collector-.tgz` | The collector as an npm package, `@octetproof/collector`, with TypeScript types. | | `octet-edge-linux-amd64` | The edge, for Linux on x86-64. | | `octet-edge-linux-arm64` | The edge, for Linux on 64-bit ARM. | | `SHA256SUMS` | SHA-256 checksums of the files above. | | `SHA256SUMS.sig`, `SHA256SUMS.pem` | The cosign signature over `SHA256SUMS`, and its certificate. | Use the collector and the edge from the same release. To check your downloads, see [Deploy the Edge](/integration/deploy-edge#1-download-and-verify-the-binary). ## Collector support Octet accepts collections from a collector release for 12 months after the release that replaces it. When a new release comes out, its entry below gives the date the previous collector stops being accepted. After that date, the old collector's sessions get `400` with `unknown_seal_key`. Two releases are exceptions, because they changed what every collection must carry. From v1.3.0, Octet accepts only sealed collections, so a v1.2.0 collector gets `400` with `unsealed_bundle`. From v1.5.0, Octet accepts only collections that carry a challenge, so every collector older than v1.5.0 gets `401` with `challenge_required`. ## Browser support The collector is built for ECMAScript 2020, which every current version of Chrome, Edge, Firefox and Safari supports, on computers and phones. It needs `fetch` and a secure (HTTPS) page. In browsers that lack WebRTC or WebSockets, or where they are turned off, the collector still works and the verdict is weaker. ## v1.8.1 No integration changes. The collector and the edge are the same as in v1.8.0. - Octet accepts the read tokens the Octet portal issues from the week of 9 November 2026. The read tokens you use today keep working until the portal replaces them. Then use the new ones as before. ## v1.8.0 No change to how you call Octet: the challenge, the verdict response and the token are the same as in v1.7.0. Update the collector and the edge when convenient. If your site sets a strict Content Security Policy, read the first item before you update. - The collector now runs a short check in a hidden, sandboxed frame with a small inline script, and removes the frame within a second. On a page with a nonce-based CSP, pass the page's nonce in the new option `cspNonce`. With Trusted Types and a `trusted-types` list, add `octet-collector` to the list. With a hash-only CSP, the check is skipped quietly. In every case nothing else changes. See [The collector's check frame](/reference/network#the-collectors-check-frame). - New `CollectorConfig` option, `cspNonce`. See [Collector API](/reference/collector-api#collectorconfig). - The collector file is about 11 KB larger, 4.5 KB gzipped. - The edge includes an updated network library with security fixes. There is nothing to configure. ## v1.7.0 No change to how you call Octet: the collector's options, the verdict response and the token are the same as in v1.6.0. Update the collector and the edge when convenient. - **New:** the Octet portal asks you to prove you hold each edge signing key you register. Run `octet-edge prove-key --challenge …` on the edge host and paste the line it prints. Keys registered earlier can be proven the same way. See [Prove you hold the signing key](/reference/edge-config#prove-you-hold-the-signing-key). - **New:** the edge can read its signing key from a file, for example one encrypted to the host's TPM, with `EDGE_SIGNING_KEY_FILE`, or sign in AWS KMS or Google Cloud KMS with `EDGE_SIGNING_KMS`. Set only one of these and `EDGE_SIGNING_KEY`. See [Where to keep the signing key](/reference/edge-config#where-to-keep-the-signing-key). - **Changed:** if the edge can't sign a session, for example because its cloud key service is unreachable, it answers the browser with `503` and `edge_signing_failed` and sends nothing to Octet. Before, it forwarded the session unsigned, which Octet refused. See [Errors](/reference/errors#from-your-edge). - When your edge serves HTTPS itself, it now also sends Octet two numbers about how steady the browser's connection was during the check, with no personal data. Mention it in your privacy notice as needed. See [Privacy and Data](/concepts/privacy#data-added-by-your-edge). - The challenge your backend gets from `POST /v1/challenge` is about 360 characters longer. Pass it to the collector as before. ## v1.6.0 No integration changes. The collector's options, the challenge, the verdict response and the token are the same as in v1.5.0. Update the collector and the edge together when convenient. - When the edge serves HTTPS itself, it now serves HTTP/2 to browsers that offer it. There is nothing to configure. See [Edge Configuration](/reference/edge-config). - When the edge serves HTTPS itself, it now also sends Octet a fingerprint of how the browser opens its TCP and HTTP/2 connections, with no personal data, as it has sent a fingerprint of the TLS connection since v1.5.0. Mention it in your privacy notice as needed. See [Privacy and Data](/concepts/privacy#data-added-by-your-edge). - Octet's root-signed manifest, at `/.well-known/octet-browser-manifest.json.jws`, has a new edition that also lists Octet's license keys. Its `typ` and `v` are unchanged, so a verifier that checks the v1.5.0 manifest checks this one too. See [Check a key against Octet's root keys](/integration/verify-token#check-a-key-against-octets-root-keys). - **Action needed if you verify tokens:** from 19 October 2026 at 15:00 UK time, Octet signs verdict tokens with an ECDSA P-256 key held in a hardware security module, as announced in v1.5.0. From then, tokens have `alg` `ES256` and the `kid` `octet-browser-verdict-2026-10`. Tokens signed before then, with `kid` `octet-browser-verdict-2026-09` and `alg` `EdDSA`, stay verifiable with the key archive. Your verifier must accept `ES256` and take the algorithm from the key the `kid` names. See [Verify the Signed Token](/integration/verify-token#format). ## v1.5.1 No integration changes. The collector and the edge are the same as in v1.5.0. ## v1.5.0 **Breaking:** update the collector, and change your backend and page to pass a challenge. From this release, Octet accepts a collection only when it carries a one-time challenge that your backend got from Octet for its `sessionRef`. Collectors older than v1.5.0 send no challenge, so their sessions get `401` with `challenge_required`. They are not covered by the 12-month support window. - Your backend requests a challenge for each page view with `POST /v1/challenge`, body `{"sessionRef": "…"}`, and your read token. The response is `{"challenge": "…", "exp": …}`. A challenge is valid for 10 minutes and works for one collection. Send the request to `geo.octetproof.com`, or through your edge, which forwards it unchanged. See [Get a challenge](/integration/embed-collector#get-a-challenge). - `CollectorConfig` has a new required field, `challenge`. Without it, `start()` throws and `verify()` rejects. See [Collector API](/reference/collector-api#collectorconfig). - Each challenge works for one collection. `ready()` reuses a result for 90 seconds as before. After that, or after a failed POST, it rejects with `octet: this challenge was already used`, and you call `start()` with a new challenge. - New reason codes on `POST /v1/signals`: `401` with `challenge_required`, `challenge_invalid`, `challenge_expired` or `challenge_mismatch`, and `409` with `challenge_spent`. New errors on `POST /v1/challenge`: `400` with `invalid_json`, `invalid_session_ref` or `invalid_client_ip`, and `429` with `rate_limited`. See [Errors](/reference/errors). - The verdict has a new field, `deviceKey`, which identifies the browser and is scoped to your license. See [Verdict Reference](/reference/verdict#device-key). - The verdict token's `typ` is now `octet-browser-verdict+jwt;v=3`. Version 2 added the `deviceKey` claim, and version 3 adds `chain`, `chain_root` and `clientNetMatch`. Tokens with `;v=1` and `;v=2` still verify. Update your verifier to accept all three. See [Verify the Signed Token](/integration/verify-token). - The `clientNet` claim is removed. To check the user's network, send the user's IP address as `clientIp` with the challenge request, and read `clientNetMatch` in the token. See [Check the user's network](/integration/verify-token#check-the-users-network). - Octet signs verdict tokens with Ed25519, as before. Octet may move to an ECDSA P-256 key (`ES256`). The verification examples now accept both, and take the algorithm from the key. - New: a key archive with every verdict key Octet has signed with, at `/.well-known/browser-verdict-jwks-archive.json`. See [Keep Verdicts for Audit](/integration/keep-verdicts#confirm-each-key). - New: a manifest of Octet's keys, signed by two offline root keys, at `/.well-known/octet-browser-manifest.json.jws`. Pin the root keys from [Check a key against Octet's root keys](/integration/verify-token#check-a-key-against-octets-root-keys). - New: the challenge key set, at `/.well-known/browser-challenge-jwks.json`. - The edge forwards `POST /v1/challenge` to Octet. It needs no new settings. ## v1.4.0 **Action needed:** when you update the edge, give it a signing key and register the key in the Octet portal. Octet is turning on required edge signatures. From then on, an edge without a registered key gets `401` with `edge_signature_required` or `edge_key_unknown`. - The edge signs each session it forwards to Octet with its own key, set in `EDGE_SIGNING_KEY`. The signature covers the collection and what the edge adds about the connection. See [Deploy the Edge](/integration/deploy-edge#5-create-the-signing-key). - You register the signing key's `kid` and public key in your license's **Edge configuration** tab in the Octet portal, under **Signing keys**. A license can hold up to 3 signing keys, for a rotation overlap, and you can revoke one there. See [Credentials](/integration/credentials#register-an-edge-signing-key). - The signing key is separate from the edge's client certificate. The edge needs both. - Octet refuses a session whose edge signature fails its check, with `401`. New reason codes: `edge_signature_required`, `edge_key_unknown`, `edge_signature_invalid`, `edge_signature_mismatch` and `edge_signature_stale`. See [Errors](/reference/errors#reasons-octet-gives-your-edge). - The edge no longer sends a `User-Agent` of its own when the browser sent none. - The collector works as in v1.3.0, with no change to its options, hosts or Content Security Policy. - A malformed collection now gets `400` with `missing_fields` instead of a server error. - Fewer wrong countries for some users behind a VPN. ## v1.3.0 **Breaking:** update the collector and the edge together. From this release, Octet accepts only sealed collections and only edges with a client certificate. A v1.2.0 collector gets `400` with `unsealed_bundle`, and a v1.2.0 edge without a client certificate gets `401` with `client_cert_required`. The v1.2.0 collector is not covered by the 12-month support window, because it doesn't seal its collections. - The collector encrypts each collection to Octet before it leaves the browser. Your edge forwards it without being able to read it, and Octet can tell if it was changed on the way. See [Integrity and Audit](/concepts/integrity). - Octet refuses a copy of a collection it accepted recently, through any edge, with `409` and `replayed_bundle`. - The collector keeps a device key in the browser's IndexedDB, in a database named `octet` on your origin. See [Privacy and Data](/concepts/privacy#data-stored-in-the-browser). - The collector needs a secure (HTTPS) page. On a plain `http://` page it fails at once with an error. See [Errors](/reference/errors#in-the-browser). - The collector needs no `unsafe-eval`. - The edge connects to Octet only over mutual TLS, with a client certificate for your license. It refuses to start without one. You issue the certificate yourself in your license's **Edge configuration** tab in the Octet portal, under **Certificates**, and can revoke a single certificate there. See [Deploy the Edge](/integration/deploy-edge#4-install-the-client-certificate). - New reason codes: `client_cert_required`, `client_cert_licence_mismatch`, `client_cert_revoked`, `unsealed_bundle`, `unknown_seal_key`, `bad_seal` and `replayed_bundle`. See [Errors](/reference/errors#reasons-octet-gives-your-edge). - Verdicts are harder to influence from the browser. - Sessions through iCloud Private Relay keep the user's own country, and aren't treated as masked. - On some connections, `ready()` can take up to about 4.5 s in `full` mode, and up to about 3 s in `lite` mode. - New pages: [Integrity and Audit](/concepts/integrity) and [Keep Verdicts for Audit](/integration/keep-verdicts). ## v1.2.0 The first public release of Octet Browser. - The collector's `start()` and `ready()` split collection from the moment of action: start at page load, and wait at the moment of action. - Three collection modes, `full`, `lite` and `passive`. Every host the collector contacts is run by you or by Octet. - Each verdict carries a signed token, verifiable against Octet's published key set. - Per-license read tokens for fetching verdicts, created and revoked by you in the Octet portal. - The edge terminates TLS itself, and returns Octet's status and reason code when Octet refuses a session. - Static edge binaries for Linux on `amd64` and `arm64`. - The collector starts a Web Worker from a `blob:` URL. Allow `worker-src blob:` in your Content Security Policy for the best results. See [Network and CSP](/reference/network). _Source: https://octetproof.com/docs/browser/reference/release-notes/_ --- # Troubleshooting Each entry starts from what you see. For every status and reason code, see [Errors](/reference/errors). Check the [service status page](https://api.octetproof.com/status) to see whether an Octet service is having problems. ## The verdict fetch always returns `404` - **The page and the backend use different `sessionRef` values.** Log the value you pass to `start()` and the value you fetch, and compare them. - **The fetch runs more than 2 minutes after the collection.** Fetch right after `ready()` resolves. - **The collection never reached Octet.** Check the browser console for a rejected `ready()`, and look up its status in [Errors](/reference/errors). - **The edge's license and your read token belong to different licenses.** A read token reads only the verdicts that arrived under its own license. ## The browser console shows a CORS error The page's origin isn't in the edge's `ALLOWED_ORIGIN`. Add the exact origin, including the scheme and any port, for example `https://www.example.com`, and restart the edge. ## Every session gets `401` or `403` from the edge Octet refused your license token, your edge's client certificate or its signature. The `reason` in the edge's response, and in the edge's log, says which. See [Reasons Octet gives your edge](/reference/errors#reasons-octet-gives-your-edge). ## Every session gets `400` with `unsealed_bundle` The page serves a collector older than v1.3.0, which Octet no longer accepts. Download the current `octet-collector.js` and its `octet-collector.js.sri`, and serve both from your origin. See [Embed the Collector](/integration/embed-collector). ## Every session gets `401` with `challenge_required` The page serves a collector older than v1.5.0, or passes it no challenge. Update the collector, and have your backend request a challenge for each page view and pass it to `start()`. See [Get a challenge](/integration/embed-collector#get-a-challenge). ## `ready()` rejects with `this challenge was already used` The collector already sent this challenge. This happens when `ready()` is called more than 90 seconds after the last collection, or after a failed POST. Get a new challenge from your backend and call `start()` with it. A `409` with `challenge_spent` from the edge has the same cause. ## Honest users get `medium` or `high` Check that nothing terminates TCP or TLS in front of the edge. A proxy, application load balancer or CDN in front of the edge makes verdicts wrong for every user. See [Deploy the Edge](/integration/deploy-edge#nothing-may-terminate-tls-or-tcp-in-front-of-the-edge). An honest session from a country under comprehensive sanctions gets `high` by design. See [Verdicts](/concepts/verdicts#alarm). If the edge is set up correctly and a specific honest session still gets `medium` or `high`, send its `sessionRef` and time to [developer@octetproof.com](mailto:developer@octetproof.com). ## Every verdict has the same country, the one where my servers are Something in front of the edge is replacing the user's IP address with its own. The edge reads the user's IP address from the connection itself, not from forwarded headers. Remove that proxy or load balancer, or switch it to layer 4 passthrough. ## VPN sessions come back as `none` - Check that the page runs in `full` mode, the default. `lite` and `passive` give Octet less evidence. - Check that your CSP allows the hosts for your mode and `worker-src blob:`. See [Network and CSP](/reference/network). - Octet does not flag every masked session. ## `ready()` is slow - Call `start()` at page load, not at the moment of action, so that the collection is done by the time the user acts. - On some connections, `ready()` can take up to about 4.5 seconds in `full` mode, and up to about 3 seconds in `lite` mode. Allow for this in any timeout you set. ## The collector script doesn't load - Serve `octet-collector.js` from your own origin. A `src` that points at the GitHub release URL won't load. - Check that the `integrity` value is the exact contents of `octet-collector.js.sri` from the same release as the script. A mismatch blocks the script. - Check that your `script-src` allows the path you serve it from. ## The edge log says `plain HTTP` `EDGE_TLS_CERT_FILE` or `EDGE_TLS_KEY_FILE` is unset, so the edge is serving HTTP. Set both and restart. See [Edge Configuration](/reference/edge-config). ## The edge doesn't start The edge refuses to start without its client certificate and key, `EDGE_CLIENT_CERT_FILE` and `EDGE_CLIENT_KEY_FILE`, or with an `OCTET_URL` that isn't `https://`. Run `sudo journalctl -u octet-edge -n 20`. The last line names what is missing, for example `mTLS to octet is required, set EDGE_CLIENT_CERT_FILE, EDGE_CLIENT_KEY_FILE`. To get the files, see [Deploy the Edge](/integration/deploy-edge#4-install-the-client-certificate). ## The edge log warns that `EDGE_SIGNING_KEY` is unset The edge is forwarding sessions without a signature. Create a signing key, set it as `EDGE_SIGNING_KEY`, restart the edge and register the key in the portal. See [Deploy the Edge](/integration/deploy-edge#5-create-the-signing-key). ## Token verification started failing on 19 October 2026 From 19 October 2026, 15:00 UK time, Octet signs verdict tokens with `ES256` and the `kid` `octet-browser-verdict-2026-10`. A verifier that accepts only `EdDSA`, or that keeps a fixed copy of the old key, rejects them. Take the algorithm from the key the `kid` names, and fetch the key set again when a token carries a `kid` you haven't seen. See [Verify the Signed Token](/integration/verify-token#format). ## Our auditor wants to check our verdicts Give the auditor your stored tokens, the Octet keys you kept, and the check in [Keep Verdicts for Audit](/integration/keep-verdicts). The auditor needs no Octet account. ## Still stuck Email [developer@octetproof.com](mailto:developer@octetproof.com) with the `sessionRef`, the time of the request, and the status and reason code you saw. Never send a license token, read token or private key. _Source: https://octetproof.com/docs/browser/troubleshooting/faq/_ --- =================== LFA =================== # Octet LFA A location factor for sign-in. LFA (Location Factor Authentication) adds a check that a password, passkey, or OTP cannot make: whether the user is physically inside the region your policy requires. The user proves presence with the Octet Location app on their phone. The proof is hardware-attested and resistant to spoofing, and no raw coordinates leave the device. Your application receives only a region verdict, delivered over standard OIDC. For LFA pricing, see [pricing](https://octetproof.com/pricing). LFA is built on the [Octet SDK](/docs/mobile/). The SDK produces and verifies the proof itself: the on-device pipeline, the hardware attestation, the anti-spoof verdicts, the region model. LFA turns that proof into an authentication factor. It wraps the proof in OIDC, applies your policy, and binds it to one of your users on one enrolled phone. :::note[Private preview] LFA is in private preview. The phone app ships on iOS and Android, geofences are country-level, and clients are registered by the Octet team ([hello@octetproof.com](mailto:hello@octetproof.com)). There is no self-service sign-up yet. Okta is supported today through signed authorization requests ([RFC 9101](https://www.rfc-editor.org/rfc/rfc9101)). See [Okta](/integration/okta). Other brokers that sign requests should work. Contact us first. ::: ## Starting points - **Evaluating LFA** → [How a login works](/concepts/how-a-login-works) - **Integrating now** → [Prerequisites](/getting-started/prerequisites), then the [Quick Start](/getting-started/quickstart) - **Deciding what to enforce** → [Policies](/concepts/policies) - **Users replacing or losing phones** → [Device lifecycle](/concepts/device-lifecycle) - **Looking up an endpoint, parameter, or claim** → [OIDC Reference](/reference/oidc) ## What this documentation covers - [**Getting Started**](/getting-started/prerequisites). What you need before the first login, and a complete login end to end. - [**Concepts**](/concepts/how-a-login-works). The login flow, policies, the device-to-user binding lifecycle, and what LFA stores and never stores. - [**Integration Guides**](/integration/direct-oidc). Direct OIDC integration for any authorization-code stack, and identity brokers such as Okta via signed authorization requests. - [**Reference**](/reference/oidc). Endpoints, protocol requirements, parameters, claims, and errors. - [**Troubleshooting**](/troubleshooting/faq). Indexed by the error you see. ## Flow walkthrough The [product page](https://octetproof.com/product/) shows the flows step by step: what the person sees on the phone, and the SDK trace at the same instant. ## What LFA does not do - **Not a source of identity.** LFA is delivered as an OIDC provider, but it establishes where the user is, not who they are. Through [device binding](/concepts/device-lifecycle) it also establishes possession of the enrolled phone. Your primary IdP or your own login proves who the user is: you tell LFA which of your users is signing in, and LFA never sees a password or a profile. [Trust & Privacy](/concepts/trust-and-privacy) lists what is stored. - **Not a tracking service.** The phone never uploads coordinates. The proof carries only a region verdict ("in GB") and the cryptographic material to check it. - **Not a heuristic.** IP geolocation infers a location from network routing and is often wrong. LFA verifies the location instead. Every proof is rooted in the phone's secure hardware (see [device attestation](/docs/mobile/concepts/device-attestation/) in the SDK docs), and a proof that fails any check is rejected. _Source: https://octetproof.com/docs/lfa/_ --- # Prerequisites Four things you need before your first LFA login: 1. **LFA client credentials**, registered by Octet. 2. **An OIDC-capable application**, with one extra field on the token request. 3. **A stable user identifier** to send at token-exchange time. 4. **A phone with the Octet Location app**, physically in the policy region. --- ## Where LFA lives One issuer serves every customer. Most OIDC libraries need only the issuer and discover the rest. Brokers such as Okta do not read discovery, so the full list is here to copy. | | | |---|---| | Issuer | `https://factor.octetproof.com` | | Discovery | `https://factor.octetproof.com/.well-known/openid-configuration` | | Authorization | `https://factor.octetproof.com/authorize` | | Token | `https://factor.octetproof.com/token` | | JWKS | `https://factor.octetproof.com/.well-known/jwks.json` | | UserInfo | `https://factor.octetproof.com/userinfo` | | Device revoke | `https://factor.octetproof.com/v1/device/revoke` | No trailing slashes, and the issuer has no path. Confirm any of it live: ```bash curl -s https://factor.octetproof.com/.well-known/openid-configuration | jq ``` --- ## 1. Client credentials Clients are registered by the Octet team. There is no self-serve portal yet. Email [hello@octetproof.com](mailto:hello@octetproof.com) with: - your **redirect URIs**, matched exactly, using HTTPS - the **region policy or policies** you need (see [Policies](/concepts/policies)), for example "UK presence" For a **direct integration** you receive a `client_id` and a `client_secret`. For a **broker integration** (such as Okta) you receive a `client_id` only. The broker authenticates with a signed assertion rather than a shared secret, so there is no secret to hold or rotate. Redirect URIs can be updated the same way. ## 2. An OIDC-capable stack LFA is a standard OIDC identity provider: authorization-code flow with PKCE. Any stack that can federate to an external OIDC IdP can drive the login. One requirement goes beyond standard OIDC on the **direct-integration path**: your token exchange must include an extra form field, `octet_user_id` (next section). If your OIDC library cannot add a parameter to the token request, perform the exchange yourself. It is a single POST, shown in the [Quick Start](/getting-started/quickstart). :::note Identity brokers such as Okta run a fixed standard token exchange and cannot attach `octet_user_id`. They are supported by a different mechanism: the broker sends a **signed authorization request** ([RFC 9101](https://www.rfc-editor.org/rfc/rfc9101)) whose `login_hint` LFA binds on, so a broker never sends `octet_user_id` at all. If you integrate through a broker, follow [Okta](/integration/okta) instead of this page's direct-integration steps. The [OIDC reference](/reference/oidc) has the parameter details. ::: ## 3. A user identifier for binding You need your own identifier for the user who is signing in. LFA uses it to [bind the user's enrolled phone to their account](/concepts/device-lifecycle). That binding is what makes the factor "this user's phone in this region" rather than "any phone in this region". How you supply it depends on your integration path. For a **direct integration** you send it as `octet_user_id` on every token exchange. For a **broker** the same role is played by the `login_hint` inside the signed authorization request. LFA binds on the hint from the verified request object, so you never send `octet_user_id`. For a direct integration, choose an identifier that is: - **stable**. It should never change for the lifetime of the account (a database ID, not an email address). - **opaque**. LFA stores it, so prefer an internal ID over anything personally identifying (see [Trust & Privacy](/concepts/trust-and-privacy)). - **unique** per user within your client. For a broker, the identifier is the `login_hint` the broker signs, which for Okta is the user's Okta username. LFA stores that value. ## 4. The phone - An iPhone with the **Octet Location app** ([App Store](https://apps.apple.com/app/octet-location/id6780920912)), or an Android phone with the same app ([Google Play](https://play.google.com/store/apps/details?id=com.octetproof.location)). Both platforms reach the same assurance level. See the [FAQ](/troubleshooting/faq) for how that is achieved on each. - Location permission granted to the app. - The user physically inside the policy's region at login time. There is no enrollment step for you to administer: a user's first successful login attests and binds their phone automatically. Replacements and revocations are covered in [Device lifecycle](/concepts/device-lifecycle). _Source: https://octetproof.com/docs/lfa/getting-started/prerequisites/_ --- # Quick Start A complete LFA login, end to end: redirect the browser out, let the phone prove presence, exchange the code, check one claim. Nothing here requires a specific framework. It is the plain authorization-code flow plus one extra token field. Have the [prerequisites](/getting-started/prerequisites) ready: a `client_id`, a `client_secret`, a registered redirect URI, and a phone with the Octet Location app inside your policy's region. :::note This is the **direct integration** walkthrough: your backend does the code exchange itself and sends `octet_user_id` on `/token`. Identity brokers such as Okta do not follow these steps. They cannot add `octet_user_id` to their fixed token exchange, so they authenticate the `/authorize` request with a signed request object instead. If you are wiring up a broker, follow [Okta integration](/integration/okta), not this page. ::: --- ## 1. Redirect to `/authorize` Send the browser to LFA with standard authorization-code and PKCE parameters. `state` and `nonce` are both required (stricter than the OIDC spec): ```bash https://factor.octetproof.com/authorize ?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https://yourapp.example/callback &scope=openid%20octet.location &state=RANDOM_STATE &nonce=RANDOM_NONCE &code_challenge=BASE64URL_SHA256_OF_VERIFIER &code_challenge_method=S256 ``` Generate `code_verifier` as 43 to 128 random URL-safe characters, and send `code_challenge = BASE64URL(SHA256(code_verifier))` with no padding: ```bash CODE_VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n') CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=\n') ``` The browser lands on the LFA login page: a QR code, plus an app link if the user is already on their phone. The login session lives **90 seconds**. ## 2. The user proves presence The user scans the QR code with the Octet Location app. The app runs the location check, produces a hardware-attested proof, and submits it directly to LFA while the browser waits. On success the browser returns to your `redirect_uri` with `?code=…&state=…`. On failure (wrong region, spoofing detected, attestation failure) it returns `error=access_denied`. ## 3. Exchange the code with `octet_user_id` Verify `state`, then exchange the code within **60 seconds**. The exchange is standard except for one field: `octet_user_id`, your identifier for the user who is signing in. ```bash curl -s https://factor.octetproof.com/token \ -d grant_type=authorization_code \ -d code=THE_CODE \ -d redirect_uri=https://yourapp.example/callback \ -d client_id=YOUR_CLIENT_ID \ -d client_secret=YOUR_CLIENT_SECRET \ -d code_verifier=THE_PKCE_VERIFIER \ -d octet_user_id=usr_1234 ``` On the user's **first** login this binds their phone to `usr_1234`. On every later login it checks that the phone is still that user's bound device. Omitting the field fails with `invalid_request`. A mismatched device fails with `invalid_grant`. See [Device lifecycle](/concepts/device-lifecycle). The response carries the tokens and a convenience mirror of the location assertion: ```json { "token_type": "Bearer", "access_token": "…", "expires_in": 120, "id_token": "…", "location_assertion": "…" } ``` ## 4. Validate and gate Validate the `id_token` as you would from any OIDC IdP: check the ES256 signature against the [JWKS](/reference/oidc#endpoints), and check `iss`, `aud`, `exp`, and your `nonce` echo. Then the gate is a single check: ```json "acr": "urn:octet:loc:strong" ``` `acr: urn:octet:loc:strong` means the proof was hardware-attested and the policy passed. The `sub` claim is the `octet_user_id` you sent. The signed region statement itself travels as the `octet_location_assertion` claim (and the top-level `location_assertion` mirror). Most integrations do not need to decode it. It exists for audit trails and downstream verification. ## Errors Every error in this flow is indexed in [Troubleshooting](/troubleshooting/faq): expired QR, expired code, missing `octet_user_id`, a device bound to a different user. ## Where to go next - [Direct OIDC integration](/integration/direct-oidc). Policy selection per request, `login_hint`, and library notes. - [OIDC Reference](/reference/oidc). Every parameter, claim, and error. _Source: https://octetproof.com/docs/lfa/getting-started/quickstart/_ --- # How a Login Works :::note[In one sentence] LFA is an OIDC identity provider that issues tokens only after the user's phone produces a hardware-attested proof of presence in the region your policy names. ::: ## Components - **Your application** (or your broker) starts the login and consumes the tokens. - **The browser** shows a QR code and waits. - **The Octet Location app** runs the location check on the phone and produces the proof. It embeds the [Octet SDK](/docs/mobile/), which is what measures and signs [proof of location](/docs/mobile/concepts/proof-of-location/). - **LFA** (`factor.octetproof.com`) checks the proof ([how verification works](/docs/mobile/concepts/verifying-proofs/)) against your [policy](/concepts/policies), then answers over standard OIDC. ## The flow ```mermaid flowchart TD A[Your app redirects to /authorize] --> B[Browser shows QR code / app link] B --> C[User scans with the Octet Location app] C --> D[App runs the location check
signed proof, region verdict only] D --> E[Phone submits proof to LFA
with a hardware attestation] E --> F{LFA verifies:
attestation, freshness, policy} F -->|pass| G[Browser returns to your app with a code] F -->|fail| H[access_denied] G --> I[Your app exchanges the code + octet_user_id] I --> J[id_token with acr urn:octet:loc:strong
+ signed location assertion] ``` Step by step: 1. Your app redirects to `/authorize` exactly as it would for any external OIDC provider (code flow, PKCE, `state`, `nonce`). 2. The browser lands on the LFA login page (a QR code, or a tap-to-open app link on the phone itself) and waits on a live status stream. 3. The user scans the code. The app fetches the session's parameters, including a **server-issued one-time challenge** the proof must commit to. 4. The app runs the Octet location check on the phone and produces a signed proof whose payload is a region verdict, never coordinates. 5. The phone submits the proof with a hardware attestation. LFA verifies the whole chain fail-closed (signature, device identity, platform attestation, freshness, and the [policy](/concepts/policies)) and pushes the result to the waiting browser. 6. The browser returns to your app with an authorization code. You exchange it (with [`octet_user_id`](/getting-started/quickstart)) for the tokens. ## Direct integrations and brokers The flow above shows a **direct** integration: your app authenticates the `/token` exchange with its client secret and sends `octet_user_id` there. That is the identifier LFA binds the attested device to (see [Device lifecycle](/concepts/device-lifecycle)). Identity brokers such as Okta run a fixed, standard token exchange and cannot add `octet_user_id`, so they bind over the front channel instead. A broker authenticates the `/authorize` request with a **signed request object** ([RFC 9101](https://www.rfc-editor.org/rfc/rfc9101), a JAR passed as `request=`). LFA verifies that object against the broker's pinned signing keys and binds the device to the `login_hint` carried **inside** the verified object. In both cases the user id LFA binds to arrives over an authenticated channel: a client-secret-authenticated `/token` exchange, or a signature-verified `/authorize` object. It is never read from the raw browser redirect. Front-channel tampering (a browser rewriting `login_hint` to a victim's id) breaks the signature, and the request is rejected. :::note Direct clients are unaffected by broker handling. A stray `request=` parameter on a non-broker client is ignored, and direct clients keep using `octet_user_id` and `octet_device_enroll` on the RP-authenticated `/token`. ::: See [Okta](/integration/okta) for the broker setup, and [Device lifecycle](/concepts/device-lifecycle) for how binding and device replacement work in each mode. ## First login versus later logins The first time a phone is used, two one-time steps happen inside the same flow, so the first login takes slightly longer: - The app creates a signing key in the phone's secure hardware and has the platform certify it to LFA: Apple App Attest on iOS, hardware key attestation chained to a Google root on Android. This is **device enrollment**. - Your token exchange **binds** that device to the `octet_user_id` you sent (see [Device lifecycle](/concepts/device-lifecycle)). Every later login reuses the enrolled key. The phone produces a fresh hardware signature over this session's challenge, and LFA checks it against the key and binding on file. There is no re-enrollment and no extra prompt. ### Push for enrolled devices Once a device is enrolled and bound to a user, LFA can reach that phone directly. If your `/authorize` request identifies the user in a verified way (a broker client's signed request object carries them in `login_hint`), LFA sends a **push notification** to their bound phone instead of expecting a scan: > **Location check requested** > > *Acme is requesting a location check. Tap to review.* The user taps the notification, sees the same consent screen, and approves, without scanning a QR code. A broker login session lasts 180 seconds, and a short per-user cooldown stops repeated requests piling up on someone's lock screen. The QR code remains the fallback: when the request does not identify the user (including every direct-client login, whose `login_hint` is unverified), the device is not enrolled yet, or the push does not arrive. Your integration code is the same either way. Only the delivery of the session to the phone differs. ## Why a captured proof cannot be reused Everything in the flow is single-use and short-lived: - The proof commits to a **per-session server challenge**, signed inside the proof itself, so it cannot be replayed into any other login. The guarantee holds wherever the proof travels. - The login session lives 90 seconds, or 180 seconds for a broker login that can be pushed to a bound phone. The authorization code lives 60 seconds and can be redeemed once. The location assertion lives 120 seconds. - The proof must be fresh when LFA checks it (see [Policies](/concepts/policies#attestation-and-freshness)). Nothing in the flow can serve as a long-lived credential. ## Where to go next - [Policies](/concepts/policies). What "pass" means: country, attestation, freshness, and anti-spoof tier. - [Device lifecycle](/concepts/device-lifecycle). Enrollment, binding, new phones, lost phones. _Source: https://octetproof.com/docs/lfa/concepts/how-a-login-works/_ --- # Policies :::note[In one sentence] A policy is the named rule a login must satisfy: the country the user must be in, and which anti-spoof tiers pass. ::: Policies live on the LFA side, registered for your account by the Octet team alongside your OIDC clients. Your application never evaluates location data. It names a policy, and LFA answers pass or fail. --- ## What a policy contains When you ask Octet for a policy, you choose two things: - **The country.** The user must prove presence in this country, identified by its ISO 3166-1 alpha-2 code (for example `GB`). - **Which anti-spoof tiers pass.** By default only a `VERIFIED` proof passes. You can also let a `PLAUSIBLE` proof pass. See [Anti-spoof tier](#anti-spoof-tier). Every policy also applies the same fixed checks, which you do not configure: the device must be hardware-attested, and the proof must be fresh for this login. ## How a policy is selected - **Direct OIDC integrations** pass `octet_policy_id` on the `/authorize` request to pick a policy per login: one client, many policies. Omitting the parameter uses the client's pinned default. An unknown id is rejected. - **Brokers** (Okta and similar) pin the policy per registered client: one client per policy. A broker runs a fixed token exchange and does not set `octet_policy_id`, so each policy gets its own client. Enforcing UK presence for one app and US presence for another means two clients. ## Geofences LFA currently supports country-level geofences. The user proves presence in a country, identified by its ISO code, and the tokens assert exactly that. Finer regions (state or province, city, custom areas) are on the roadmap. They will not need changes to your integration. See [regions](/docs/mobile/concepts/regions/) for how a region is expressed and matched on the device. ## Attestation and freshness Every policy requires a proof rooted in the phone's secure hardware, carrying a per-login signature from the enrolled device key. iOS and Android both meet this (see [device attestation](/docs/mobile/concepts/device-attestation/)), and a passing login carries `acr: urn:octet:loc:strong`. A proof that does not meet it is denied. The proof must also be recent when LFA checks it, so a proof made for an earlier moment cannot pass. The location assertion LFA then issues is valid for 120 seconds. ## Anti-spoof tier Every proof carries an assurance tier, described in [verdicts](/docs/mobile/concepts/verdicts/). The policy sets which tiers pass: - **Default.** Only `VERIFIED` proofs pass. Use this for anything security-relevant. - **Opt-in.** `PLAUSIBLE` proofs also pass. This lowers both friction and assurance. Use it for flows where a false rejection costs more than accepting a less-corroborated proof. ## Where to go next - [Direct OIDC integration](/integration/direct-oidc). The `octet_policy_id` parameter in practice. - [How a login works](/concepts/how-a-login-works). Where the policy check sits in the flow. _Source: https://octetproof.com/docs/lfa/concepts/policies/_ --- # Device Lifecycle :::note[In one sentence] A user's first successful login binds their phone to their account. From then on only that phone can complete their location factor, until you replace it or revoke it. ::: Binding changes what LFA establishes from "a phone in the region" to "this user's phone in the region": presence and possession. Without binding, anyone with the app inside the geofence could complete anyone's location factor. With binding, the factor is pinned to the device the user enrolled. Each user has one bound device per client. --- ## Enrollment: the first login Enrollment requires no administration. During a user's first login: 1. The app creates a signing key in the phone's secure hardware and has the platform certify it to LFA: Apple App Attest on iOS, hardware key attestation on Android (device enrollment). 2. Your token exchange supplies [`octet_user_id`](/getting-started/quickstart), and LFA binds the enrolled device to that user. The first device for a user is bound automatically. Every later login checks that the device is the one bound to this user. As long as the user keeps the same phone, there is nothing further to do. :::note[Broker clients bind to a different identifier] The step above is the **direct** path: your OIDC client sends `octet_user_id` on the RP-authenticated token exchange. If you integrate through an identity broker such as Okta, the broker runs a fixed standard token exchange and cannot add `octet_user_id`. Instead, the binding identifier is the `login_hint` carried inside the broker's signed authorization request. LFA verifies that request against the broker's pinned keys, so the device binds to the authenticated hint, never a raw front-channel value. See [Okta Integration](/integration/okta) for setup. The rest of this page applies to both, with the device-replacement difference called out below. ::: ## Replacing a phone If the user still controls their account, let them replace the device themselves. After **your own re-verification step** (a step-up you trust), send one extra field on the token exchange: ```bash octet_device_enroll=true ``` LFA revokes the old device and binds the new one, atomically. Without the flag, a new device claiming an already-bound user is rejected (`invalid_grant`). That rejection is what blocks an account-takeover attempt from swapping in an attacker's phone, so gate the flag behind real verification, not a self-serve checkbox. :::caution[Broker clients cannot replace a device this way] `octet_device_enroll` is sent on the direct token exchange, and brokers cannot add fields to it, so enrollment is forced off for broker clients. When a broker user gets a new phone, their next login is rejected (`invalid_grant`) and stays rejected until you clear the old binding with [`POST /v1/device/revoke`](/reference/oidc#post-v1devicerevoke) (below). After the revoke, the next login binds the new phone automatically, with no flag and no further action. ::: ## A lost or stolen phone Revoke the binding server-side, immediately. No login flow is required. A direct client calls the endpoint itself with its client secret: ```bash curl -s https://factor.octetproof.com/v1/device/revoke \ -d client_id=YOUR_CLIENT_ID \ -d client_secret=YOUR_CLIENT_SECRET \ -d octet_user_id=usr_1234 ``` A successful call returns `{"revoked": true, "device_id": ""}`. If no active device is bound to that user, it returns `404 no_device`. See the [OIDC reference](/reference/oidc#post-v1devicerevoke) for every response. From that moment the revoked phone is denied at proof submission, regardless of where it is or what it can prove. When the user signs in on a replacement phone, it binds automatically. A revoked device no longer counts as a live one, so no `octet_device_enroll` flag is needed. For a broker client, ask Octet to revoke the binding. The endpoint accepts a client secret only, and a broker integration holds no client secret. Revocation is also the only device-recovery path for a broker, because brokers cannot send `octet_device_enroll`. Give Octet the identifier the device is bound to, which is the `login_hint` value from the signed request object. For a direct client, that identifier is your `octet_user_id`. ## What each rejection means | Error | Meaning | What to do | |---|---|---| | `invalid_request` (`octet_user_id is required`) | A direct token exchange omitted the user identifier | Send `octet_user_id` on every exchange | | `invalid_grant` (`this device is bound to a different user`) | The phone that proved presence belongs to another account | The user must use their own enrolled phone | | `invalid_grant` (`a new device requires enrollment`) | The user already has a bound device and this is a different one | **Direct:** re-verify the user, then retry with `octet_device_enroll=true`. **Broker:** ask Octet to revoke the old binding (`POST /v1/device/revoke`). The next login then binds the new phone | ## Scoping Each client keeps its own bindings, and the user identifiers you send are never visible to other clients. See [Trust & Privacy](/concepts/trust-and-privacy). ## Where to go next - [Quick Start](/getting-started/quickstart). Where `octet_user_id` goes. - [OIDC Reference](/reference/oidc#post-v1devicerevoke). The revoke endpoint's full contract. _Source: https://octetproof.com/docs/lfa/concepts/device-lifecycle/_ --- # Trust & Privacy :::note[In one sentence] The phone proves which region it is in without revealing where in that region it is, and LFA stores the minimum needed to keep the factor bound to the right device and user. ::: ## What never leaves the phone The location check runs entirely on the phone. What leaves the phone is a signed proof whose payload is a region verdict ("inside GB") plus what is needed to verify it. Coordinates and raw readings never leave the device. LFA never receives them, so it cannot disclose them. ## Why a verdict can be trusted The verdict carries no coordinates, so it is only as trustworthy as the chain that produces it. LFA verifies that chain fail-closed on every login: 1. **The proof is signed on-device**, produced by the Octet SDK's location and anti-spoof pipeline (the same proof-of-location machinery as the [Octet Mobile SDK](/docs/mobile/concepts/proof-of-location/)). 2. **The device is hardware-attested.** The signing key lives in the phone's secure hardware. At [enrollment](/concepts/device-lifecycle) the platform certifies, once, that the genuine Octet Location app created it on a genuine device: Apple App Attest on iOS, hardware key attestation on Android. LFA verifies the evidence against the platform's root certificates directly (Apple's for App Attest, Google's for Android key attestation). 3. **Every login is freshly signed, and the freshness is committed inside the proof.** The phone commits this session's one-time challenge inside the signed proof, identically on both platforms. Freshness therefore travels with the proof rather than alongside it: a captured proof cannot be replayed into another login even if it is stored, forwarded, or verified later and out of band. 4. **Any failed check is a denial.** Weak attestation, a stale proof, a spoof tier the [policy](/concepts/policies#anti-spoof-tier) does not accept, an unknown or revoked device: LFA denies rather than scoring the result. ## What LFA stores | Stored | What it is | Why | |---|---|---| | Device key + attestation record | The device's public key and its platform attestation (App Attest on iOS, key attestation on Android) | To verify each login's hardware signature | | Push token and device details | The phone's push token, platform, an optional display name, enrollment time, and last successful login time | To send a sign-in request to a bound phone | | Device-to-user binding | The per-client user identifier you supply (`octet_user_id` for a **direct** client, or the `login_hint` from the signed request object for a **broker** client) | To pin the factor to the user's own phone | | Revocation flag | Whether a device has been revoked | To deny lost or replaced phones | | Usage counts | Daily sign-in counts per client, by outcome. No user ID, device, or location | To count usage for billing | On a direct integration you choose the binding identifier, and LFA never sees a password, email, or profile unless you send one as the ID (send an opaque internal ID instead, see [Prerequisites](/getting-started/prerequisites)). On a broker path LFA stores the username your broker signs, which is often an email address. Bindings are scoped per client, so identifiers are never visible across clients and LFA builds no cross-application identity graph. The binding identifier reaches LFA one of two ways: - **Direct clients** send `octet_user_id` on the RP-authenticated `/token` call. This is the standard path. - **Broker clients** (such as Okta) run a fixed token exchange and cannot add `octet_user_id`, so they instead authenticate the front channel. Their `/authorize` request carries a signed request object, and the `login_hint` inside that verified object becomes the binding identifier. LFA never binds to the raw query-string `login_hint`. See [Okta integration](/integration/okta) and the [OIDC reference](/reference/oidc). :::note Either way, what LFA stores is a user identifier, with no coordinates, credentials, or profile. On a direct integration we recommend an opaque internal ID. ::: ## What LFA does not store - **Coordinates or location history.** Never received in the first place. - **User profiles or credentials.** Identity stays with your IdP. - **`login_hint` (direct clients).** Passed through into the id_token as `octet_login_hint`, not persisted. For broker clients this is different: the `login_hint` from the signed request object is the binding identifier and is stored, exactly as `octet_user_id` is for a direct client (see [What LFA stores](#what-lfa-stores)). It is still only an identifier, with no coordinates, credentials, or profile. - **Long-lived tokens.** The login session lives 90 seconds (180 seconds for a broker login), the authorization code 60 seconds, the location assertion 120 seconds. There are no refresh tokens. ## Where to go next - [Device lifecycle](/concepts/device-lifecycle). Enrollment, replacement, and revocation of the bound device. - [How a login works](/concepts/how-a-login-works). The full flow the chain above protects. _Source: https://octetproof.com/docs/lfa/concepts/trust-and-privacy/_ --- # Direct OIDC Integration LFA is a standard OIDC identity provider. Any stack that can federate to an external OIDC IdP, or perform an authorization-code flow, can consume LFA. You redirect to LFA, the user proves presence on their phone, and you get back an id_token whose claims carry the location verdict. The [Quick Start](/getting-started/quickstart) walks the wire protocol end to end. This page covers the integration decisions around it. --- ## The one non-standard requirement This is the **direct** path, where your app drives the OIDC flow itself. On this path your **token exchange must include `octet_user_id`**, your stable, opaque identifier for the user signing in. It drives [device-to-user binding](/concepts/device-lifecycle), and an exchange without it is rejected (`invalid_request`). :::note If you federate through an identity **broker** such as Okta, the broker runs a fixed token exchange and cannot add `octet_user_id`. That path is supported: the broker authenticates the front channel with a signed `/authorize` request instead, and the device binds to the verified `login_hint`. See [Okta](/integration/okta). The rest of this page assumes the direct path. ::: This requirement determines how you use your OIDC library. Any OIDC library can handle the `/authorize` redirect. For the token exchange, either: - your library lets you add a custom form field to the token request, so use it, or - perform the exchange yourself. It is one POST (see the [Quick Start](/getting-started/quickstart#3-exchange-the-code-with-octet_user_id)), and you can hand the resulting tokens back to your session layer. Your callback handler already has the identifier of the user who is mid-login, because you initiated the redirect from their session or your primary IdP's. ## Selecting a policy per login Pass `octet_policy_id` on `/authorize` to choose which registered [policy](/concepts/policies) this login must satisfy. One client can use many policies: ```bash &octet_policy_id=country_uk ``` Omit it to use your client's pinned default. An unknown id is rejected rather than falling back, so a typo cannot silently weaken the check. ## `login_hint` If you pass `login_hint` on `/authorize`, LFA echoes it back in the id_token as the namespaced, **unverified** `octet_login_hint` claim. It is useful for correlating the response with the login you started. On the direct path it is pure passthrough: LFA does not verify or persist it, does not bind to it (`octet_user_id` on `/token` is the binding identifier), and never reflects it into identity claims such as `email`. ## Protocol constraints A misconfigured request fails immediately rather than falling back to weaker behaviour: - PKCE **S256 is required**. `state` and `nonce` are **both mandatory**. - Scopes are `openid octet.location`. Extra scopes a library adds (`profile email`) are ignored, and no profile claims come back. - There are no refresh tokens. Run a new login when you need fresh proof of presence. - Authorization codes are single-use with a **60-second** redemption window. The full tables (endpoints, parameters, claims, errors) live in the [OIDC Reference](/reference/oidc). ## Gating on the result There are two ways to use the result: - **Simple gate.** Require `acr == "urn:octet:loc:strong"` in the id_token. That single check means hardware-attested presence with the policy passed. - **Audit or downstream verification.** Keep the `octet_location_assertion` claim, also mirrored as top-level `location_assertion` in the token response. It is a signed, 120-second statement of the region verdict, verifiable against the same JWKS. ## Client registration Clients are registered by the Octet team. Email [hello@octetproof.com](mailto:hello@octetproof.com) with your redirect URIs and the policies you need. See [Prerequisites](/getting-started/prerequisites). ## Where to go next - [Device lifecycle](/concepts/device-lifecycle). Binding, new phones, and revocation: the operational side of `octet_user_id`. - [OIDC Reference](/reference/oidc). Every endpoint, parameter, claim, and error. _Source: https://octetproof.com/docs/lfa/integration/direct-oidc/_ --- # Okta Integration LFA plugs into Okta as a **step-up location factor**. Okta remains the identity authority and proves who the user is. LFA proves where they are. The mechanism is Okta's **IdP Authenticator**: an external OIDC IdP registered with *IdP Usage = "Factor only"*, wrapped as an authenticator, and required by an app's authentication policy. :::note[Broker setups are configured with you] Broker integrations need a signing key exchanged and your issuer and request-signing key pinned on the LFA side, so this one is not self-service. Email [hello@octetproof.com](mailto:hello@octetproof.com) and we will validate the end-to-end flow against your Okta org before you enable it for users. ::: --- ## How the pieces map | Okta concept | LFA side | |---|---| | OIDC IdP, *Factor only* | LFA's `/authorize`, `/token`, JWKS endpoints | | Signed authorize request | A broker client Octet pins to your org (see below) | | IdP Authenticator | The "Octet UK presence" factor users see | | App authentication policy | Where the factor becomes required | | Username matching | The `preferred_username` claim, which is Okta's default (see below) | Okta acts as a **broker**: it runs a fixed, standard token exchange and cannot attach custom parameters to it. Two consequences shape the whole setup. First, Okta cannot send an `octet_policy_id`, so the [policy](/concepts/policies) is pinned per registered client. That means **one client per policy, and therefore one IdP per policy**. This guide uses a single `Octet UK presence` throughout as a running example. It is a name you choose, not a fixed value. To enforce a second region you repeat the whole setup (a new LFA client, a new Okta IdP, its own authenticator) named for that region (`Octet US presence`, and so on). Nothing below is shared between regions except the LFA endpoints. Second, because Okta cannot add its own fields to the token exchange, LFA authenticates the front channel instead. Your `/authorize` request is cryptographically signed, and LFA takes the identity of the user who is logging in from the signed request. The Okta-side settings below configure that signing. ## What Octet sets up for you A broker client is configured on the LFA side by the Octet team. You do not edit any server configuration yourself. **One client, one policy.** Each client we register is pinned to a single region [policy](/concepts/policies) on our side, as described above. Name all the policies you need in step 1 and we register a client for each. **RS256 id_tokens.** We set your broker client to sign id_tokens with RS256, because Okta does not accept ES256. You do not configure this in Okta. Setup takes **two exchanges with us**, and the order is forced: Okta generates the key that signs your requests only when you create the IdP, and you cannot create the IdP without the client ID we issue. | # | Who | What happens | |---|---|---| | 1 | **You** | Send your Okta org URL and the [policy](/concepts/policies) you need, for example "UK presence" | | 2 | **Octet** | Issues your `client_id` | | 3 | **You** | Create the IdP in Okta with it (below) and click **Finish** | | 4 | **You** | On the IdP page click **Download public key**, send us that JWK | | 5 | **Octet** | Pins the key and confirms the client is live | | 6 | **You** | Finish the remaining Okta screens and test | :::caution[Send the IdP's key, not your org's] Step 4 means the key on the **Identity Provider page**, generated for this IdP. It is not the org signing key at `https://.okta.com/oauth2/v1/keys`. Those are different keys, and Okta signs request objects with the IdP's key. If we pin the org key, every login fails verification with an error visible only in our logs. ::: Your **redirect URI** is not something you choose. Okta fixes it per org, and it is shown on the IdP page: ``` https://.okta.com/oauth2/v1/authorize/callback ``` Octet pins your issuer and that signing key to your broker client. From then on LFA accepts a signed `/authorize` from your org and rejects an unsigned or wrongly-signed one. :::caution[Notify Octet when you rotate signing keys] LFA verifies your requests against the key Octet has pinned. It does not fetch new keys automatically. If you regenerate this IdP's keypair, or delete and recreate the IdP, **send Octet the new public key first** (again via **Download public key**). Otherwise signed requests start failing verification the moment the old key stops being used, and the error appears only in our logs, not yours. ::: See [Prerequisites](/getting-started/prerequisites) for what to gather before you reach out. ## Okta-side configuration ### 1. Identity Provider **Security → Identity Providers → Add → OpenID Connect.** One per policy, using the client credentials Octet issues. | Setting | Value | |---|---| | *IdP Usage* | **Factor only** | | Client ID | The value Octet issued you | | *Authentication type* | **Public key / private key**, letting Okta generate the keypair | | *Enable signed requests* | **ON** (required) | | Algorithm | `RS256` | | *Require PKCE* | **ON** | | *Application context* | Off | | *IdP username* | **Leave at default** (see step 2) | | *JIT Settings → Profile Source* | **Off** | **Endpoints, to copy exactly.** Okta does not read the discovery document, so all five are typed in by hand. They are the same for every customer: | Okta field | Value | |---|---| | Issuer | `https://factor.octetproof.com` | | Authorization endpoint | `https://factor.octetproof.com/authorize` | | Token endpoint | `https://factor.octetproof.com/token` | | JWKS endpoint | `https://factor.octetproof.com/.well-known/jwks.json` | | Userinfo endpoint | `https://factor.octetproof.com/userinfo` | No trailing slashes, and the issuer has **no path**. You can check these against the live discovery document at any time: ```bash curl -s https://factor.octetproof.com/.well-known/openid-configuration | jq ``` Four of those settings need explanation. **Userinfo is not optional.** Okta builds the user profile from the **UserInfo response**, not the id_token. Leaving it blank fails the login. **Signed requests also change client authentication.** Turning them on switches Okta to `private_key_jwt` at the token endpoint, signed with the keypair Okta generates and verified by LFA against your pinned key. **There is no client secret to enter.** Okta hides the field in this mode, and Octet does not issue you one for a broker client. **RS256 is required.** Okta verifies only RS256 and rejects LFA's default ES256. **Leave JIT off.** JIT provisioning lets an external IdP create or update Okta user profiles. LFA is a factor, not an identity source, and Okta remains the authority on who the user is. It also has a practical consequence: LFA emits `email` with `email_verified: false`, because that value is the hint your org signed rather than an address we verified. With *Profile Source* enabled, that unverified value could overwrite the real email on the Okta user record. Left off, the claim is only ever a matching input. ### 2. Username matching **Leave *IdP Username* at its default.** A Factor-only IdP always matches on `idpuser.preferredUsername` and **ignores whatever you type in that field**. LFA emits `preferred_username` for broker clients so that the default matching works. You do **not** need a Profile Editor attribute, and a custom expression has no effect. If you set one, Okta still evaluates `preferredUsername`, and the login fails with a message that does not mention usernames at all. ### 3. The authenticator Okta offers the IdP to users only after it is registered as an authenticator. **Security → Authenticators → Setup tab → Add authenticator → Identity Provider.** Select the IdP you just made, and give it the name users will see at sign-in, for example `Octet UK presence`. Save. ### 4. Enrollment policy **Security → Authenticators → Enrollment tab** → edit the policy that covers your users (the Default Policy, unless you have added others) → set the new authenticator to **Optional**. Save. Without this the authenticator exists but no one can use it, and it does not appear in the policy picker in the next step. ### 5. The app policy This step makes the factor required at sign-in. You need **an app to sign in to** and **a policy that forces the factor** for that app. **a. An app.** If you are only testing, make a throwaway one: **Applications → Create App Integration → OIDC → Web Application.** Give it any redirect URI. A dead `http://localhost:8080/authorization-code/callback` is fine, and you never need a server there. Note two things from its **General** tab: the **Client ID**, and the **exact Sign-in redirect URI** Okta saved. It may differ from what you typed, so copy it verbatim. You need it to launch the test. With **Federation Broker Mode** on (the default for a new OIDC app), the app is implicitly assigned to all users and access is governed entirely by its sign-on policy, so there is no separate assignment step. If you turned that off, assign yourself under the app's **Assignments** tab. Otherwise the policy below never runs and the login silently skips the factor. **b. A policy that forces Octet.** **Security → Authentication Policies → Add a policy**, pick the **app sign-in policy** type (labelled *"OIDC"* or *"Okta Sign-On"*, **not** account-management). A new policy comes with one **Catch-all Rule**. Edit that rule rather than adding another: - *User must authenticate with*: **Password + Another factor** - *Possession factor constraints are*: **uncheck every box.** A new rule ships with **Device Bound** required, and an external-IdP factor is not device bound, so leaving it ticked silently removes Octet from the options. - *Authentication methods*: **Allow specific authentication methods**, and add exactly **Password** and **Octet UK presence**, nothing else. That last setting is what forces the factor, and it is easy to get wrong: :::caution[Do not leave the rule as "any 2 factors"] If the rule allows any second factor (the default), Okta satisfies it with whatever the user already has enrolled, usually Okta Verify, and **never prompts for Octet at all.** The login looks like 2FA while the location factor stays optional. Restricting *Authentication methods* to Password and Octet is what makes presence mandatory. ::: **c. Attach the app to the policy.** On the policy's **Applications** tab, **Add app** and pick the app from (a). A policy governs nothing until an app points at it. ### Setup checklist - The authenticator is **Active** on Authenticators → Setup. - Your test app is listed on the policy's **Applications** tab. - The rule's **Authentication methods** list is exactly *Password* and *Octet UK presence*, and every possession-factor constraint is **unchecked**. ## Testing the login Launch the app's sign-in directly at Okta. No running app is needed behind it. Build this URL, substituting your app's **Client ID** and the **exact** redirect URI from step 5a: ``` https://.okta.com/oauth2/v1/authorize?client_id=&response_type=code&scope=openid+profile+email&redirect_uri=&state=test&nonce=test ``` Open it in a **fresh private or incognito window**. Expected sequence: 1. Okta login: username and password. 2. **"Verify with Octet UK presence"**. On the first run Okta enrols it in the same step. If you instead see only Okta Verify and Password, the rule is not forcing the factor, so recheck step 5b. 3. The browser shows LFA's page with a QR code. Scan it with the Octet Location app, or tap the notification if your phone is already bound. The phone proves presence. 4. The browser lands on your dead redirect URI showing `…/callback?code=…&state=test`. **That final `?code=…` on a dead page is success.** It means Okta completed the whole exchange and issued an authorization code. The connection error is expected, because you never ran a server there. :::caution[After any policy change, start a brand-new sign-in] Okta snapshots the policy at the moment a sign-in begins and holds it for the whole flow. A tab you started before a change keeps the old rules no matter how many times you save. Always retest in a fresh private window, never the error screen's **Verify** button, which resumes the old transaction. ::: ## What travels where 1. **Okta → LFA `/authorize`.** Okta signs the request as a JWS request object (`request=`). LFA verifies it against the key pinned to your broker client. The issuer, audience, and expiry must check out, and the parameters inside the signed object take precedence over anything in the URL. The `login_hint` **inside** that signed object is the user LFA binds the device to. A signed request that carries **no** `login_hint` is rejected. 2. **Phone → LFA.** A signed location proof plus the phone's hardware attestation, carrying a **region verdict only, no raw coordinates**. See [Trust & Privacy](/concepts/trust-and-privacy). 3. **LFA → Okta.** An RS256 id_token (verifiable against LFA's JWKS) with: - `acr: urn:octet:loc:strong`, meaning hardware-attested presence with the policy passed. - `octet_location_assertion`, the signed region-verdict statement. - `preferred_username`, the hint from the signed request. This is what Okta matches the user on. - `octet_login_hint`, plus `email` with `email_verified: false` when the hint is an address. The same claims are returned from `/userinfo`, which is where Okta reads them. The device binds to the `login_hint` from the **signed** request, never a raw value from the browser's URL. A tampered front-channel request therefore cannot bind a victim's account: editing the hint breaks the signature, and the request is rejected. ## Device replacement and recovery A broker login binds the user's phone on first success, the same as any LFA login (see [Device Lifecycle](/concepts/device-lifecycle)). Because Okta cannot send device-enrollment fields on the token exchange, a broker user's **new or different phone is rejected** (`invalid_grant`) rather than silently rebinding. To move a user to a new phone, ask Octet to **revoke the binding** (`POST /v1/device/revoke`). The user's next login then binds the replacement phone automatically. ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | `/authorize` rejected as an unsigned or invalid request | Signed requests are off, or the key Octet pinned is not the one Okta is signing with | Turn **Enable signed requests** on. Otherwise re-send the key from **Download public key** on the IdP page, not `/oauth2/v1/keys`, which is a different key and a common mix-up | | **"Octet UK presence" never appears** at the verify screen, only Okta Verify and Password | The rule is not forcing it: it allows any 2 factors, a possession-factor constraint (Device Bound) is filtering the external-IdP factor out, or (with Federation Broker Mode off) you are not assigned to the app | Step 5b: set *Authentication methods* to **Allow specific → Password + Octet**, uncheck all possession constraints, and confirm the app is assigned | | `Authorization error: signed request object failed verification` on LFA's page | The public key on file does not match the one Okta is signing with | Re-send the key from **Download public key** on the IdP page so Octet can re-pin it. This mismatch does not show up in Okta's own logs | | `Authorization error: request object must carry login_hint` on LFA's page | The signed request reached LFA without a `login_hint` | Contact us with the `Authenticate user via IDP` entry from the System Log | | "Unable to verify authenticator", or "The UserInfo response is invalid" | Okta reports several unrelated failures with these two strings, and neither describes the real cause | Open **Reports → System Log**, expand the `Authenticate user via IDP` failure, and read `debugContext.debugData.errorMessage`. It names the exact check that failed. The summary text does not | | Changing *IdP Username* makes no difference | Expected. A Factor-only IdP ignores that field | Leave it at the default. Matching is always on `preferred_username` | | Login fails immediately after a config change, but the settings look right | An in-flight sign-in keeps the configuration it started with | Retest from a fresh private window, not the error screen's **Verify** button | | User's new phone rejected (`invalid_grant`) | The user is already bound to a previous device | Ask Octet to revoke the binding. The next login rebinds the new phone | | QR code or login expires before completion | The login session (180 s for broker logins) or the authorization code (60 s) lapsed | Restart the login. Presence proofs are deliberately short-lived | ## Where to go next - [Policies](/concepts/policies). What the pinned policy enforces. - [Device Lifecycle](/concepts/device-lifecycle). Binding, revocation, and new phones on the broker path. - [Direct OIDC integration](/integration/direct-oidc). The same endpoints without a broker, where your token exchange sends `octet_user_id` directly. _Source: https://octetproof.com/docs/lfa/integration/okta/_ --- # OIDC Reference The complete wire contract: endpoints, protocol requirements, parameters, claims, and errors. Discovery is live at `https://factor.octetproof.com/.well-known/openid-configuration`. --- ## Endpoints | Endpoint | URL | |---|---| | Issuer | `https://factor.octetproof.com` | | Authorization | `https://factor.octetproof.com/authorize` | | Token | `https://factor.octetproof.com/token` | | UserInfo | `https://factor.octetproof.com/userinfo` | | JWKS | `https://factor.octetproof.com/.well-known/jwks.json` | | Device revocation (LFA extension) | `https://factor.octetproof.com/v1/device/revoke` | ## Protocol requirements LFA implements only the authorization-code flow, with these requirements: | Requirement | Value | |---|---| | Response type | `code` only | | Grant | `authorization_code` only, with no refresh tokens | | PKCE | **S256 required.** An `/authorize` request without a code challenge is rejected | | `state` and `nonce` | **Both mandatory** (stricter than the OIDC spec, which only recommends `state`) | | Client authentication | At `/token`: `client_secret_post`, `client_secret_basic`, or `private_key_jwt` (RFC 7523 §2.2, broker clients, verified against the same pinned keys as the request object). `/v1/device/revoke` accepts a client secret only | | Authorization code | Single-use (enforced atomically), **60-second** redemption window | | id_token signing | **ES256 by default, RS256 available per client.** Both keys are published at the JWKS endpoint with distinct `kid`s, and the id_token header names the one that signed it. Ask us to switch your client to RS256 if your stack cannot verify EC signatures. Okta, for example, verifies only RS256 | | Scopes | `openid` and `octet.location`, the only scopes advertised. Extras a library adds (`profile email`) are ignored | | UserInfo | Accepts the access token only, which is valid for 120 seconds. An id_token or location assertion presented as a bearer token is rejected (401) | ## `/authorize` parameters Beyond the standard authorization-code parameters: | Parameter | Purpose | |---|---| | `octet_policy_id` | Selects which registered [policy](/concepts/policies) this login must satisfy. Omit for the client's pinned default. An unknown id is rejected | | `login_hint` | Optional and unverified, echoed back as the `octet_login_hint` claim | | `request` | A signed request object (RFC 9101 JAR). **Required for broker clients, ignored for direct clients.** See below | ### Signed request objects (brokers) Identity brokers, such as Okta's IdP-Authenticator "Factor only", run a fixed, standard token exchange and cannot add the `octet_user_id` that a direct client sends at `/token`. A broker instead authenticates the front channel: it signs its `/authorize` request as a JWS and passes it as `request=`, and the user it binds is taken from inside that signed object. A client is a broker client when Octet has registered your broker's issuer and request-signing public keys for it. This is fail-closed: once a client is a broker, an `/authorize` without a valid signed object is rejected. For a direct client a stray `request=` is ignored. | Check | Rule | |---|---| | Verification | Verified against the broker's **pinned** keys only, with no network key fetch. `RS256` and `ES256` only. `none` and `HS*` are rejected | | `iss` | Must equal the configured broker issuer | | `aud` | Must equal LFA's own issuer (`https://factor.octetproof.com`) | | `exp` / `nbf` | Must be valid, in a strict 3-segment JWS | | `client_id` | The object's `client_id` must equal the query `client_id` (RFC 9101 §5) | | `login_hint` | **Required inside the object.** Becomes the broker-authenticated binding identifier. An object with no `login_hint` is rejected | Parameters inside the signed object take precedence over the query string: `response_type`, `redirect_uri`, `scope`, `state`, `nonce`, the PKCE challenge and method, `acr_values`, and `octet_policy_id`. :::note The device binds only to the `login_hint` from the **signed** object, never the raw front-channel query `login_hint`. A browser that edits the query `login_hint` to a victim's id breaks the signature, so the request is rejected. See [Okta integration](/integration/okta) for the broker setup. ::: ## `/token` parameters Beyond the standard code-exchange parameters (`grant_type`, `code`, `redirect_uri`, client credentials, `code_verifier`): | Parameter | Purpose | |---|---| | `octet_user_id` | **Required for direct clients.** Your stable, opaque identifier for the user signing in. Drives [device-to-user binding](/concepts/device-lifecycle) | | `octet_device_enroll` | Optional. `true` replaces the user's bound device with the one that just proved presence, revoking the old device. Send only after your own step-up verification | There are two binding sources, depending on the client: - **Direct clients** send `octet_user_id` on the RP-authenticated `/token`, as documented above, and use `octet_device_enroll` for device replacement. - **Broker clients** bind on the `login_hint` from the signed request object at `/authorize` (see [Signed request objects](#signed-request-objects-brokers)) and **cannot** send `octet_user_id` or `octet_device_enroll`. Enrollment is forced off, so a new or different device for an already-bound user is rejected (`invalid_grant`). To recover, ask Octet to revoke the binding with `POST /v1/device/revoke`. The next login binds the new phone automatically. A broker session that reaches `/token` with no authenticated hint is rejected regardless of attestation. The token response: ```json { "token_type": "Bearer", "access_token": "…", "expires_in": 120, "id_token": "…", "location_assertion": "…" } ``` `location_assertion` is a top-level mirror of the `octet_location_assertion` claim, for stacks that do not want to open a nested JWT. ## id_token claims | Claim | Meaning | |---|---| | `iss` | `https://factor.octetproof.com` | | `sub` | The bound user id, returned verbatim: the `octet_user_id` you supplied at `/token` for a direct client, or the signed-request `login_hint` for a broker client | | `aud` | Your `client_id` | | `exp`, `iat` | Standard timestamps | | `auth_time` | When the location proof was accepted, which is the moment the user authenticated | | `nonce` | Echo of your `nonce`. Verify it | | `acr` | `urn:octet:loc:strong`, meaning the proof was hardware-attested (Apple App Attest on iOS, hardware key attestation on Android) and the policy passed | | `amr` | `["octet-loc"]` | | `token_use` | `id` (the access token carries `access`) | | `octet_login_hint` | Echo of the `login_hint`, if any. **Unverified** for a direct client (front-channel passthrough). For a broker client it is the signature-verified hint from the request object, the same value bound as `sub` | | `preferred_username` | **Broker clients only.** The signed hint, echoed back so account-matching works with default configuration. Not emitted for direct clients | | `email`, `email_verified` | **Broker clients only**, and only when the signed hint is an email address. `email_verified` is **always `false`**, because LFA verifies location rather than mailboxes. Do not treat it as a verified address | | `octet_location_assertion` | The signed location assertion (nested JWS) stating the region verdict | ### Why brokers get identity claims and direct clients do not A direct client's `login_hint` is an ordinary query parameter. Anyone can put any value there, so reflecting it into `email` or `preferred_username` would let a caller impersonate someone to their own RP. A **broker's** hint arrives inside a signature-verified request object, signed by a key LFA has pinned, and LFA returns it only to the party that signed it. LFA does not vouch for the identity. It returns the broker's own assertion, which is why `email_verified` stays `false`. ### `GET /userinfo` Returns the same identity claims as the id_token: `sub`, `octet_login_hint`, and (for brokers) `preferred_username`, `email`, `email_verified`. Some products build their user profile from **UserInfo rather than the id_token**, so the two return the same identity claims. Present the **access token** as a bearer token. An id_token or location assertion is rejected with 401. ## The location assertion A compact ES256 JWS, verifiable against the same JWKS, asserting the region verdict for this login. It expires **120 seconds** after issue and is bound to the login's nonce. It is a statement of presence at that moment, for audit use, not a credential. The subject is device-rooted (the enrolled device, not your user id), so the assertion can be verified downstream without carrying your user identifiers. ## `POST /v1/device/revoke` Revokes the device bound to one of your users. Use it when a phone is lost or stolen, and to move a broker user to a new phone. Direct clients call it themselves. Broker clients hold no client secret, so Octet calls it for them. See [Device lifecycle](/concepts/device-lifecycle). | | | |---|---| | Authentication | Client secret only (`client_secret_post` or `client_secret_basic`). `private_key_jwt` is not accepted here | | Body | `octet_user_id=`. For a direct client this is the `octet_user_id` you send at `/token`. For a broker client it is the `login_hint` value from the signed request object | | Effect | The user's bound device is denied from the next proof submission onward. Their next successful login on a new phone binds it automatically | | Response | `200 {"revoked": true, "device_id": ""}` | | Errors | `404 no_device` when no active device is bound to that user. `401 invalid_client` for bad credentials. `400 invalid_request` when `octet_user_id` is missing | | Scope | Per client. You can only revoke bindings under your own `client_id` | ## Errors | Error | Where | Meaning | |---|---|---| | Error page on LFA: `unknown client_id` | `/authorize` | The `client_id` is not registered. LFA shows its own page (HTTP 400) and does not redirect | | Error page on LFA: `redirect_uri not registered for this client` or `missing required parameter: redirect_uri` | `/authorize` | The `redirect_uri` must match a registered one exactly. Email [hello@octetproof.com](mailto:hello@octetproof.com) to add one | | Error page on LFA: `this client must send a signed request object` | `/authorize` | A broker client sent no `request=` object | | Error page on LFA: `signed request object failed verification` | `/authorize` | The object failed signature, `iss`, `aud`, `exp`, or `nbf` verification, or used an unsupported algorithm | | Error page on LFA: `request object client_id mismatch` | `/authorize` | The `client_id` inside the signed object does not match the query `client_id` (RFC 9101 §5) | | Error page on LFA: `request object must carry login_hint` | `/authorize` | A broker's signed object carried no `login_hint`, so there is no user to bind | | `invalid_request` (PKCE, `state`, or `nonce` missing) | `/authorize` | The request does not meet the [protocol requirements](#protocol-requirements) | | `invalid_scope` (`scope must include openid`) | `/authorize` | Add `openid` to the requested scopes | | `unsupported_response_type` | `/authorize` | Only `response_type=code` is supported | | `invalid_request` (`unknown octet_policy_id`) | `/authorize` | The policy id is not registered for your client. LFA does not fall back to another policy | | HTTP 429 (`try again shortly`) | `/authorize` | LFA could not start a login session. Retry after a short wait | | `access_denied` | callback redirect | The phone's proof failed verification or policy: wrong region, stale proof, spoof verdict below the floor, attestation failure, or revoked device | | `invalid_grant` (code expired or already redeemed) | `/token` | Codes are single-use and live 60 seconds | | `invalid_client` (HTTP 401) | `/token` | The client credentials are missing or wrong | | `invalid_grant` (`PKCE code_verifier does not match challenge`) | `/token` | The `code_verifier` is not the one whose S256 hash you sent as `code_challenge` | | `invalid_grant` (`redirect_uri mismatch`) | `/token` | The `redirect_uri` differs from the one sent at `/authorize` | | `invalid_grant` (`client_id does not own this code`) | `/token` | The code was issued to a different client | | `invalid_request` (`octet_user_id is required`) | `/token` | Exchanges from a direct client must carry the user identifier | | `invalid_grant` (`this device is bound to a different user`) | `/token` | The proving phone belongs to another account | | `invalid_grant` (`a new device requires enrollment`) | `/token` | The user already has a bound device. See [Device lifecycle](/concepts/device-lifecycle) | | `401` | `/userinfo` | The bearer token is not an access token (`token_use` mismatch) | _Source: https://octetproof.com/docs/lfa/reference/oidc/_ --- # FAQ & Troubleshooting Indexed by what you see. Errors at the token endpoint follow standard OAuth shapes (`error` plus `error_description`). Check the [service status page](https://api.octetproof.com/status) to see whether an Octet service is having problems. --- ## At the login page | Symptom | Cause and fix | |---|---| | QR page expires before the user finishes | The login session lives 90 seconds, or 180 seconds for a broker login. Restart the login, and have the phone ready | | Phone shows "Check complete" but the login fails | The phone shows the same screen whether or not the login passed. Your app gets the result: the browser returns with `error=access_denied`. Ask the user to confirm they are in the required region and retry | | Phone shows "Update required" | The app is too old to approve a sign-in. Update Octet Location from the App Store or Google Play, then scan again | | Browser returns `error=access_denied` | Any failed verification or policy check, including a revoked device. LFA denies when any check fails, so this does not indicate an outage | ## At `/authorize` | Symptom | Cause and fix | |---|---| | `invalid_request: PKCE S256 code_challenge is required` | Your library is not sending PKCE, or sends `plain`. Enable S256 | | `invalid_request: missing required parameter` | `state` or `nonce` is missing. Both are required, which is stricter than the OIDC spec. Configure your library to send both | | Unknown `octet_policy_id` rejected | Deliberate, because a typo must not fall back to a different policy. Check the id against your registered policies | ## At `/token` | Symptom | Cause and fix | |---|---| | `invalid_request: octet_user_id is required` | Every direct-client exchange must name the signing-in user. See the [Quick Start](/getting-started/quickstart#3-exchange-the-code-with-octet_user_id) | | `invalid_grant: this device is bound to a different user` | The phone that proved presence is enrolled to another account. The user must use their own phone | | `invalid_grant: a new device requires enrollment` | The user already has a bound device and just used a different one. Re-verify the user, then retry with `octet_device_enroll=true`. See [Device lifecycle](/concepts/device-lifecycle) | | `invalid_grant: code expired / already redeemed` | Codes are single-use with a 60-second window. Exchange immediately in the callback, and check for duplicate callback invocations | ## Common questions **Does the user have to enroll before the first login?** No. The first successful login enrolls the phone and binds it to the `octet_user_id` you send. It is one flow, slightly longer the first time. **How does a user switch phones?** Two paths. If the user still controls their account, use your own step-up verification plus `octet_device_enroll=true`. If the phone is lost or stolen, revoke the binding with [`/v1/device/revoke`](/reference/oidc#post-v1devicerevoke) and the next phone binds automatically. Direct clients call it themselves. Broker clients ask Octet to call it. See [Device lifecycle](/concepts/device-lifecycle). **Can I get the user's coordinates?** No. They never leave the phone. LFA can only tell you the region verdict. See [Trust & Privacy](/concepts/trust-and-privacy). **Why are there no refresh tokens?** A location proof is a statement about the present moment. If you need fresh presence, run a fresh login. The location assertion is valid for 120 seconds. **Why did my OIDC library's `profile email` scopes return nothing?** LFA advertises `openid octet.location` only and asserts no identity claims for direct clients. The extras are ignored rather than rejected. **Is Android supported?** Yes. The Octet Location app runs on iOS and Android, and both reach the same `urn:octet:loc:strong` assurance: iOS via Apple App Attest, Android via hardware key attestation chained to a Google root. Your integration is the same for both platforms, and the `acr` you receive is identical. The app is on the App Store and Google Play. **We use an identity broker such as Okta. Can we broker LFA?** Okta is supported today through signed authorization requests. Other brokers that sign requests (RFC 9101) should work. Contact us first. A broker cannot add `octet_user_id` on the fixed token exchange, so LFA authenticates the front channel instead: the broker signs its `/authorize` request as a signed request object (RFC 9101, "JAR"), and LFA binds the device on the `login_hint` inside that verified object. A client is a broker client when Octet has registered your broker's issuer and request-signing key for it. You send that public key along with your issuer URL. For Okta it is the key on the IdP page, not the org key. Device enrollment is forced off for brokers, so a broker user's new phone is recovered when Octet revokes the old binding with [`/v1/device/revoke`](/reference/oidc#post-v1devicerevoke), rather than with `octet_device_enroll`. See the [Okta guide](/integration/okta). :::note Broker setups are configured with us rather than self-served. A signing key is exchanged and your issuer and request-signing key are pinned on the LFA side. Contact [hello@octetproof.com](mailto:hello@octetproof.com) and we will validate the flow against your org before you enable it for users. ::: ## Brokered clients Signed request object errors appear on LFA's own error page (HTTP 400), with no redirect, because LFA cannot trust the redirect target until the signed request is verified. Your app never receives a callback. | Symptom | Cause and fix | |---|---| | LFA shows `this client must send a signed request object` | The client is a broker client, so a plain `/authorize` is rejected. Send the request as a signed object via `request=`. On Okta, this means **Enable signed requests** is off, so Okta sends no request object. Turn it on | | LFA shows `request object must carry login_hint` | The signed object is the only source of the binding identifier. Include `login_hint` inside the signed request object, not just the query string | | LFA shows `signed request object failed verification` | The key Octet has on file does not match the one your broker signs with, or the object failed an `iss`, `aud`, `exp`, or `nbf` check. See the [OIDC reference](/reference/oidc#signed-request-objects-brokers) | | Login fails at username matching | Leave *IdP Username* at its default. A Factor-only IdP always matches on `preferred_username`, which LFA emits for broker clients, and it ignores any custom expression you set. If it still fails, read `debugContext.debugData.errorMessage` in the System Log. The visible error text names something other than the actual cause | | `invalid_grant` with a new device rejected for a broker user | Brokers cannot enroll a replacement device. Ask Octet to revoke the old binding with [`/v1/device/revoke`](/reference/oidc#post-v1devicerevoke). The next login binds the new phone | :::note Signature verification is against **pinned** keys only. LFA does not fetch your JWKS over the network. Octet pins the key you send. Send Octet the new public key before you rotate, or valid logins will start failing. Only `RS256` and `ES256` are accepted. ::: _Source: https://octetproof.com/docs/lfa/troubleshooting/faq/_ --- =================== MAGISTRATE =================== # Magistrate Magistrate turns a location rule into a decision you can check. You describe the rule in the [Magistrate studio](https://magistrate.octetproof.com/), for example "only users in Austria" or "nobody in a country under embargo". Magistrate decides each request against that rule, using location proofs from the Octet Mobile SDK, and returns a signed answer. :::warning[You enforce the decision] Octet decides and signs. It does not block, allow or change anything in your app or on your servers. Your backend verifies the signed answer and decides what happens to the request. If your backend ignores the answer, nothing is enforced. ::: ## The parts | Part | What it is | Who uses it | |---|---|---| | **Magistrate studio** | The web app at [magistrate.octetproof.com](https://magistrate.octetproof.com/) where you write, review and sign a policy | Your product, risk or compliance owner | | **OPL** | The Octet Policy Language. Every policy the studio writes is an OPL policy, and you can read it in the studio's code pane | Anyone who reviews a policy | | **Octet policy engine** | Octet's service that holds your signed policy and decides each request against it | Nobody directly. The Magistrate module and your backend talk to it | | **Magistrate module** | A library for iOS and Android that sits on top of the Octet Mobile SDK and asks the engine for a decision | Your app developers | | **Decision receipt** | The engine's signed copy of each decision | Your backend developers | ## The answer Each policy asks one question about where the device is, such as "Is the device in Austria?". The studio shows the answer as **YES**, **NO** or **UNKNOWN**. The engine returns it as `permit` or `deny`, and adds a `cause` when the location could not be settled. [Decision Response](/reference/decision) shows how the two map. UNKNOWN is never a guess. When the proofs can't settle the question, the policy decides with its fail-safe answer, which is `deny` for every policy the studio writes. ## Get started 1. [Write a policy in Magistrate](/get-started/write-a-policy). 2. Add the Magistrate module to your [iOS](/get-started/ios) or [Android](/get-started/android) app. 3. [Verify and enforce the decision](/get-started/verify-and-enforce) on your backend. To read or review a policy, start with the [OPL overview](/opl/overview). For how a request travels through the system, see [How It Works](/how-it-works). :::note[Preview] The Magistrate module for iOS and Android is in preview. The API on these pages is the one version 0.1.0 will ship. ::: _Source: https://octetproof.com/docs/magistrate/_ --- # How It Works This page follows a policy from the studio to your app, and one decision from the device to your backend. ## The components ```mermaid flowchart LR W[You] -->|describe, review, sign| S[Magistrate studio] S -->|publish| E[Octet policy engine] E -->|1 signed policy bundle| A[Your app
Octet Mobile SDK
+ Magistrate module] A -->|2 location proofs| E E -->|3 decision + signed receipt| A A -->|4 receipt| K[Your backend
verifies and enforces] J[Octet public keys
/.well-known/] -.->|verify the receipt| K ``` - **The studio** turns what you describe into an OPL policy. When you sign it off, it publishes the policy to the engine. - **The engine** compiles the policy and signs it as a **policy bundle**. It decides every request, and signs each decision as a **receipt**. - **The Magistrate module** in your app checks that the bundle is signed by Octet, works out which location proofs the policy needs, asks the Octet Mobile SDK for them, and sends them to the engine. - **Your backend** verifies the receipt with Octet's public key and enforces the decision. ## Publishing a policy 1. You describe the rule in the studio, or start from a template. 2. The studio writes the policy in OPL and shows each rule as a plain sentence. 3. You approve every sentence and sign off with your name. 4. **Sign and send** publishes the policy. The engine checks it, compiles it and signs it with Octet's policy signing key. 5. Your Octet contact connects the policy to your license. 6. The engine always decides with the newest version. Apps fetch it the next time they check. A device never runs a policy it can't verify. If the bundle's signature, format or version is wrong, the module refuses it and no decision is made. ## One decision, in order ```mermaid sequenceDiagram participant A as Your app (Magistrate module) participant E as Octet policy engine participant K as Your backend A->>E: GET /v1/bundle (signed policy) A->>A: verify the bundle, read which proofs it needs A->>E: POST /v1/decide/challenge E-->>A: { decision_ref, nonce } A->>A: Octet SDK makes the proofs, bound to the nonce A->>E: POST /v1/decide { decision_ref, proofs } E->>E: verify the proofs, decide the policy E-->>A: { decision, reasons, obligations, receipt } A->>K: the receipt, with the user's request K->>K: verify the receipt, then enforce ``` 1. The module fetches the policy bundle and checks Octet's signature on it. 2. It asks the engine for a one-time challenge. The challenge expires after a few minutes and works once. 3. The Octet Mobile SDK makes the location proofs the policy needs, and binds each one to the challenge. A proof made for another request can't be reused. 4. The engine verifies the proofs, decides the policy and signs the decision. 5. Your app sends the receipt to your backend with the request it protects. 6. Your backend verifies the receipt and decides what happens. The module runs steps 1 to 4 in one call, `decide`. ## Where the decision is made The decision is made on Octet's servers, never on the device. The device only verifies the policy's signature and makes proofs. Anything the device computed could be changed by whoever holds it, so the answer that counts is the one the engine signs. ## What the device discloses The proofs say which country or state the device is in, and for a policy with a geofence, whether the device is inside that area. The decision and the receipt never carry coordinates. ## What Octet keeps Octet doesn't give you a way to fetch a receipt later, so store each receipt you act on, with the request it decided. _Source: https://octetproof.com/docs/magistrate/how-it-works/_ --- # Write a Policy in Magistrate The [Magistrate studio](https://magistrate.octetproof.com/) writes the policy for you. You say what you are trying to solve, review what it wrote, and sign it off. You don't need to write OPL yourself. ## 1. Start from a template or describe the rule Open [magistrate.octetproof.com](https://magistrate.octetproof.com/) and pick a starting point: | Template | What it starts with | |---|---| | Country access | One or more countries to include or exclude | | US state access | The United States, with states to include or exclude | | OFAC embargo | Cuba, Iran, North Korea, Crimea, Sevastopol, Donetsk and Luhansk | | EU sectoral sanctions | Iran, North Korea, Russia, Belarus, Crimea, Sevastopol, Donetsk, Luhansk, Kherson and Zaporizhzhia | | UK sectoral sanctions | The same places as the EU list | | OFAC + EU + UK | All three lists together | Each sanctions list shows the month it was last reviewed. Russia and Belarus are covered as whole countries, and the Ukrainian regions as whole regions, which goes further than the sanctions require. Check each list against your own obligations before you sign it off. Or describe the rule in the chat, in your own words, for example "Only users in Germany, Austria and Switzerland" or "Everyone in the US except New Jersey". You can type or dictate. Keep refining in the chat until the policy says what you mean. Today the studio writes policies about **countries** and **US states**, and the named sanctions lists above. Each place is either included or excluded. ## 2. Review the policy The studio shows the policy three ways: - **As sentences.** Each rule in plain English, for example "The device is in Austria". - **As a map.** The places the policy includes and excludes, with the question and its answers. - **As OPL.** The exact policy text the engine will decide, in the code pane. See [OPL Overview](/opl/overview) to read it. Approve each sentence. If one is wrong, go back to the chat and change it. ## 3. Read the question Every policy asks one question about where the device is, for example: - "Is the device in Iran?" - "Is the device in the United States, outside Puerto Rico?" Octet answers it for every request with **YES**, **NO** or **UNKNOWN**. Decide now what your backend does with each answer. UNKNOWN means the proofs couldn't settle the question, for example because the device has no current location fix. The engine answers it with `deny`. ## 4. Sign and send Sign and send needs every sentence approved, your name, and your confirmation that you have the authority to ship the policy. It then publishes the policy: 1. The engine checks the policy. A policy it can't decide is refused. 2. The engine compiles the policy and signs it as a policy bundle. 3. Apps with the Magistrate module pick up the signed policy the next time they fetch it. Each publish is a new version, and the engine always decides with the newest one. A device that still holds the older version may make proofs the new version can't use. It then gets UNKNOWN until it fetches the new version. ## 5. Ship The Ship page gives you what your developers need: - the policy's question and its three answers - a code snippet for the Magistrate module - the policy as a `.opl` file - a link to get the Octet Mobile SDK, if you don't have it yet ## 6. Connect the policy to your license Your Octet contact connects the published policy to your Octet license. Get the SDK through the Ship page's link, so Octet can find your policy. Until the policy is connected, the Magistrate module throws `notProvisioned`. Next, add the Magistrate module to your [iOS](/get-started/ios) or [Android](/get-started/android) app. _Source: https://octetproof.com/docs/magistrate/get-started/write-a-policy/_ --- # Add Magistrate to Your iOS App The Magistrate module sits on top of the Octet Mobile SDK. One call, `decide`, fetches your policy, makes the location proofs it needs, and returns the engine's signed decision. :::note[Preview] The Magistrate module is in preview. The API on this page is the one version 0.1.0 will ship. It works with Octet SDK 3.0.0 or later, and sandbox builds need 3.1.0 or later. The packages below become available with the 0.1.0 release, which follows Octet SDK 3.1.0. ::: ## Prerequisites - iOS 16 or later, Swift 5.9 or later. - The Octet Mobile SDK set up in your app, with the App Attest capability and the location and motion usage descriptions in `Info.plist`. Follow the [iOS Quickstart](/docs/mobile/getting-started/ios-quickstart/) first. - A policy published from the [Magistrate studio](/get-started/write-a-policy). ## 1. Add the packages Add both Swift packages to your app. Swift Package Manager doesn't pass the Octet SDK through Magistrate, so your app adds it as well. Magistrate needs Octet SDK 3.0.0 or later. ```swift dependencies: [ .package(url: "https://github.com/octetproof/octet-magistrate-ios", exact: "0.1.0"), .package(url: "https://github.com/octetproof/octet-sdk-ios", from: "3.0.0"), ], targets: [ .target(name: "YourApp", dependencies: [ .product(name: "Magistrate", package: "octet-magistrate-ios"), .product(name: "OctetSDK", package: "octet-sdk-ios"), ]), ] ``` ## 2. Start the SDK and wrap it ```swift import OctetSDK import Magistrate let config = OctetConfig(licenseKey: "") // required by the type, ignored by start let sdk = try await Octet.start(config: config) let magistrate = Magistrate(sdk: sdk) ``` To keep the verified policy across launches, pass a store: ```swift let magistrate = Magistrate(sdk: sdk, persistentStore: KeychainPolicyBundleStore()) ``` ## 3. Ask for a decision Call `decide` when the user does something your policy protects, such as opening an account or placing a bet. ```swift guard let license = sdk.licenseStatus else { return } do { let result = try await magistrate.decide(license: license) if result.cause != nil { // UNKNOWN: the location couldn't be settled, for example no fix yet. // Ask the user to retry. } // Send result.receipt to your backend with the request. // Your backend verifies it and decides what happens. try await api.submit(order, decisionReceipt: result.receipt) } catch let error as PolicyError { // No decision was made. Treat it as NO. } ``` `decide` runs the whole exchange: it fetches the policy, checks Octet's signature on it, gets a one-time challenge, has the SDK make the proofs, and sends them to the engine. Don't manage the challenge yourself, unless your backend starts the check (see [Start the check from your backend](/get-started/verify-and-enforce#start-the-check-from-your-backend)). A `deny` is a result, not an error. `result.isPermit` is `false` and `result.reasons` says why. When `result.cause` is set, the answer is UNKNOWN: the proofs couldn't settle the location. A `PolicyError` means no decision was made at all. See [Decision Response](/reference/decision#yes-no-and-unknown) to read a result as YES, NO or UNKNOWN. :::warning[The app is not where you enforce] `result.isPermit` is fine for the screen you show next, but a user can change anything that runs on their device. Your backend enforces the decision, from the verified receipt. See [Verify and Enforce](/get-started/verify-and-enforce). ::: ## Several policies in one check `decideMany` decides up to 16 policies with one set of proofs. Each result carries its policy's name. ```swift let set = try await magistrate.decideMany(license: license, policies: ["p_…", "p_…"]) for decision in set.results { print(decision.policy, decision.decision) } ``` ## Obligations A policy can attach obligations to a `permit`, for example a step-up authentication. A permit with obligations counts only once your app and backend have fulfilled every one. Treat an obligation you don't recognise as NO. See [Decision Response](/reference/decision#obligations). ## Things to know - **The first check after start.** Location takes a moment to warm up after `Octet.start`. The first check can come back UNKNOWN, with `cause` `absence`. Retry after a few seconds. - **Real devices for production.** Production needs hardware attestation on a real device. A simulator or a debug build signs in with a sandbox token (see the [iOS Quickstart](/docs/mobile/getting-started/ios-quickstart/)), and its results and receipts say `env` `sandbox`. Your backend accepts only `prod` in production. Magistrate checks from a sandbox build need Octet SDK 3.1.0 or later, and a sandbox app registered on your license. Production apps don't. - **Geofence policies.** A policy with a `disc` needs an Octet SDK release that can prove one. Until then `decide` throws `bindingUnavailable` for it. Policies from the Magistrate studio don't use discs. - **Errors.** See [Errors](/reference/errors) for each `PolicyError` case. ## Sample app The [sample app](https://github.com/octetproof/octet-magistrate-ios/tree/main/sample) starts the SDK, calls `decide` and shows the decision, reasons and obligations. It needs a real device. _Source: https://octetproof.com/docs/magistrate/get-started/ios/_ --- # Add Magistrate to Your Android App The Magistrate module sits on top of the Octet Mobile SDK. One call, `decide`, fetches your policy, makes the location proofs it needs, and returns the engine's signed decision. :::note[Preview] The Magistrate module is in preview. The API on this page is the one version 0.1.0 will ship. It works with Octet SDK 3.0.0 or later, and sandbox builds need 3.1.0 or later. The packages below become available with the 0.1.0 release, which follows Octet SDK 3.1.0. ::: ## Prerequisites - Android `minSdk` 30 or later, the Octet Mobile SDK's minimum. - The Octet Mobile SDK set up in your app, with the `ACCESS_FINE_LOCATION` permission. Follow the [Android Quickstart](/docs/mobile/getting-started/android-quickstart/) first. - A release-signed build on a real device for production. A debug build or an emulator needs a sandbox token. - A policy published from the [Magistrate studio](/get-started/write-a-policy). ## 1. Add the dependency Add the Magistrate Maven repository and the dependency. Magistrate brings in the Octet SDK (`com.octetproof:sdk`, 3.0.0 or later) for you. ```kotlin // settings.gradle.kts dependencyResolutionManagement { repositories { maven("https://raw.githubusercontent.com/octetproof/octet-magistrate-android/mvn-repo") } } // app/build.gradle.kts dependencies { implementation("com.octetproof:magistrate:0.1.0") } ``` ## 2. Start the SDK and wrap it ```kotlin import com.octetproof.magistrate.Magistrate import com.octetproof.sdk.api.Octet import com.octetproof.sdk.api.OctetConfig val config = OctetConfig(licenseKey = "") // required by the type, ignored by start val sdk = Octet.start(context = applicationContext, config = config) val magistrate = Magistrate(sdk) ``` To keep the verified policy across launches, pass a store: ```kotlin val magistrate = Magistrate(sdk, persistentStore = EncryptedPrefsPolicyBundleStore(context)) ``` ## 3. Ask for a decision Call `decide` when the user does something your policy protects, such as opening an account or placing a bet. It is a `suspend` function. ```kotlin val license = sdk.licenseStatus ?: return try { val result = magistrate.decide(license) if (result.cause != null) { // UNKNOWN: the location couldn't be settled, for example no fix yet. // Ask the user to retry. } // Send result.receipt to your backend with the request. // Your backend verifies it and decides what happens. api.submit(order, decisionReceipt = result.receipt) } catch (e: PolicyException) { // No decision was made. Treat it as NO. } ``` `decide` runs the whole exchange: it fetches the policy, checks Octet's signature on it, gets a one-time challenge, has the SDK make the proofs, and sends them to the engine. Don't manage the challenge yourself, unless your backend starts the check (see [Start the check from your backend](/get-started/verify-and-enforce#start-the-check-from-your-backend)). A `deny` is a result, not an error. `result.isPermit` is `false` and `result.reasons` says why. When `result.cause` is set, the answer is UNKNOWN: the proofs couldn't settle the location. A `PolicyException` means no decision was made at all. See [Decision Response](/reference/decision#yes-no-and-unknown) to read a result as YES, NO or UNKNOWN. :::warning[The app is not where you enforce] `result.isPermit` is fine for the screen you show next, but a user can change anything that runs on their device. Your backend enforces the decision, from the verified receipt. See [Verify and Enforce](/get-started/verify-and-enforce). ::: ## Several policies in one check `decideMany` decides up to 16 policies with one set of proofs. Each result carries its policy's name. ```kotlin val set = magistrate.decideMany(license, policies = listOf("p_…", "p_…")) set.results.forEach { println("${it.policy} ${it.decision}") } ``` ## Obligations A policy can attach obligations to a `permit`, for example a step-up authentication. A permit with obligations counts only once your app and backend have fulfilled every one. Treat an obligation you don't recognise as NO. See [Decision Response](/reference/decision#obligations). ## Things to know - **The first check after start.** Location takes a moment to warm up after `Octet.start`. The first check can come back UNKNOWN, with `cause` `absence`. Retry after a few seconds. - **Real devices for production.** Production needs hardware attestation on a real device with a release-signed build. An emulator or a debug build signs in with a sandbox token (see the [Android Quickstart](/docs/mobile/getting-started/android-quickstart/)), and its results and receipts say `env` `sandbox`. Your backend accepts only `prod` in production. Magistrate checks from a sandbox build need Octet SDK 3.1.0 or later, and a sandbox app registered on your license. Production apps don't. - **Geofence policies.** A policy with a `disc` needs an Octet SDK release that can prove one. Until then `decide` throws `BindingUnavailable` for it. Policies from the Magistrate studio don't use discs. - **Errors.** See [Errors](/reference/errors) for each `PolicyException` case. ## Sample app The [sample app](https://github.com/octetproof/octet-magistrate-android/tree/main/sample) starts the SDK, calls `decide` and shows the decision, reasons and obligations. It needs a real device and a release-signed build. _Source: https://octetproof.com/docs/magistrate/get-started/android/_ --- # Verify and Enforce the Decision Octet decides and signs. Your backend enforces. This page shows how your backend checks a decision receipt and turns it into what happens to the request. ## Why the backend, not the app The app gets the decision first, but a user controls their own device. They can change the app, replay an old answer or skip the check. The receipt is signed by the Octet policy engine with a key the device never has, so your backend can tell a real, current decision from anything else. ## 1. Send the receipt with the request Have the app send `receipt` from the `DecisionResult` together with the request it protects, for example in the body of the order or the sign-up call. A receipt is valid for 5 minutes after the engine signs it, so ask for the decision right before the request. ## 2. Know which decision you expect The receipt's `decision_ref` identifies the check it answers, and `policy` names the policy that decided it. Your backend checks both, so a permit from another check or another policy can't be sent instead. Have the app send `decisionRef` with the request, and accept each `decision_ref` only once. Together with the checks below, that refuses an old or reused receipt. Your backend can also start the check itself, so the decision comes to your backend directly and never passes through the app. See [Start the check from your backend](#start-the-check-from-your-backend). ## 3. Verify the receipt A receipt is a compact JWS signed with Ed25519 (`alg` `EdDSA`, `typ` `octet-decision-receipt+jwt`). The engine's public keys are at: ``` https://gw.octetproof.com/.well-known/decision-receipt-jwks.json ``` Each key has a `kid`, a `valid_from` and, once it is replaced, a `valid_until`, all in Unix seconds. A receipt is valid when: 1. its signature verifies with the key whose `kid` is in the receipt's header 2. it hasn't expired (`exp`) 3. it was signed inside that key's window. A replaced key still counts for 300 seconds after its `valid_until` 4. `iss` is `octet-policy-engine` 5. `decision_ref` is the one you expected, `policy` is the policy that protects this request, and `license_id` is yours 6. `env` is `prod`. A receipt without `env` is `prod`. A `sandbox` receipt comes from a simulator, an emulator or a debug build. Accept it only in your test environment Uses [`jose`](https://github.com/panva/jose). ```javascript import { decodeProtectedHeader, importJWK, jwtVerify } from "jose"; const JWKS_URL = "https://gw.octetproof.com/.well-known/decision-receipt-jwks.json"; const GRACE_S = 300; // a replaced key still verifies for 5 minutes after valid_until let jwksCache = null; async function receiptKey(kid) { if (!jwksCache || !jwksCache.keys.some((k) => k.kid === kid)) { jwksCache = await (await fetch(JWKS_URL)).json(); // refetch on an unknown kid } const jwk = jwksCache.keys.find((k) => k.kid === kid); if (!jwk) throw new Error("unknown receipt key"); return jwk; } // Returns the receipt's claims, or throws. decisionRef is the one your // backend expected for this request, policy is the policy that protects it, // licenseId is your license id. export async function verifyReceipt(receipt, { decisionRef, policy, licenseId, getKey = receiptKey }) { const { kid } = decodeProtectedHeader(receipt); const jwk = await getKey(kid); const { payload: c } = await jwtVerify(receipt, await importJWK(jwk, "EdDSA"), { algorithms: ["EdDSA"], typ: "octet-decision-receipt+jwt", issuer: "octet-policy-engine", }); // checks the signature and exp if (c.iat < jwk.valid_from || (jwk.valid_until && c.iat > jwk.valid_until + GRACE_S)) { throw new Error("receipt signed outside its key's window"); } if (c.decision_ref !== decisionRef) throw new Error("receipt is for another request"); if (c.policy !== policy) throw new Error("receipt is for another policy"); if (c.license_id !== licenseId) throw new Error("receipt is for another license"); if ((c.env ?? "prod") !== "prod") throw new Error("sandbox receipt"); return c; } // The request may go ahead only on a permit without a cause (a cause means // UNKNOWN), whose every obligation you fulfilled. export function mayProceed(claims, fulfil) { return claims.decision === "permit" && claims.cause == null && (claims.obligations ?? []).every(fulfil); } ``` Uses [PyJWT](https://pyjwt.readthedocs.io/) with the `cryptography` package. ```python import json import urllib.request import jwt # PyJWT, with the cryptography package for EdDSA JWKS_URL = "https://gw.octetproof.com/.well-known/decision-receipt-jwks.json" GRACE_S = 300 # a replaced key still verifies for 5 minutes after valid_until _jwks = None def receipt_key(kid): global _jwks if _jwks is None or not any(k["kid"] == kid for k in _jwks["keys"]): with urllib.request.urlopen(JWKS_URL) as r: # refetch on an unknown kid _jwks = json.load(r) for k in _jwks["keys"]: if k["kid"] == kid: return k raise ValueError("unknown receipt key") def verify_receipt(receipt, *, decision_ref, policy, license_id, get_key=receipt_key): """Returns the receipt's claims, or raises. decision_ref is the one your backend expected for this request, policy is the policy that protects it, license_id is your license id.""" header = jwt.get_unverified_header(receipt) if header.get("typ") != "octet-decision-receipt+jwt": raise ValueError("not a decision receipt") jwk = get_key(header.get("kid")) c = jwt.decode( receipt, jwt.PyJWK(jwk, algorithm="EdDSA").key, algorithms=["EdDSA"], issuer="octet-policy-engine", options={"require": ["exp", "iat"]}, ) # checks the signature and exp if c["iat"] < jwk["valid_from"] or ( jwk.get("valid_until") and c["iat"] > jwk["valid_until"] + GRACE_S ): raise ValueError("receipt signed outside its key's window") if c.get("decision_ref") != decision_ref: raise ValueError("receipt is for another request") if c.get("policy") != policy: raise ValueError("receipt is for another policy") if c.get("license_id") != license_id: raise ValueError("receipt is for another license") if c.get("env", "prod") != "prod": raise ValueError("sandbox receipt") return c def may_proceed(claims, fulfil): """The request may go ahead only on a permit without a cause (a cause means UNKNOWN), whose every obligation you fulfilled.""" return ( claims["decision"] == "permit" and claims.get("cause") is None and all(fulfil(o) for o in claims.get("obligations", [])) ) ``` ## 4. Enforce Decide from the verified claims, and only from them: | Claims | What it means | What your backend does | |---|---|---| | `cause` is present, whatever the `decision` | UNKNOWN: the proofs couldn't settle the location, so the policy used its fail-safe answer | Your choice. Often: ask the user to try again with location on | | `decision` is `permit`, no `obligations` | The policy's conditions hold | Let the request go ahead | | `decision` is `permit`, with `obligations` | The policy's conditions hold if you also do something, such as a step-up authentication | Go ahead only once you fulfilled every obligation. Treat one you don't recognise as not fulfilled | | `decision` is `deny`, no `cause` | A rule or the default decided against the request | Your choice: refuse, review or log it | | No receipt, or it doesn't verify | No decision | Treat it as `deny` | `mayProceed` and `may_proceed` above let a request go ahead only on a `permit` with no `cause` and every obligation fulfilled. Everything else is your policy, not Octet's. See [Decision Response](/reference/decision) for `cause` and `obligations`, and how the studio's YES, NO and UNKNOWN map to these claims. ## 5. Keep the receipt Octet doesn't give you a way to fetch a receipt later, so store each receipt you act on, with the request it decided. It stays checkable against the public key, and `/.well-known/decision-receipt-key-history.json` lists every receipt key the engine has used, so a receipt still verifies after its key is replaced. ## Start the check from your backend Instead of calling `decide` in the app, your backend can run the check itself. The app only makes the proofs, and the decision comes back to your backend. **1. Your backend asks for a challenge.** ``` POST https://gw.octetproof.com/v1/decide/challenge { "policy": "p_…" } ``` The answer has `decision_ref`, `nonce` and `expires_at`. Keep `decision_ref`, and send `nonce` to the app. **2. The app makes the proofs for that nonce**, and sends them to your backend with its license ID and its enrolment. ```swift let proofs = try await magistrate.prove(license: license, nonce: nonce) let enrolment = magistrate.attestationEnrolment() // a JSON string, or nil try await api.submitProofs(licenseId: license.licenseId, proofs: proofs.compactMap(\.proofBytesBase64URL), enrolment: enrolment) ``` ```kotlin val proofs = magistrate.prove(license, nonce) val enrolment = magistrate.attestationEnrolment() // always null on Android api.submitProofs(licenseId = license.licenseId, proofs = proofs.mapNotNull { it.proofBytesBase64Url }, enrolment = enrolment) ``` **3. Your backend asks for the decision.** ``` POST https://gw.octetproof.com/v1/decide { "policy": "p_…", "license_id": "…", "decision_ref": "…", "proofs": [{ "proof_bytes_b64": "…" }], "attestation_enrolment": { … } } ``` Send `attestation_enrolment` as a JSON object, the app's string parsed, not as a quoted string. Leave it out when the app sent none. The answer is a [decision response](/reference/decision) with its receipt. Verify and enforce it as in steps 3 to 5, with the `decision_ref` your backend kept. **The enrolment on iPhone.** An iPhone's first check needs the enrolment. A proof carries only the device's App Attest assertion, and the engine learns the device's key from the enrolment, once. Without it, a first-time iPhone's check comes back `deny` with a `cause`, which is UNKNOWN. Send it every time: the engine ignores it for a device it already knows. On Android, `attestationEnrolment()` always returns null, because every proof carries its own hardware key chain, so one backend flow serves both. `decide` and `decideMany` send the enrolment for you. _Source: https://octetproof.com/docs/magistrate/get-started/verify-and-enforce/_ --- # OPL Overview OPL, the Octet Policy Language, is how a Magistrate policy is written down. The studio writes it for you, and shows it in its code pane. Read this page to review a policy before you sign it off, or to understand a decision afterwards. This documentation covers the part of OPL that the Magistrate studio and the Octet policy engine support today. [What's Next](/whats-next) lists what comes later. ## A policy ```opl policy p_3b41af1558a1448b8845fa2821871b5d { combine deny_overrides rule deny when outside(country("AT")) reason "E-304" default permit freshness 5m } ``` In words: a device outside Austria gets `deny`. Every other device gets `permit`. If the proofs can't settle where the device is, the answer is `deny`. Proofs older than 5 minutes don't count. ## Policy structure A policy is a name and a body in braces. The body is a list of clauses, in any order. | Clause | Required | Description | |---|---|---| | `policy ` | Yes | The policy's name. Policies from the studio are named `p_` followed by a UUID. The studio shows a separate, human title | | `rule` | Yes, one or more | An effect, `permit` or `deny`, and the condition under which it applies. See [Policy clauses](/opl/language#policy-clauses) | | `combine` | No | How the rules combine into one decision. These docs cover only `deny_overrides`, the default | | `default` | No | The decision when no rule applies, `permit` or `deny`. Without it, no matching rule means `deny` | | `on_indeterminate` | No | The decision when the proofs can't settle a rule. Defaults to `deny`, and these docs cover only `deny`. The studio doesn't write this clause | | `freshness` | No | How old a proof may be and still count, for example `5m`. Without it, 300 seconds | | `requires` | No | A minimum precision for the proofs, for example `requires proof_level >= country` | ## How a policy is decided The engine verifies the device's proofs, then evaluates the rules: ```mermaid %%{init: {"flowchart": {"nodeSpacing": 30, "rankSpacing": 40}}}%% flowchart LR Q(Are the proofs precise
enough for the policy?) -- yes --> D(Does a deny
rule apply?) D -- no --> P(Does a permit
rule apply?) P -- no --> DEF[the default
answer] D -- yes --> DENY[deny] P -- yes --> PERMIT[permit] Q -- no --> U[UNKNOWN: the
on_indeterminate answer] D -- can't tell --> U P -- can't tell --> U ``` 1. **The proofs must be precise enough.** If the policy has a `requires` clause and the proofs don't meet it, the policy can't be decided and gets its `on_indeterminate` answer, with `cause` `other`. A proof older than `freshness` doesn't stop the decision. It only leaves the regions it would have settled unknown, so a deny rule that other proofs settle still wins. 2. **Deny wins.** If any `deny` rule applies, the decision is `deny`. 3. **A rule that can't be settled stops the decision.** If a `deny` rule might apply but the proofs can't tell, the decision is the `on_indeterminate` answer. For example, a device that proves only that it is in Ukraine can't settle a rule about Crimea. 4. **Then permit.** If a `permit` rule applies, the decision is `permit`. 5. **Then the default.** If no rule applies, the decision is `default`, or `deny` without one. The engine returns only `permit` or `deny`. When the answer came from `on_indeterminate`, the decision also carries a `cause` saying why the location couldn't be settled. See [Decision Response](/reference/decision). ## Defaults to know - **No proof is not a pass.** A device with no current proof can't satisfy a rule, so the policy gives its `on_indeterminate` answer. Every policy the studio writes uses `deny`. - **Countries follow ISO 3166 strictly.** `country("US")` doesn't include Puerto Rico or the other territories, which have their own codes. List them if you mean them. - **No matching rule and no default is `deny`.** - **A policy decides one question.** To check several at once, use `decideMany` in the Magistrate module. ## Next - [Language](/opl/language): every keyword, region and type. - [Examples](/opl/examples): complete policies to copy. _Source: https://octetproof.com/docs/magistrate/opl/overview/_ --- # OPL Language This page lists the parts of OPL that the Octet policy engine decides today. The **Studio** column says whether the Magistrate studio writes it yet. A policy that uses a part the studio doesn't write yet can still be decided by the engine. ## Grammar | Construct | Syntax | Example | Studio | |---|---|---|---| | Policy | `policy { }` | `policy p_… { … }` | Yes | | Rule | `rule permit\|deny when ` | `rule deny when outside(country("AT"))` | `deny` only | | Condition | `within()`, `outside()` | `within(subdivision("US-CA"))` | Yes | | Region union | ` union ` | `country("CU") union country("IR")` | Yes | | Region intersection | ` intersect ` | `country("US") intersect disc(40.71, -74.01, 50km)` | No | | Region difference | ` difference ` | `country("US") difference subdivision("US-NJ")` | No | | Grouping | `( )` | `(country("AT") union country("DE")) difference …` | No | | Comment | `// …` or `/* … */` | `// Sanctions list, reviewed 2026-10` | No | `intersect` binds tighter than `difference`, which binds tighter than `union`. Use parentheses when in doubt. ## Keywords ### Policy clauses | Keyword | Type | Description | |---|---|---| | `rule` | `permit` or `deny`, then `when ` | Applies its effect when the condition holds. A rule can end with `reason "