# Accounts, auth, and regions

> The Auth0 PKCE handoff through Redis, the HB Code JWT and its refresh, what a region change invalidates, how a phone reads and switches Codex profiles and Claude accounts, and which data leaves your computer.

Handbook / Relay & accounts · Checked against the source on 2026-10-05 · https://www.hbcode.ai/docs/accounts-and-auth

## How the native sign-in returns to the app

**The browser delivers the code to the regional server, and only the polling app can redeem it**

Time runs from left to right. The app opens the browser at the regional server’s initiate-login, which stores a state key in Redis and redirects to Auth0. The app starts polling right away and gets 204 while you sign in. Auth0 sends the browser to the callback with the code. The callback consumes the state key and parks the code under the app’s polling ID, so a replayed callback gets 401. A poll reads the parked code without consuming it, so when a reply is lost the next poll gets the same code. The app redeems the code once at Auth0 with its verifier, then calls finalize-login, which returns the HB Code JWT and deletes the poll key. Nothing goes from the browser to the app.

- **State key** (auth0:state:{state}): Written by initiate-login with the polling ID and CSRF token, and deleted by the callback as it parks the code. A replayed callback finds nothing and gets 401.
- **Poll key** (auth0:poll:{pid} · SET NX): Polls read it without consuming it, so after a lost reply the next poll gets the same code. finalize-login deletes it after issuing the JWT, and both keys expire after 30 minutes.
- **Polling** (GET /auth0/poll-status): Phones poll every 2 seconds, up to 60 times. The desktop polls every 2 seconds for about a minute, then every 3, and stops after 60 polls or 150 seconds.
- **Verifier** (code_verifier): Created with the polling ID and kept in app memory. Only Auth0’s token endpoint receives it, once, so a parked or intercepted code is useless without it.

**Auth0’s tokens pass through your device once, and the agent sign-ins never leave the desktop**

At sign-in the app exchanges the code at Auth0 for an access token and a refresh token, posts both once to finalize-login, and keeps neither. The regional server stores the refresh token encrypted and exchanges it with Auth0 itself whenever a device refreshes. Each device keeps its own HB Code JWT, valid for 7 days and refreshable for 24 hours after it expires. The ChatGPT sign-in and the Claude sign-in stay on the desktop. Codex uses the first for every Codex turn with OpenAI, and Claude uses the second for every Claude turn with Anthropic. Only the name, email, plan, and usage limits of each go over the relay to phones.

- **Access token** (finalize-login · auth0_id_token): The app gets it from its own code exchange and posts it once to finalize-login, in a field still named auth0_id_token. The server validates it, fetches userinfo with it, and stores it nowhere.
- **Refresh token** (users.auth0_refresh_token): Passes through the app once. The server stores it encrypted, exchanges it with Auth0 using its own client credentials, and keeps the rotated token even when the new tokens then fail validation.
- **HB Code JWT** (JWT_ACCESS_TOKEN_DURATION_DAYS = 7): Issued per device with a device_id claim. Up to 24 hours after it expires, refresh-app-token still trades it for a new one. After that you sign in again.
- **ChatGPT sign-in** (codex --auth-home): Created by the Codex child process’s own login and kept in the auth home of the desktop profile. Phones get the profile’s name, email, plan, and usage limits over the relay, never the sign-in itself.
- **Claude sign-in** (claude auth login --claudeai): Created by Claude’s own sign-in command, which the desktop runs for one account folder. The desktop never reads the token. Phones get the account’s email, organization, plan, and usage windows over the relay.

| HTTP entry point | Purpose |
| --- | --- |
| GET /auth/auth0/initiate-login | Begin account login. |
| GET /auth/auth0/callback | Auth0 redirects the browser here. Park the code and send the browser to a confirmation page. |
| GET /auth0/poll-status | Non-consuming poll; 204 with Cache-Control: no-store until the code is parked. |
| POST /auth0/finalize-login | Validate the Auth0 token and client ID, require a verified email, issue the HB Code JWT, grant first-login credits, then delete the poll key when the request carries its polling ID and CSRF token. |
| POST /api/auth0/refresh-app-token | Refresh the HB Code app token. The only route that accepts an expired token. |

