Sign in from a native app
Prompt:
Let the users of our iPhone and Android app sign in to the backend on Turn Zero Cloud.
Also works: "Our Expo app needs sign-in." "Keep app users signed in for months." "The app must confirm it is really the user before deleting the account."
What your tool does
- Reads your manifest. Where its
servicesarray declares noaccountsservice, it adds{"kind": "accounts"}and resubmits the manifest withsubmit_manifest, so each environment has a realm. - Calls
configure_realmwith the app'sclientsdeclaration. On an application with one environment, it calls once, for production, declaring the debug build and the store build there. With two, it calls once withenvironment: "development"for the debug build and once for production with the store build's redirect URIs. - Calls
read_realmto confirm the declared clients and the session lengths, and raisessession_cap_dayswhere you ask for longer sessions. - Writes the app's sign-in code: the library configured with the realm's endpoints, the exchange of the code, the secure store for the pair, and the refresh before each hour passes.
- Writes the backend's verification of the bearer, reading the
Authorizationheader instead of the cookie (Verifying an end user). - Asks you for the app's identifier, its redirect scheme, and its app signing identities where the prompt does not mention them, and reports every value it declared.
- Where the app signs in through Apple's or Google's own software development kit (SDK), declares the audiences given in those tokens and writes the exchange of step 10.
What you need
- Your app's identifier in reverse-domain form, such as com.example.app, and a development build of your app ready to test with.
Before your AI starts
This section is for your AI tool: what it checks and gathers before it begins. You don't need to do these steps yourself.
- A connected, signed-in tool (Connect your tool).
- An application whose manifest declares the accounts service, submitted, so each environment has a realm (Manage end users).
- The sign-in methods your users sign in with, enabled on the realm. The emailed code and passkeys work inside the browser sheet as they do on the web.
- Expo Go's
exp://redirect is outside the rule below.
Steps
1. Declare the client on each environment's realm
A native app is a public client: it has no secret, and the realm identifies it by its client_id and its redirect URIs. Declare it with configure_realm.
A new application has one environment, production, and one realm. Both builds sign in there, so one call declares the debug build's and the store build's redirect URIs on the production realm. Where you turned on a development environment, the development realm contains the debug build's redirect URIs and the production realm the store build's, so the declaration takes two calls. No test checks this sample.
{
"application": "<application id>",
"environment": "production",
"clients": [
{
"client_id": "com.example.app",
"redirect_uris": ["com.example.app:/callback"],
"ios": { "bundle_id": "com.example.app", "team_id": "ABCDE12345" },
"android": { "package": "com.example.app", "sha256_cert_fingerprints": ["AB:CD:..."] }
}
]
}
A redirect URI takes one of three forms, and any other is refused invalid_redirect_uri:
- a custom scheme in reverse-domain form, such as
com.example.app:/callback; - an
httpsURI on one of the application's own hostnames; - a loopback URI,
httponlocalhost,[::1], or an address in 127.0.0.0/8.
The authorization endpoint matches a presented URI against the declared ones exactly, a loopback URI on any port, and the token endpoint takes the URI the sign-in returned to. Expo Go's exp:// redirect is outside the rule, so test the sign-in in a development build with your own scheme. The ios, android, and google_client_ids members are optional at this stage. A realm declares at most ten clients, and clients replaces the list each time.
On an application with one environment, choose how to declare the debug build on the production realm:
- With its app signing identity, such as its debug signing fingerprint. The debug build then keeps passkeys and the token exchange of step 10. The production hostname's well-known files, below, then list the debug build beside the store build.
- With a loopback or custom-scheme redirect URI alone. The debug build then signs in through the browser redirect, with no passkeys and no exchange of Apple's or Google's own token.
Testers who sign in from a debug build are real users of the production realm, beside your live users.
read_realm returns the declared clients beside the rest of the configuration. It never returns a secret, and a native client has none.
Once a client is declared, the platform serves the files the operating systems read, on each environment's hostname. /.well-known/apple-app-site-association lists each iOS client's application identifier, for passkeys and for https redirect URIs on that hostname. /.well-known/assetlinks.json lists each Android client's package and signing fingerprints. /.well-known/oauth-authorization-server lists the realm's endpoints, for a library that reads that path. Each is returned as JSON with a one-hour cache and needs no session. A path stays your application's own until the realm declares a client of that platform.
2. Configure the sign-in library
The realm's endpoints sit under the reserved prefix on the environment's own hostname:
| Endpoint | Path |
|---|---|
| Authorization | https://<hostname>/__account/oauth/authorize |
| Token | https://<hostname>/__account/oauth/token |
| Revocation | https://<hostname>/__account/oauth/revoke |
On an application with one environment, both builds use the production hostname, <label>.ai.host. With two, the development hostname has -dev after the application's label: the debug build uses that hostname, and the store build the production hostname. The flow is the standard one: authorization code with Proof Key for Code Exchange (PKCE) under the S256 method, the sign-in in the system browser sheet, and no client secret.
With Expo AuthSession in a development build, the discovery object lists the endpoints. useAutoDiscovery reads the OpenID Connect discovery path, which the platform does not serve. A request for it under /__account/ returns 404 not_found, whose detail names the metadata address /.well-known/oauth-authorization-server. No test checks this sample.
const discovery = {
authorizationEndpoint: 'https://shop.ai.host/__account/oauth/authorize',
tokenEndpoint: 'https://shop.ai.host/__account/oauth/token',
revocationEndpoint: 'https://shop.ai.host/__account/oauth/revoke',
};
const redirectUri = 'com.example.app:/callback';
const [request, response, promptAsync] = useAuthRequest(
{ clientId: 'com.example.app', redirectUri, usePKCE: true, scopes: [] },
discovery,
);
With AppAuth, build an AuthorizationServiceConfiguration from the endpoints in the table. fetchFromIssuer reads the OpenID Connect discovery path, which the platform does not serve. Then build an AuthorizationRequest with the client identifier, the redirect URI, and the response type code. The library generates the verifier and the challenge.
3. Run the sign-in and exchange the code
The library opens the authorization endpoint with response_type=code, the client_id, one declared redirect_uri, the code_challenge, and a state. The realm shows its sign-in page in the browser sheet. When the person signs in, the sheet returns to the redirect URI with a code and the state, and no session is opened in the browser. The code is valid for sixty seconds and is used once.
The library then posts the code to the token endpoint, form-encoded. No test checks this sample.
grant_type=authorization_code
client_id=com.example.app
code=<the code>
redirect_uri=com.example.app:/callback
code_verifier=<the verifier>
The exchange opens the session, and the response contains the token. No test checks this sample.
{
"contract_version": 1,
"token_type": "bearer",
"access_token": "turnzero_cloud_usr_1....",
"expires_in": 3600,
"refresh_token": "...",
"session_expires_at": "2026-10-27T00:00:00.000Z"
}
Keep both values in the platform's secure store. A spent or expired code, a code issued to another client, a redirect URI other than the code's, and a verifier that fails its challenge are each refused invalid_grant. The app then starts the sign-in again.
4. Refresh the session
The access token lasts one hour. Before it passes, the app presents the refresh credential at the token endpoint. No test checks this sample.
grant_type=refresh_token
client_id=com.example.app
refresh_token=<the credential>
The response contains a fresh token and a fresh refresh credential, and the presented credential is replaced. Each refresh extends the session by the realm's session_days, up to session_cap_days from the sign-in: 30 days and 365 days by default. At the cap the session expires, and the app signs in again.
Replace the stored credential with each response, and keep one refresh in flight at a time. If a response is lost, a retry with the same credential within sixty seconds returns a fresh pair, because a lost response is not a sign of theft. If a second refresh starts with the newer credential before the first has its response, one of the two is refused, and the app signs in again. A replaced credential presented after sixty seconds is refused refresh_reused, and the session ends on every device that had it.
5. Call the backend with the bearer
Send the token as a bearer on every request to your backend. No test checks this sample.
Authorization: Bearer turnzero_cloud_usr_1....
The serving router treats a bearer of that form as it treats the session cookie: it verifies the bearer and forwards the request with the session header set. Under an invited or workforce audience, the bearer passes the audience check as the cookie does. Your backend verifies the token through the account package's verify client, reading the Authorization header instead of the cookie. Verifying an end user describes the client and the header.
The realm's own routes accept the bearer too: GET /__account/me returns the signed-in user, and the passkey and sign-out routes take it. The accounts service's verification route accepts it from your backend as it accepts the cookie's token.
A bearer the router cannot verify is refused 401 authentication_required with WWW-Authenticate: Bearer error="invalid_token" before it reaches your backend, on every audience. The router never renews a bearer, so refresh before the hour passes, and sign in again on a 401 that follows a refresh.
Public or workforce for a backend that verifies the bearer
Under a public audience, a request with no bearer reaches your backend, which refuses it itself. Under workforce, the router refuses it first with 401, and sends a GET that accepts text/html to the sign-in page. The realm then signs people in with the declared tenant's entra route alone (Work accounts). So a backend whose only sign-in method is one company's work account declares workforce. A backend that also offers Google, GitHub, Apple, or the emailed code declares public. Under workforce, a provider's callback path goes in session_free_paths (Choose who may sign up).
To sign out, post the refresh credential as token to the revocation endpoint with the client_id, or post to /__account/signout with the bearer. Either ends the session and its refresh credential at once, and every router refuses the token within sixty seconds (the copy interval). A sign-out made offline clears the app's own store and leaves the session open on the realm until the user ends it from the session list.
6. Confirm it is you, from the app
Deleting the account and some passkey actions need a fresh sign-in, not the existing session. From the app, run the authorization again with prompt=login. The sign-in page opens as before, and the person signs in with the account they already have.
Exchange the code as in step 3, and send the app's current token as the bearer with the exchange. The exchange marks that session as freshly authenticated for five minutes and opens no new session. No test checks this sample.
{ "contract_version": 1, "reauthenticated": true, "until": "2026-09-27T00:05:00.000Z" }
An exchange without the bearer, or with another user's, is refused reauthentication_other_account. So is a sign-in that resolves to no existing user: the code's sign-in creates nobody.
7. Register a passkey from inside the app
After the exchange in step 6, the app may register a passkey under the same bearer within five minutes. Post to /__account/passkeys/register/options and then to /__account/passkeys/register with the platform's credential response, as the web page does. Only a user who signs in by emailed code can register one, and for any other user both routes answer 409 passkey_requires_email (Manage end users). Identities and passkeys covers the routes.
The ceremony binds the passkey to the environment's hostname, so it also works on the sign-in page in the browser sheet. Two things let the app run it. On iOS, the app declares the associated domain webcredentials:<hostname> in its entitlements, and the app-site association file the platform serves lists the app. On Android, the realm's declared signing fingerprints become accepted origins for the ceremony, and the asset links file includes the get_login_creds relation. A registration from an app the realm does not declare is refused passkey_origin_mismatch.
8. Show the user's sessions and end one
GET /__account/sessions with the bearer lists the user's live sessions, in the app and in a browser alike, newest first, at most 100, and total counts them all. A user has at most 100 live sessions: the sign-in that would open the 101st ends the oldest. Each entry gives the session's id and kind, its client_id where native, and when it was created, last_used, and expires. The presenting session is marked current. No entry contains a token or a refresh credential. No test checks this sample.
{
"contract_version": 1,
"sessions": [
{ "id": "<session id>", "kind": "native", "client_id": "com.example.app", "created": "2026-09-27T00:00:00.000Z", "last_used": "2026-09-27T09:00:00.000Z", "expires": "2026-10-27T09:00:00.000Z", "current": true },
{ "id": "<session id>", "kind": "browser", "client_id": null, "created": "2026-09-20T00:00:00.000Z", "last_used": "2026-09-26T21:00:00.000Z", "expires": "2026-10-20T00:00:00.000Z", "current": false }
],
"total": 2
}
A native session's last_used moves with each refresh. A browser session's last_used moves with each hourly re-issue of its token, made at the user's first request after the token's hour.
To end one, post to /__account/sessions/<id>/revoke with the bearer. The session ends at once, the current one included, and every router refuses its token within sixty seconds. An id that matches no live session of this user is refused not_found, and nothing ends. A web page that posts with the session cookie is accepted only from the application's own origin.
9. Delete the account from the app
Deletion needs the fresh sign-in of step 6 on the session that deletes. Within five minutes of that exchange, post to /__account/delete with the same bearer. Without it the post is refused fresh_authentication_required, and the app runs step 6 first.
The response gives counts of what was removed. Every session of the user ends at once on every device, in the app and in a browser, and each refresh credential with it. Clear the stored pair, and return the app to its signed-out state.
10. Sign in with Apple's or Google's own SDK
An app may sign in through the operating system's own sheet instead of the browser sheet. Apple's AuthenticationServices returns an Apple ID token, and Google's sign-in SDK returns a Google ID token. The token endpoint exchanges either for the same session step 3 opens.
The realm needs three things first:
- the route of the token's issuer among the realm's
sign_in_methods,appleorgoogle; - the audience given in the token, declared on the client:
ios.bundle_idfor an Apple token, and one ofgoogle_client_idsfor a Google token; - for the
appleroute, the realm'sapplemember, which Manage end users describes.
Post the token to the token endpoint, form-encoded. No test checks this sample.
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
client_id=com.example.app
subject_token=<the ID token>
subject_token_type=urn:ietf:params:oauth:token-type:id_token
The response is the one step 3 shows, with the same refresh. The platform verifies the token against its issuer's published keys and checks its expiry and its audience. The person's account is found or created as a sign-in in the browser sheet finds or creates it.
On an invitation-only realm the exchange creates no account and is refused unrecognized_invitation, since it includes no invitation. The person redeems their invitation in the browser sheet first, and the exchange then finds the account.
An exchange whose address another account of the app already has is refused identity_collision. The person then signs in through the browser sheet with that account's own route, and links the two there.
Apple lets a person hide their address behind a private relay address, which the realm accepts as any address. Register the platform's managed sender, donotreply@turnzero.ai, and its domain, turnzero.ai, with Apple's private email relay for the app's team, or a relay-address user receives no code and no notice.
From a desktop app
A macOS or Windows app is a native client like any other. It is declared on the realm with configure_realm, signs in through the system browser, and exchanges the code as steps 1 to 5 describe. Never run the sign-in in a web view embedded in the app: Google refuses its sign-in there.
Its redirect URI is a loopback URI in the form step 1 states, or a custom scheme. A loopback redirect is matched on any port, so a desktop app may bind a free port at each sign-in. The scheme, the host, and the path still match the declared URI exactly.
The ios, android, and google_client_ids members do not apply to a desktop app. Universal links, app links, and the association files of step 1 are not used in its sign-in.
The version header and the minimum version apply as on a phone. Keep installed clients compatible covers both.
Push reaches a macOS app only where it registers the platform ios with a token issued under the one bundle identifier set with configure_push, the iOS app's. A desktop app with an identifier of its own gets no push, and no Windows app does (Send push notifications).
Where the app keeps its refresh credential is the app's choice. The operating system's keychain is the secure form, and a plain file is not.
Expected result
read_realm on each environment returns the app under clients, with its redirect URIs and identities and no secret. In a development build the sign-in opens in the browser sheet, returns to the app with a code, and the exchange returns a bearer the backend accepts. An hour later the refresh returns a fresh pair, and session_expires_at moves forward. A replayed refresh credential ends the session, and the app signs in again. The three well-known documents are available on each environment's hostname, and a passkey registered in the app or on the web signs the user in on both.
GET /__account/sessions lists the app's session beside any browser session, and ending one from the list signs that device out. A native session deletes its account after the fresh sign-in of step 6. An Apple or Google ID token from the platform's own SDK exchanges for the same session.
Refusals
| Refusal | Status | Cause | Remedy |
|---|---|---|---|
invalid_client |
400 | The client_id matches no client the realm declares. |
Declare it with configure_realm on this environment's realm. |
invalid_redirect_uri |
400 | The redirect URI is outside the three forms, or the client does not declare it. | Declare the URI in one of the three forms and present it exactly, a loopback URI on any port. |
invalid_request |
400 | The authorization request lacks the S256 challenge or the state, or the token request asks for another grant. A request containing a NUL character (U+0000) or an unpaired UTF-16 surrogate is refused too, and its error_description identifies the character rather than a member. |
Send response_type=code, a code_challenge under S256, and a state; use the three grants. Remove a character the error_description identifies. |
id_token_invalid |
400 | The ID token of step 10 failed verification: its signature, issuer, expiry, or audience. | Sign in again in the app, and declare the token's audience on the client. |
provider_not_enabled |
400 | The ID token's issuer is Apple or Google, and the realm's sign-in methods lack apple or google. |
Add the route to the realm's sign_in_methods with configure_realm. |
identity_collision |
409 | The ID token's address belongs to another account of the app. | Sign in through the browser sheet with that account's route, and link the two there. |
invalid_grant |
400 | The code or the refresh credential is spent, expired, unknown, or not this client's, or the verifier fails. An ID token of step 10 is exchanged once, and a second exchange of it is refused. | Sign in again from the app. |
refresh_reused |
400 | A replaced refresh credential was presented after sixty seconds, and the session is ended. | Sign in again, and replace the stored credential with each response. |
fresh_authentication_required |
403 | The deletion was posted without a fresh sign-in on this session within five minutes. | Run step 6 with this session's token as the bearer, then post the deletion again. |
not_found |
404 | The session id matches no live session of this user, or no route under /__account/ serves the path or the method, such as the OpenID Connect discovery path. |
Read the list again, and use an id it returns. For a path, use the endpoints in step 2. |
reauthentication_other_account |
403 | The re-authentication exchange had no bearer, another user's bearer, or a sign-in that resolves to no user. | Send the app's current token as the bearer, and sign in with the account the app is signed in with. |
user_suspended |
403 | The user is suspended in this realm. | Reinstate the user with reinstate_end_user. |
authentication_required |
401 | The bearer sent to the backend is expired, ended, or another realm's, and the router refused it. | Refresh at the token endpoint, and sign in again where the refresh is refused. |
passkey_origin_mismatch |
400 | The passkey ceremony ran from an app the realm does not declare, or an Android build signed with an undeclared certificate. | Declare the app signing identity on the realm, with every signing fingerprint. |
Related
- Manage end users covers the realm's sign-in methods, its limits, and the
clientsandsession_cap_daysmembers. - Sign-in, sessions, and tokens compares the native session with the other credentials.
- Verifying an end user explains how the backend checks the token.
- Identities and passkeys covers passkeys and linked sign-ins.
- Keep installed clients compatible sends the app's version with each request and refuses old versions.