An iPhone or Android app does not ship the app secret. It ships two public values, the organization ID and the App ID. When it starts, it asks AppEngine for a challenge, has Apple or Google sign a statement that names the app and the challenge, and sends that statement back. AppEngine checks the statement against the app identity you registered in Studio and returns a one-hour app token.
Video tutorials for this topic
A website does the same with the website it is on instead of a device proof: the site chat widget sends the page's domain, and AppEngine checks it against the app's Allowed domains. See Web: the site chat widget.
The person's own sign-in does not change. Staff and customers sign in with the same routes as before. Only the app's own sign-in changes.
appmint_flutter_client 0.2.0 signs the app in this way by itself: see Flutter: the client does this for you. @appmint/js-client signs in with the secret only and is for servers. The raw requests under Sign-in flow for other stacks are for everything else.
How it fits together
An app credential is a user in the System group, like a service account on Windows. Each app that calls AppEngine before a person signs in has its own app credential: a customer app such as Event OS, or a site's chat widget. AppMint Mobile has none; it signs staff in directly and sends their token. Its App ID is that user's username. The App ID is public: the app sends it with the organization ID on every sign-in. The app credential's record id (sk) names the same app, so appId may be either one on app/key, app/challenge, app/attest and guest/auth. The match is exact: the username as saved, or the 24-character record id.
The App identity belongs to the app credential. It is the same for every install and every version of the app:
| Platform | What you register | What AppEngine checks |
|---|---|---|
| iOS | Apple Team ID and bundle ID | The attestation names the app as SHA-256 of <teamId>.<bundleId> |
| Android | Package name, signing certificate SHA-256 fingerprints, Play Integrity response keys | The verdict names the package, is signed by a registered certificate, and Google Play recognises the build |
| Web | Allowed domains | The domain the page sends is listed, and the browser's Origin header names the same site |
The proof travels in the attestation, not in the app. Apple or Google signs a statement that carries the bundle ID or package name and the challenge AppEngine issued. AppEngine checks the signature and compares both values with the registered identity.
On iOS each install has its own App Attest key, as Apple designs it. AppEngine keeps that key as a device of the app user (a user_device record named "App name on iOS (App Attest)"), so later sign-ins from that install can be checked against it. On Android nothing is stored per install.
What ships in the app
| Value | Example | Secret? |
|---|---|---|
| AppEngine URL | https://appengine.appmint.io | No |
| Organization ID | acme | No |
| App ID | acme-mobile | No |
| App secret | — | Do not ship it |
A debug build that runs on a simulator or emulator also carries a debug token. Keep debug tokens out of release builds. See Debug tokens.
Set up the app in Studio
Open Configuration › User, Group › App Credentials and open the app (click its App ID). The drawer has these sections: Secret key, Check, App identity, Debug tokens and Sign-ins.
iOS
| Field | Where to find it |
|---|---|
| Team ID | Your Apple Developer account, under Membership details. It is 10 capital letters and digits; Studio refuses anything else. |
| Bundle ID | The app target's bundle identifier in Xcode, for example com.example.app. |
The app also needs the App Attest capability in Xcode (Signing & Capabilities › + Capability › App Attest), which adds the com.apple.developer.devicecheck.appattest-environment entitlement. AppEngine accepts attestations from both the App Attest production and development environments, and records which one on the install's device record.
Android
| Field | Where to find it |
|---|---|
| Package name | The app's application ID, for example com.example.app. |
| Signing certificate SHA-256 | Play Console, the app's App integrity page, App signing. Add the app signing key certificate's SHA-256 fingerprint. Add the upload key certificate's too if you test builds you sign yourself. The field takes several fingerprints. It accepts hex (AB:CD:…, 32 pairs) or base64. A SHA-1 fingerprint is refused. |
| Play Console response keys | Play Console, App integrity, Play Integrity API settings, response encryption. Choose to manage and download the response encryption keys yourself; that gives a decryption key and a verification key. Paste both. |
AppEngine decrypts and checks the Play Integrity token itself with these two keys, so it needs both. Studio saves them with the app identity and never shows them again: the screen only says Set or Not set. To change them, choose Replace and paste both again.
The Play Integrity API has to be turned on for the app in Play Console. The exact Play Console menu names change between Console versions; the values above are the ones to look for.
AppEngine also requires Google Play to recognise the build (PLAY_RECOGNIZED) and the device to meet device integrity (MEETS_DEVICE_INTEGRITY). A build installed outside Google Play does not get the first verdict and is refused with not_play_recognized, even when its certificate is registered. Install test builds from a Play testing track (internal testing is enough), or sign sideloaded builds and emulators in with a debug token.
Web
Allowed domains lists the websites the app signs in on, for example shop.example.com. Enter the address as the browser shows it. A scheme and path are dropped (https://Shop.Example.com/contact is saved as shop.example.com), and so are a trailing dot and capitals.
- A name without a port allows that site on any port:
shop.example.comallowsshop.example.com:8443. - A name with a port allows only that port:
localhost:3200allowslocalhost:3200, notlocalhost:3000. - A subdomain is a different site:
shop.example.comdoes not allowwww.shop.example.com. List each one.
The site chat widget signs in only on a listed website. Studio adds a site's own addresses (its host name and custom domain) for you in three places: Site Config › Chat (an Add this site button, and on save when you pick a new chat app), the Live Chat setup when you finish it, and Chat Designer › Embed Code, which also takes the other websites you paste the embed on.
Choose Save app identity when the fields are filled in.
The same setup by API
/profile/app/:id/identity:id is the app credential's record id (sk), not the App ID. Parts you do not send stay as they are. The Play keys are replaced only when sent non-empty. The response is the app record with the keys replaced by android.keysSet and debug tokens without their hash. This route needs an administrator (ConfigAdmin).
Flutter: the client does this for you
appmint_flutter_client 0.2.0 is a Flutter plugin: the App Attest half (Swift) and the Play Integrity half (Kotlin) ship inside the package. Give it the org ID and App ID and no secret:
final appmint = Appmint(const AppmintConfig(
baseUrl: 'https://appengine.appmint.io',
orgId: 'acme',
appId: 'acme-mobile',
// Simulators, emulators and Flutter web only. Empty in a release build.
debugToken: String.fromEnvironment('APPMINT_DEBUG_TOKEN'),
// Android: the Google Cloud project number linked in Play Console.
playCloudProjectNumber: 123456789012,
));baseUrl, orgId and appId are required. appSecret and appKey are optional and empty by default. With appSecret empty the client signs the app in with a proof; with it set the client uses POST /profile/app/key instead, which is for servers and trusted tools only.
What the client does:
- Before the first request it gets a challenge and posts the proof to
app/attest. Concurrent first calls share one sign-in. Every attempt asks for a new challenge, since each works once. - The proof is the
debugTokenwhen one is set; otherwise App Attest on iOS and a classic Play Integrity request on Android (withplayCloudProjectNumberwhen set). - iOS keys. The first sign-in of an install makes a key and attests it; later sign-ins send assertions. The key ID is kept in the keychain for this device only, per org and app, and only after AppEngine accepted the attestation. If AppEngine answers
unknown_key,bad_signatureorcounter_not_increasing, or the key is gone after a reinstall, the client drops it and attests a new key once. - Renewal. The app token is renewed before it expires (60 seconds early, or at half its life if that is shorter) and after a
401.appmint.http.appTokenExpiresAtandappmint.http.hasUsableAppTokenshow its state. - Refusals throw
AppmintAppAuthException. Itsreasonis AppEngine's reason code (see Reasons) and itsmessageis AppEngine's sentence. Two reasons come from the device instead of the server:proof_unavailable(no App Attest or Play Integrity here: the iOS simulator, Flutter web, desktop, or the native plugin is not registered) andproof_failed(App Attest or Play Integrity returned an error).
iOS setup
- Fill in the iOS identity in Studio (above).
- In Xcode, Runner target › Signing & Capabilities › + Capability › App Attest.
- Run
pod install, or letflutter rundo it. CocoaPods and Swift Package Manager both work. - App Attest needs iOS 14 or later on a real device. The simulator has none: run simulator builds with
--dart-define=APPMINT_DEBUG_TOKEN=….
Android setup
- Fill in the Android identity in Studio (above).
- Set the app's minimum SDK to 24 or higher. The plugin adds the Play Integrity library itself; nothing else goes in your Gradle files.
- In Play Console, App integrity: link a Google Cloud project and turn on the Play Integrity API. Pass that project's number as
playCloudProjectNumber. It is needed when the app is not distributed only through Google Play, and harmless otherwise. - Install from a Play testing track. Sideloaded and debug builds, and emulators, sign in with a debug token.
Classic Play Integrity requests have a daily quota per app, shown in Play Console. The client asks for a token only when it signs the app in: about once an hour per running app, and again after a 401.
Web, desktop and tests
Flutter web and desktop have no device proof. The client signs them in only with a debug token, which is for development. A Flutter web app on a listed website can sign in with platform: "web" through its own AppProof (below) that returns platform: "web" and domain; the client does not do this by itself.
Tests, or an app with its own proof, can pass an AppProof: Appmint(config, appProof: myProof). It has three methods: prove(nonce, config) returns the platform and fields to send, accepted(result, config) runs after AppEngine issues a token, and refused(result, reason, config) returns true when one fresh attempt may succeed. The package also exports DebugTokenProof, DeviceAppProof and AttestKeyStore (with MemoryAttestKeyStore for tests).
Sign-in flow for other stacks
The Flutter client sends these requests for you. Use them directly from Swift, Kotlin, React Native or any other stack. Every sign-in is the same three steps: get a challenge, prove the app with it, use the token.
1. Get a challenge
/profile/app/challenge{ "nonce": "q7Vb3Zk1nXw0n5gYc2qJ8r9tLh4mPz6aEoU1sD3fGh0", "expiresAt": "2026-10-19T14:02:00.000Z" }The nonce is 32 random bytes in base64url (43 characters). It works once, for this app only, and expires after 2 minutes. An App ID that is not an app credential of the organization gets 404 with code: "APP_NOT_FOUND" and the message "No app error field).
2. Prove the app
Send the nonce back to POST /profile/app/attest with the platform's proof. The four device forms share appId, platform and nonce. The web form takes no challenge: see Web: the site chat widget.
iOS, first launch of an install: generate an App Attest key and attest it.
/profile/app/attestiOS, every later sign-in: sign the new challenge with the same key.
{
"appId": "acme-mobile",
"platform": "ios",
"nonce": "<nonce>",
"keyId": "<the same keyId>",
"assertion": "<assertion, base64>"
}Android:
{
"appId": "acme-mobile",
"platform": "android",
"nonce": "<nonce>",
"integrityToken": "<token from Play Integrity>"
}Simulator, emulator or CI:
{
"appId": "acme-mobile",
"platform": "debug",
"nonce": "<nonce>",
"debugToken": "adt_…"
}A sign-in that holds returns:
{ "token": "<app token>", "expiresIn": 3600, "platform": "ios" }The app token is the same System token POST /profile/app/key gives. It also carries an attest claim naming the platform and method (app-attest, app-assert, play-integrity, debug-token or web-domain), and on iOS the install's key id. It lasts one hour.
Web: the site chat widget
A website has no device proof. The page signs the app in with the website it is on, with no challenge:
/profile/app/attest{ "token": "<app token>", "expiresIn": 3600, "platform": "web" }domain is the page's host as the browser shows it (window.location.host, with the port if there is one). AppEngine accepts it when:
- the app has at least one Allowed domain (else
web_not_configured); domainis on the list, by the rules under Web (elsedomain_not_allowed);- the
Originheader, which the browser sets itself, names the same host (elseorigin_mismatch). A request with noOriginheader is checked ondomainalone.
A web sign-in works whatever the Check is set to: Off, Monitor and Enforce are about the phone apps and the secret. It is counted under Web in the app's sign-ins.
The domain check stops other websites from embedding your chat in a browser. It is not a secret: a script outside a browser can send any Origin. Treat the app token a web page gets as public, like the App ID.
The site chat widget does all of this itself: it signs the app in, keeps the token for its hour, and then makes the visitor a guest (below). If the sign-in is refused, the visitor sees "Live chat isn't set up for this website yet. Please contact us another way." and the browser console shows AppEngine's reason for the site owner.
What the server expects from each platform
These details decide whether the proof matches. They come from the AppEngine checks, not from the platform SDKs.
iOS (DCAppAttestService: generateKey, attestKey, generateAssertion). The client data is the nonce string's UTF-8 bytes. Pass SHA256(UTF-8 bytes of the nonce) as clientDataHash to both attestKey and generateAssertion. Do not decode the nonce first. Send keyId exactly as generateKey returned it; it is already base64. Send the attestation and the assertion as base64 (base64url also works). Each assertion's counter must be higher than the last one AppEngine accepted for that key, so sign each new challenge once.
Android (IntegrityManagerFactory.create(context).requestIntegrityToken(…)). Use a classic Play Integrity request (requestIntegrityToken) and pass the nonce string unchanged to setNonce. AppEngine compares nonces as base64url without padding, which is the form it issues. The token must be used within 5 minutes of Google issuing it. Send the token string as returned; AppEngine decrypts it.
3. Call AppEngine with the app token
Send the app token as the bearer token, with the organization:
/storefront/productsAfter a person signs in (staff or customer, the same routes as before), send their token as well, in its own header:
orgid: acme
Authorization: Bearer <app token>
x-client-authorization: Bearer <person token>x-client-authorization accepts the token with or without the Bearer prefix. See Authentication for how the two identities are used.
4. Renew
There is no refresh token for the app. When the app token is near its hour, or a request answers 401, run the flow again: a new challenge, then an assertion on iOS, a new integrity token on Android, or the debug token. The iOS key does not change; only the first sign-in of an install sends an attestation. Keep the key ID only after AppEngine accepted its attestation, and if AppEngine answers unknown_key, drop it and attest a new key.
Guest sign-in needs the app token
POST /profile/guest/auth makes a guest customer only for a signed-in app. Send the app token as the bearer token:
/profile/guest/authThe body needs email or phone. The appId header is optional; when it is sent it must name the app the token belongs to, by App ID or record id. Without this app's token the answer is 401 with code: "app_token_required". The response carries the guest record, token and refreshToken.
A site's chat widget gets the app token from the web sign-in above, then calls guest/auth with it. A server app (base-app, a server proxy) uses its app/key token.
When sign-in is refused
A refused app/attest answers 401:
{
"code": "app_attestation_failed",
"reason": "nonce_mismatch",
"statusCode": 401,
"error": "The attestation was not made for this challenge.",
"path": "/profile/app/attest",
"method": "POST",
"timeStamp": "2026-10-19T14:01:12.000Z"
}Branch on reason. error is the sentence for a person and may be reworded. Every refusal is counted under Failed in the app's sign-ins and listed under Recent failures with its reason.
A request without appId or with a platform other than ios, android, debug or web gets a 400 instead.
Reasons
| Reason | What happened | What to do |
|---|---|---|
challenge_invalid | The nonce is unknown, expired (2 minutes), already used, or was issued for another app | Get a new challenge and try once more |
attestation_off | The app credential's Check is Off | Set it to Monitor or Enforce in Studio |
unknown_app | The App ID is not an app credential of this organization | Check the App ID and the orgid header |
revoked | The app credential was revoked | Nothing on the device fixes this; an administrator must issue a new credential |
missing_proof | The body lacks the proof for its platform (keyId, attestation/assertion, integrityToken, or domain on web) | Send the fields for the platform |
web_not_configured | Web: the app has no Allowed domains | Add the website in Studio (App Credentials, or Site Config › Chat › Add this site) |
domain_not_allowed | Web: domain is not one of the app's Allowed domains | Add the website, with its port if it uses one that the list names |
origin_mismatch | Web: the browser's Origin names another site than domain (or null, from a sandboxed frame or a file) | Send the host of the page the request comes from |
ios_not_configured | No Team ID or bundle ID saved | Fill in the iOS identity in Studio |
android_not_configured | Package name, certificate fingerprint or either Play key missing | Fill in the Android identity in Studio |
debug_not_allowed | Debug tokens are turned off for this app | Turn on Allow debug tokens in Studio, or use a real device |
debug_token_invalid | The debug token is not one of this app's tokens (or was deleted) | Make a new debug token |
app_id_mismatch | iOS: the attestation or assertion is for a different Team ID or bundle ID | Check both values in Studio against the build |
nonce_mismatch | The proof was made for a different challenge, or the client data was encoded differently | iOS: hash the nonce string's UTF-8 bytes. Android: pass the nonce unchanged |
key_mismatch | iOS: the attested key does not match keyId | Send keyId exactly as generateKey returned it |
unknown_key | iOS: an assertion for a key AppEngine never attested for this app | Make a new key and send an attestation |
counter_not_increasing | iOS: the assertion counter did not go up (possible replay) | Get a new challenge and make a new assertion |
bad_counter | iOS: a new key's counter was not 0 | Make a new key |
bad_environment | iOS: the attestation is from neither App Attest production nor development | Check the App Attest entitlement |
bad_certificate_chain, certificate_expired | iOS: the attestation is not signed by Apple's App Attestation CA, or a certificate is out of date | Make a new key and attest again; if it persists, the attestation did not come from Apple |
bad_format, bad_auth_data | The attestation, assertion or integrity token could not be read | Send the value base64-encoded, unmodified |
bad_key | iOS: the attested key is not a P-256 key | Use a key made by generateKey |
bad_signature | iOS: the assertion is not signed by this install's key. Android: the verdict is not signed with the registered verification key | iOS: make a new key. Android: check the verification key in Studio |
device_blocked | iOS: this install's device record is blocked | An administrator unblocks the device, or the install stays out |
keys_invalid, decrypt_failed | Android: the Play keys in Studio cannot be read or do not decrypt this token | Paste both keys from Play Console again (Replace) |
package_mismatch | Android: the token was requested by, or is for, another package | Check the package name in Studio |
token_stale | Android: the token is more than 5 minutes old | Request a new integrity token for a new challenge |
not_play_recognized | Android: Google Play does not recognise this build | Install from Google Play, or use a debug token for test builds |
cert_mismatch | Android: the build is signed with a certificate that is not registered | Add the app signing key's SHA-256 (and upload key's, for your own test builds) in Studio |
device_integrity | Android: the device does not meet Play device integrity | The app cannot sign in on this device |
root_untrusted | The server's copy of Apple's root certificate is wrong | A server problem; report it |
Other refusals
| Where | Status and code | Meaning |
|---|---|---|
POST /profile/app/key | 401, code: "app_attestation_required", reason: "secret_refused" | The app is on Enforce; the secret no longer signs it in |
| Any request | 401, code: "app_revoked" | The app token belongs to a revoked app credential |
POST /profile/guest/auth | 401, code: "app_token_required" | No app token for this app and organization |
Off, Monitor and Enforce
The Check section of the app sets how the app may sign in:
| Check | Secret (app/key) | app/attest |
|---|---|---|
| Off (default) | Works | Refused with attestation_off (web sign-in still works) |
| Monitor | Works | Works; failures are refused and recorded |
| Enforce | Refused with secret_refused | Works |
On Monitor, a failed attestation is still refused. Monitor means both ways in work, not that failures are let through.
Rolling it out
- Fill in the App identity for each platform you ship.
- Set the check to Monitor. Versions that use the secret keep working.
- Ship an app update that signs in with
app/challengeandapp/attestand no longer contains the secret. For Flutter: update toappmint_flutter_client0.2.0 and removeappSecretfrom the config. - Watch Sign-ins. It shows sign-ins per day by method (Secret, iOS, Android, Debug, Web, Failed) for the last 30 days, and the 20 latest failures with their reason. Fix any failure reasons that show up.
- When the Secret count has gone to zero, set the check to Enforce. Studio warns before switching if sign-ins used the secret in the last 30 days, or if no iOS or Android identity is saved.
Server apps keep their own app credential with the secret and the check left Off. A Next.js or Remix server, a server proxy or a script can keep the secret safely. Give phone apps their own app credential so you can enforce them without touching the server.
Debug tokens
A simulator or emulator cannot pass App Attest or Play Integrity. A debug token lets it sign in while the check is on.
- In the app's Debug tokens section, turn on Allow debug tokens (simulators and emulators).
- Enter a Token name (for example iPhone 16 simulator) and choose Make token.
- Copy the token. It is shown once; AppEngine keeps only its hash. It starts with
adt_. - Put it in the debug build only. With the Flutter client, pass it at run time:
--dart-define=APPMINT_DEBUG_TOKEN=…read intoAppmintConfig.debugToken. Other stacks send it with"platform": "debug".
The list shows each token's name, its first characters, when it was made and when it was last used. Delete stops a token at once. Turn Allow debug tokens off for production apps; with it off, every debug token is refused with debug_not_allowed.
By API: POST /profile/app/:id/debug-token with { "name": "…" } returns { id, name, token }; DELETE /profile/app/:id/debug-token/:tokenId removes one. allowDebugTokens is set through PUT /profile/app/:id/identity.
Revoking and rotating
- Revoke (in the App Credentials list) stops the app at once. Tokens it already holds are refused on the next request with
app_revoked, andapp/attestanswersrevoked. - New key (in Secret key) makes a new secret. The old secret stops working immediately. Apps that sign in with App Attest or Play Integrity do not use the secret and are not affected.
- One iOS install: each install's App Attest key is a device record of the app user. Blocking it refuses that install's next sign-in with
device_blocked. - Debug tokens: delete the token, or turn Allow debug tokens off.
Related
- Authentication — the app identity and the person's identity on each request
- Flutter client
- User authentication — app registration and the secret exchange