Consuming the state key and deleting the poll key each run as one Lua script in Redis. The store is Redis rather than memory because every API process in the region shares it.

The HB Code JWT is HS256 with a shared secret, issuer hbcode, audience hbcode-api, and scope read write rpc. Its jti is checked against a revocation table on every request and again at the WebSocket upgrade, its device_id claim is compared to the X-Device-ID header, and it lives 7 days by default. The refresh route accepts a token for up to 24 hours after expiry, with issuer, audience, device binding, scopes, and revocation still enforced, runs in a per-user serialized transaction, and refuses to mint a token if the Auth0 subject or email no longer matches the stored user. The WebSocket upgrade captures a per-user registration fence before its revocation check, so a logout that completes between the HTTP check and relay registration invalidates the registration.

The server’s optional server-client credential pair is needed for server-side Auth0 refresh exchange. Configure the native app’s Auth0 domain, native client ID, audience, and callback settings together. The selected API region and the iOS authServerURL are distinct configuration paths, so verify both when creating a local environment.

## What a region change or sign-out invalidates

Changing the region or signing out ends the previous attempt and clears its credentials. A late result from an old attempt cannot restore an account, because every result is bound to the attempt and the origin that started it.

Desktop login, logout, token refresh, and session reconciliation share one mutation lock, and reconciliation discards results when the token or origin changed during a request. Callback replay is rejected, and handoff acknowledgement is idempotent.

