---
type: research
title: Auth, Preemptive Refresh, and Link-Session
description: Self-contained OAuth PKCE, token rotation with preemptive refresh (codex-rs semantics), link-session exchange, and cross-surface Bearer
tags: [openai, oauth, pkce, auth, token-refresh, link-session]
timestamp: 2026-08-24
---

# Auth, Preemptive Refresh, and Link-Session

Part of [openai-research](openai-research.md). Sources: ChatGPT desktop (main + renderer), codex-rs (`login/src/auth/manager.rs`), openai-oauth (reference).

## Self-contained OAuth PKCE

- Public client: **`app_EMoamEEZ73f0CkXaXp7hrann`** — the same one used by the Codex CLI, the desktop app, and codex-rs
- Issuer: `https://auth.openai.com` — `/oauth/authorize` + `/oauth/token`
- Scope: `openid profile email offline_access api.connectors.read api.connectors.invoke` (the app-server login — `codex-rs/login/src/server.rs` build_authorize_url; the 4-scope variant is a reduced credential with 403 on `/f/conversation`)
- PKCE S256; extra params: `id_token_add_organizations=true`, `codex_cli_simplified_flow=true`
- Callback: `http://localhost:1455/auth/callback` (dual loopback `127.0.0.1` + `::1`; the desktop app uses deep links `com.openai.chat://` and has an additional remote-control flow with scope `codex.remote_control.enroll` + `allowed_workspace_id`)
- Alternative without callback: device-code (`/api/accounts/deviceauth/usercode`, verification `auth.openai.com/codex/device`)
- Exchange: `POST /oauth/token` **form-urlencoded** `{grant_type: authorization_code, code, redirect_uri, client_id, code_verifier}`
- The `chatgpt_account_id` and `chatgpt_plan_type` come from the JWT claim `https://api.openai.com/auth`

## Preemptive refresh (official codex-rs semantics)

`should_refresh_proactively()` (`login/src/auth/manager.rs:2894`):

1. Parse `exp` of the access-token JWT → refresh if `exp <= now + 5 minutes` (`CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES = 5`)
2. Fallback (unparseable exp): `last_refresh < now - 8 days` (`TOKEN_REFRESH_INTERVAL = 8`)

Refresh request: `POST /oauth/token` **JSON** `{client_id, grant_type: "refresh_token", refresh_token}` — response with **partial rotation** (`id_token`/`access_token`/`refresh_token` all Option; persist the ones returned + `last_refresh`).

Failure classification: `400 + invalid_grant`, `401`, and codes `refresh_token_expired`/`refresh_token_reused`/`refresh_token_invalidated` = **permanent** (re-login); everything else = transient. (openai-oauth uses 55min proactive / 5min margin — same family.)

Implemented in probe v2 (`scripts/probes/openai-codex-authorization.ts`) as a pre-flight: refresh before any request when the window closes.

## Cross-surface Bearer

**The same Codex OAuth Bearer authenticates directly on the chat submode backend** (`chatgpt.com/backend-api/*`) — that is how the chat submode of the desktop works (renderer → IPC → main injects the app-server token). Quota is separated by **endpoint**, not by token:

- Bearer → `/backend-api/codex/*` = Codex quota (work submode + Codex mode)
- Bearer → `/backend-api/*` (chat submode) = chat quota (non-Codex)
- Direction is one-way: Codex Bearer → chat submode ✓ (`/me` 200); chat cookies → codex ✗ (401)

## Link-session (token → web session)

`POST https://chatgpt.com/api/auth/link-session`

```
headers: Content-Type: application/json
         x-i-am-a-browser: true
         cookie: <anonymous cookies from a prior GET /api/auth/session>
body:    {"auth_token": "<codex access token>", "expires_in": <seconds until exp>}
→ HTTP 200 "OK" + Set-Cookie __oailb=<JWT> (HttpOnly, Secure, SameSite=Lax, Max-Age=3600)
```

The `__oailb` is the **derived web session** (1h, renewable by re-linking) — it authenticates `/api/auth/session` (payload with the sensitive-session `WARNING_BANNER`) and `/backend-api/me` **without Bearer**. Real usage in the app: **only webviews that render site pages** (checkout `/purchase/*`, workspace settings, Sites) — the chat does not use it. Observed limitation: the derived session authenticated `/me` but saw **0 conversations** in history — scope possibly restricted (open).

## Official persistence (codex-rs)