- [Launch waits for the Keychain](https://www.hbcode.ai/docs/ios.md#launch): Sign-in, token refresh, and the stored region on the iPhone.
- [Keystore, sign-in, and sign-out](https://www.hbcode.ai/docs/android.md#credentials): Custom Tab sign-in, token refresh, and the ordered sign-out on Android.

## Agent accounts from a phone

Codex profiles and Claude accounts live on the desktop. A phone reads them and picks the active one through six relay methods in the system namespace. Four methods read. Two methods switch, and both are mutations that need an idempotencyKey.

| Method | Params and result |
| --- | --- |
| system.codexAuthProfiles | Read, no params. Returns profiles. Each entry has profileId, label, authMode, accountId, email, name, planType, workspaceName, workspaceKind, isAuthenticated, isActiveAgentChat, canDelete, ownedSessionLinkCount, and errorMessage. |
| system.activateCodexAuthProfile | Mutation, param profileId. Returns the same profiles list, read after the switch. |
| system.codexUsageLimits | Read, no params. Returns the limits of the active Codex profile: primary and secondary, each with usedPercent, windowMinutes, and resetsAt, plus credits, planType, and rateLimitReachedType. |
| system.claudeAccounts | Read, no params. Returns accounts. Each entry has accountId, isDefault, isActive, signedIn, email, organization, planType, usageWindows, and errorMessage. A usage window has kind (session or weekly), model, usedPercent, and resetsAt. |
| system.activateClaudeAccount | Mutation, param accountId. Returns the same accounts list, read after the switch. |
| system.agentUsageHistory | Read, param agent with the value codex or claude. Returns agent, recordingSince, weeklySamples (sampledAt, usedPercent, resetsAt), dailyTokens, and tokenSummary. |

The two switch methods accept only their one ID. An unknown field, a snake_case field, or a blank ID is an invalid-params error. system.agentUsageHistory rejects every agent value other than codex and claude.

**A switch from the phone changes one setting, and the turn that already runs keeps its account**

Time runs from left to right in four lanes: phone, relay, desktop, and the Claude agent host. A Claude turn runs on account A. The phone sends system.activateClaudeAccount for account B with the key k1 through the relay. The desktop writes the active account setting and answers with the accounts list, and that reply is lost on the way to the phone. The phone sends the same call with the same key again. The desktop does not switch a second time and returns the stored answer, in which B is active. The host of the running turn keeps account A until the turn ends. The next turn starts a new host in the folder of account B.

- **Switch call** (system.activateClaudeAccount · system.activateCodexAuthProfile): Params are one ID, accountId or profileId. Both methods are mutations. The relay refuses them without an idempotencyKey with error -32026.
- **Setting** (agent.chat.claude_account_id · agent.chat.codex_profile_id): The switch is one settings write on the desktop. It stops no process. The desktop then emits agent-accounts-changed to its own window.
- **Stored answer** (idempotencyKey): The desktop keeps the finished answer under the key. A retry with the same key and params gets it back, so a lost reply cannot cause a second switch.
- **Next turn** (CLAUDE_CONFIG_DIR): A Claude turn reads the active account once, when it starts, and spawns its host with the folder of that account. A Codex switch also stops no child process. A running Codex turn keeps its profile, and the next Codex turn signs in with the active profile.

A switch is one settings write. It stops no process, and it changes only which sign-in the next turn uses. Session history does not move.

| Step | Codex | Claude |
| --- | --- | --- |
| Check | Takes the profile lifecycle lock and checks that the profile exists. | Finds the account folder. An unknown account is an error. |
| Write | Sets agent.chat.codex_profile_id. | Sets agent.chat.claude_account_id. The default account has no folder of its own, so the switch to it deletes the setting. The switch also clears the cached Claude model catalog. |
| Running turn | Keeps the profile pair it started with. A steer message goes to that same Codex child process. | Keeps its host and its account folder until the turn ends. |
| Next turn of an existing session | Signs in with the profile that is active when the turn is admitted. The session history stays in the profile that stores it. A turn is refused when the active profile is not signed in. | Reads the setting once, when it starts, and runs on the active account. Every account reads and continues the same sessions. |

After the write the desktop emits the event agent-accounts-changed with the agent name to its own window. The sidebar and the settings page read again. A failed emit is only logged, because the switch is already stored and the desktop window catches up at its next periodic read.

The relay refuses a switch without a key with error -32026. The desktop stores the finished answer under the key. A retry with the same key and the same params gets that stored answer, and the same key with other params is a conflict.

| Read | Freshness | Limit |
| --- | --- | --- |
| Claude accounts | One reading per account for 60 seconds. A reading runs claude auth status --json with a 15 second timeout and, for a signed-in account, one usage read. | A failed read becomes errorMessage on that account. The list still returns. A finished Claude run drops the reading of its account. |
| Codex usage limits | One cached value for 60 seconds, read through account/rateLimits/read on the active profile. | The cache has no profile key and a switch does not clear it. For up to 60 seconds after a switch the phone can see the limits of the previous profile. |
| Usage history, weekly samples | The desktop saves each fresh limit reading in the table agent_usage_samples. It saves only the two all-model windows, the five-hour session window and the weekly window. | A reading that repeats the newest one within 10 minutes, with the same reset time and less than 0.5 percentage points of change, is skipped. Rows are kept for 35 days. The answer holds the last 14 days of the active account, oldest first. |
| Usage history, tokens | Codex only. dailyTokens and tokenSummary come from account/usage/read. The desktop caches one answer for 5 minutes and drops it when the active profile changes. dailyTokens holds at most 30 days. | Both fields are null for Claude and for a Codex sign-in without history, such as an API key. |

> **A sign-in never leaves the desktop** The relay allow-list has no method that starts, cancels, or finishes an agent sign-in, and no result in the table has a token field. For Codex, the Codex child process runs its own login and keeps the credential in the auth home of the profile. For Claude, the desktop runs claude auth login --claudeai in the account folder and never reads a token. Adding, signing in, signing out, and removing an account are desktop actions only.

- [Codex profiles and sign-in](https://www.hbcode.ai/docs/codex-app-server.md#accounts): The profile pair, the login flow, and which profile a session uses.
- [Claude accounts and usage](https://www.hbcode.ai/docs/claude-agent-sdk.md#accounts): Account folders, links to the main Claude folder, and the usage read.
- [Idempotency on the desktop](https://www.hbcode.ai/docs/relay-protocol.md#idempotency): The claim, the stored answer, and the conflict rule for every mutation.

## Local execution still uses remote services

Tools and file edits run on the desktop, and a model request still sends the task context to the provider of the session’s agent: OpenAI for Codex, Anthropic for Claude. Mobile control sends requests and the returned workspace content through the regional server. Dictation sends a recording to the transcription service you configured, and the agent’s Gemini tools send text or video to Gemini under your key.

So “desktop-owned” describes where execution and files live. It is neither an offline mode nor an end-to-end encryption claim.

- [Privacy documents](https://www.hbcode.ai/legal/eu/privacy): The regional policy and the provider information.