`auth.json` at `CODEX_HOME`: `{tokens: {id_token, access_token, refresh_token, account_id}, last_refresh}` — the same format openai-oauth writes. The desktop app keeps it in the native app-server (keychain).

## Token exchange inventory (note on "phantom tokens")

No phantom/derived-exchange token exists in the ecosystem (checked the desktop asar, app-server strings and codex-rs). The full token family: OAuth `{id_token, access_token, refresh_token}` (partial rotation), the `__oailb` derived web session from [link-session](#link-session-token--web-session) (1h, renewable by re-linking), the `resume_conversation_token` (topic-resume JWT, ES256, stream-scoped) and the one-time `attestation_challenge`; the only token **exchange** endpoint is `/api/auth/link-session`.

## TypeScript types (OAuth + refresh + link-session)

```ts
// ── OAuth ────────────────────────────────────────────────────────────────
export const OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
export const OAUTH_ISSUER = 'https://auth.openai.com'
export const OAUTH_CALLBACK = 'http://localhost:1455/auth/callback'
/** The app-server login scope (codex-rs build_authorize_url) — reduced variants
 *  (without the connectors) produce 403-authorization on /f/conversation. */
export const LOGIN_SCOPE =
  'openid profile email offline_access api.connectors.read api.connectors.invoke'
export const LOGIN_EXTRA_PARAMS = {
  id_token_add_organizations: 'true',
  codex_cli_simplified_flow: 'true',
} as const

export interface AuthorizeParams {
  response_type: 'code'
  client_id: typeof OAUTH_CLIENT_ID
  redirect_uri: typeof OAUTH_CALLBACK
  scope: typeof LOGIN_SCOPE
  code_challenge: string            // S256 of the verifier
  code_challenge_method: 'S256'
  state: string
  originator: string                // config originator ('Codex Desktop' on the desktop)
} & typeof LOGIN_EXTRA_PARAMS

// form-urlencoded exchange (authorization_code)
export interface TokenExchangeParams {
  grant_type: 'authorization_code'
  code: string
  redirect_uri: typeof OAUTH_CALLBACK
  client_id: typeof OAUTH_CLIENT_ID
  code_verifier: string
}

export interface RefreshResponse {
  access_token?: string
  id_token?: string
  refresh_token?: string            // partial rotation — persist only the returned ones
  token_type?: string
  expires_in?: number               // 864000 observed (10 days)
  scope?: string
  earliest_refresh_at?: number      // observed field — the OAuth-side minimum refresh time
  oai_is?: string                   // observed field — integrity marker for the OAuth token path
}
export interface RefreshParams {
  client_id: typeof OAUTH_CLIENT_ID
  grant_type: 'refresh_token'
  refresh_token: string
}

// ── persistence ──────────────────────────────────────────────────────────
export interface AuthTokens {
  id_token?: string
  access_token: string
  refresh_token?: string
  account_id?: string
}
export interface AuthFile {
  tokens: AuthTokens
  last_refresh: string              // ISO — the proactive-refresh fallback (8-day window)
}

// ── link-session ─────────────────────────────────────────────────────────
export interface LinkSessionBody {
  auth_token: string                 // the Codex access token
  expires_in: number                 // seconds until exp
}
export interface LinkSessionHeaders {
  'Content-Type': 'application/json'
  'x-i-am-a-browser': 'true'
  Cookie: string                     // anonymous cookies from a prior GET /api/auth/session
}
export interface OailbCookie {
  name: '__oailb'
  value: string                      // JWT
  HttpOnly: true
  Secure: true
  SameSite: 'Lax'
  MaxAge: 3600                       // 1 hour — renewable by re-linking
}

// ── token exchange inventory ─────────────────────────────────────────────
export interface TokenFamily {
  oauth: AuthTokens                  // partial rotation
  webSession: Pick<OailbCookie, 'name' | 'value'>      // __oailb (derived, 1h)
  resumeToken: string                // topic-resume JWT (ES256, stream-scoped)
  attestationChallenge: string       // one-time 'gAAAA…'
  // NO phantom tokens exist in the ecosystem (verified asar + binary + codex-rs)
}
```

## Cross-references

- Where the Bearer enters each flow: [chat-submode-protocol](chat-submode-protocol.md) and [codex-flow-protocol](codex-flow-protocol.md)
- Validation probes: [probes](probes.md)
- Flow A placement: [flow-definitions](flow-definitions.md)
